┌──────────────────────┐
│ action ─────┐ │
│ ▼ │
│ ╭─────────────╮ │
│ │ ▓ gate ▓ │ │
│ ╰─────────────╯ │
│ ╱ │ ╲ │
│ ✓ ? ✗ │
│ allow ask deny │
└──────────────────────┘
bareguard
One chokepoint between your agent and the world. Gates what the agent does — and whether its work is done — with deterministic checks only; it never runs an LLM. Single audit log. Hard caps that halt with a human in the loop. Small, one production dep.
One gate, two jobs.
1. Gate the action (Axis A) — every shell command, file write, network call, and spend passes through one Gate and comes back allow, deny, or ask a human. You scope it one of two mutually exclusive ways:
tools.allowlist(+bash/fs/net) — a closed allowlist naming exactly what's reachable. Simplest, built for one agent.rwxletters — tag every toolr(read — changes nothing),w(write — can be set back), orx(execute — can't be undone), borrowed straight from Unixchmod. Built for a fleet of agents: a human reviews by scanning forxinstead of reading N separate allowlists. See rwx + rwxmap below.
2. Gate the advance (rubric) — a human signs a list of checks before the work; the next step is denied until the agent's output is green. See Rubric.
Both share one audit log of everything the agent tried, and the same hard caps (budget, limits) that halt with a human in the loop instead of silently.
What it isn't — not a content filter, not a sandbox, not auth, and it never runs an LLM. It decides the action; it never runs it. A judge runs on your side and bareguard takes its answer as a fact (via annotate, or a caller-measured number in the rubric).
npm install bareguard
Requires Node.js >= 22. One production dep: proper-lockfile. Ships with TypeScript types (generated from JSDoc) — import { Gate, type GateConfig } from "bareguard" works out of the box, no @types package needed.
Pick ONE of tools.allowlist or rwx — never both on the same gate. (rwx mode replaces the tools/bash/fs/net block below with one rwx: {...} config — see rwx + rwxmap.)
import { Gate } from "bareguard";
const gate = new Gate({
tools: { allowlist: ["bash", "read", "write", "fetch"] },
bash: { allow: ["git", "ls"], denyPatterns: [/sudo/, /rm\s+-rf/] },
fs: { readScope: ["/srv/agent"], writeScope: ["/srv/agent/out"], deny: ["~/.ssh"] },
budget: { maxCostUsd: 5.00, maxTokens: 100_000 },
limits: { maxTurns: 50 },
// event.kind: "ask" | "halt" — your UX decides (TUI, Slack, web, PIN)
humanChannel: async (event) => ({ decision: "allow" }), // or "deny" / "topup" / "terminate"
});
await gate.init();
// gate.check never returns "askHuman" — it resolves that via humanChannel first.
const decision = await gate.check(action); // audit auto-redacts secrets (default-on)
if (decision.outcome === "allow") {
const result = await yourExecutor(action);
await gate.record(action, result); // result.costUsd / result.tokens
}fs.readScope and fs.writeScope are separate and deny-by-default (unset or [] = nothing allowed). Scope roots must be real paths, not symlinks. Detail: bareguard.context.md.
A tool that walks a directory (a grep/glob over a root) is gated as one action, so it must check each directory and file it touches with await gate.allows({ type: "read", tool, path }) and prune/skip on false; otherwise it can read under fs.deny. See gotcha 22 in bareguard.context.md.
Small files, each readable in a sitting. The gate runs them in a fixed order (deny → ask → scope → default, first match wins). Building tool-calling automation? Read primitives.json first, a machine-readable menu of every verb (when, import, signature, fails, example), generated from source. Also on unpkg: unpkg.com/bareguard/primitives.json.
- Scope what runs —
bash/fs/netbound which commands, paths, and domains are reachable.netgates on any action carrying aurl/args.urlfield, not onaction.type === "fetch". - Tier what's dangerous —
bash.classifyranks a command safe → destructive → super-destructive;contentdeniesrm -rf //DROP TABLEoutright. - Bound what accumulates —
budgetcaps spend, tokens, or any countable resource;limitscaps turns / children / depth — both halt with a human in the loop, shared across processes. - Gate on meaning, not text —
flagsreads a structured field's value (e.g. a memory engine'sprovenance) straight off the action, no regex. - Grade the work —
rubric: signed checks on the work (words, headings, phrases, quoted proof, table math, caller-measured numbers); the next step is gated on green. See Rubric. - Prove what happened —
secretsauto-redacts every audit line by default; oneauditJSONL joins each request to its outcome and its approval.
Full per-primitive reference lives in the Usage Guide and Integration Guide. Tested across Linux + macOS + Windows × Node 22 + 24.
Borrowed from Unix file permissions (chmod). Tag every tool and bash command once with one letter:
| Letter | Means | Examples |
|---|---|---|
r — read |
Looks, changes nothing | read, git status, a search, an HTTP GET |
w — write |
Changes something, but a later write can set it back | write a file, git commit, update a record |
x — execute |
Cannot be undone — unsure? tag it x |
deploy, git push, send an email, make a payment |
Then give each agent a three-letter ceiling, read like chmod: a dash means "not allowed". "r--" = read only, "rw-" = read + write, "rwx" = everything. An agent can run a tool only if its ceiling holds that tool's letter. A human reviews the whole fleet by scanning for x instead of reading per-agent allowlists:
const gate = new Gate({
rwx: {
agent: "fixer",
agents: { researcher: "r--", fixer: "rw-", deployer: "rwx" },
tools: { read: "r", write: "w", deploy: "x" },
bash: { "git status": "r", "git commit": "w", "git push": "x" },
},
});An unlisted tool, command, or agent is denied, never asked; add a row, don't widen a letter. To have a human decide instead of a flat deny when an agent lacks a listed letter, name that letter in rwx.askUnheld (e.g. askUnheld: ["x"]; default [] = deny); unlisted, joined and malformed cases still deny. See rwx.askUnheld. A starter file ships at bareguard.rwx.json; gate.add(entries) / addToGates(gates, entries) grow a running gate tighten-only. Full contract: bareguard.context.md.
rwxmap [WIP] labels every OpenAPI operation r/w/x as a starting point and marks unsure rows (tight/loose) for human review; its exporter writes a bareguard rwx config, and askOn: "loose" sends unreviewed rows to a human. bareguard never imports it. See marker-carrying entries.
A rubric is a signed list of deterministic checks bound to a goal. bareguard mints a verdict (green / red / stopped) and, at a checkpoint marked gating, denies the advance action unless it is green. The Axis A floor runs first; a green verdict never turns a floor deny into an allow.
Three roles:
- The runner (your harness) drafts the rubric from
rubricVocabulary, drives the agent, takes any measurements and callsgate.checkStep. - The human signs the rubric before the work (
rubricSha) and accepts the result after (requiresHuman). - bareguard grades and gates. It never sees the agent's reasoning, and the agent never sees the rubric: a retry is fed only the gap (
gate.drainGaps()).
The rules at a glance
| Kind | Rule | Checks |
|---|---|---|
| Always on | happened |
The output exists: non-empty text, or an object with a key. |
| Size | nonEmpty, maxWords/minWords, maxLines, maxParagraphs, maxLineWords, listItems, maxListItems/minListItems |
Field has content; word, line, paragraph or words-per-line count within a bound; exactly N, at most N or at least N outer list items. |
| Shape | sections, sectionOrder, sectionWords, allowedKeys |
Headings present and in order; words per section; no unlisted object keys. |
| Words | mustCarry, carriesAny, carriesNone, blockLines, in/notIn, atMost |
All phrases carried; at least one phrase on word edges; none on word edges; per-block phrases; value in or out of a list (exact; opt-in ignoreCase); rank cap. |
| Format | jsonValid |
The field parses as JSON. |
| Numbers | max/min |
A number field is at most, or at least, a bound. |
| Proof | cited, complete |
Each claim's quote is in the frozen input; every listed item is covered. |
| Tables | cells, table, diff |
Recompute counts, sums and lookups over rows; table shape; before/after changes. |
| Measured by your harness | notWorse, commandExit, patternAbsent, filesChanged |
Your measurements: score not worse, exit code, no bad pattern, only allowed files. |
Per-rule detail (fields, examples, what turns red): rubric-rules.md. Exact field shapes: rubricVocabulary.
import { Gate, createRubric, rubricSha } from "bareguard";
const spec = { schema: 1, goal: "Write the status report", inputs: [],
checkpoints: { draft: { gating: true, checks: [
{ id: "short", rule: "maxWords", field: "text", value: 120 },
{ id: "heads", rule: "sectionOrder", field: "text", names: ["Summary", "Risks"] },
] } }, maxReds: 3 };
createRubric(spec); // throws on a bad shape
const gate = new Gate({
rubric: { spec, sha256: rubricSha(spec), advanceOn: ["step.done"] }, // the human signs rubricSha(spec)
audit: { path: null }, humanChannel: async () => ({ decision: "allow" }),
});
await gate.init();
const advance = (r) => ({ type: "step.done", checkpoint: "draft", outputSha: r.outputSha });
let r = await gate.checkStep("draft", "Summary\nall good"); // no Risks heading
console.log(r.verdict, (await gate.check(advance(r))).rule); // red rubric.red
r = await gate.checkStep("draft", "Summary\nall good\nRisks\nnone");
console.log(r.verdict, (await gate.check(advance(r))).outcome); // green allowadvanceOn matches an action's type or tool: give the advance a reserved name no real tool uses (e.g. fwdloop.advance), or the rubric would run on that tool's ordinary calls. Deny rules, accept: "later" / gate.recordAccept, and resuming with a stable runId: bareguard.context.md, rubric-prd.md.
annotate is transport for a fact you computed (bareguard never decides it): await gate.annotate({ surface: true, verdict: "broke", where: "..." }), then gate.drainAnnotations(). Reversibility is read from the gated action's type via axisB.reversible, never from the fact. The gate keeps the newest 256 facts (a flood of surface:false facts evicts first; each eviction run is recorded as an annotate_dropped audit row), and a meta over 1000 bytes loses its largest top-level keys, not all of them (_truncated, bytes and _droppedKeys say what went). Contract: bareguard.context.md.
Local-first, composable agent infrastructure. Same API patterns throughout — mix and match, each module works standalone.
Core — the brain, the gate, the memory.
- bareagent — the think→act→observe loop. Goal in → coordinated actions out. Replaces LangChain, CrewAI, AutoGen.
- bareguard — the single gate every action passes through. Action in → allow / deny / ask-a-human out. Work in → green / red. Replaces hand-rolled allowlists and scattered policy code.
- litectx — tree-sitter code + memory graph with activation decay, plus lightweight context engineering (write · select · compress · isolate). Query in → ranked context out.
Add-on for bareguard — labels API operations for the gate.
- rwxmap [WIP] — labels every OpenAPI operation r/w/x as a starting point, marked for review. API spec in → per-operation letter out. The harness runs it offline; the file it writes goes straight into bareguard's
rwxmode. bareguard never runs or depends on it.
Optional reach — give the agent hands.
- barebrowse — a real browser for agents. URL in → pruned snapshot out. Replaces Playwright, Selenium, Puppeteer.
- baremobile — Android + iOS device control. Screen in → pruned snapshot out. Replaces Appium, Espresso, XCUITest.
- beeperbox — 50+ messaging networks via one MCP server (headless Beeper Desktop in Docker). Chat in → unified message stream out. Replaces Twilio, per-platform bot APIs.
Wiring it into a real agent? Hand your AI assistant bareguard.context.md (from node_modules/bareguard/) and describe your setup — it has the humanChannel patterns, shared-budget-across-processes setup, eval order, audit format, and 10+ wiring recipes.