For AI assistants and developers wiring bareguard into a project. v0.7.1 | Node.js >= 20 | 1 production dep (
proper-lockfile) | ships TypeScript types | Apache-2.0Full design spec:
docs/01-product/bareguard-prd.md— unified PRD (v0.7).
bareguard is the action-side runtime policy library every agent uses (or
should). One class (Gate), three call sites (redact, check, record),
thirteen primitives (bash, fs, net, budget, content, flags, secrets, audit,
limits, tools, defer-rate, spawn-rate, approval). Single audit log per
agent family. One humanChannel callback for all human escalations.
npm install bareguard
One entry point:
import { Gate, redact, defaultAuditPath, BudgetUnavailableError, SAFE_DEFAULT_DENY_PATTERNS, SAFE_DEFAULT_ASK_PATTERNS, globToRegex, matchAny } from "bareguard"
| I want to... | Use these |
|---|---|
| Gate every action my agent takes | new Gate({...}) + await gate.check(action) before exec, await gate.record(action, result) after |
| Stop runaway spend or runaway turns | budget.maxCostUsd, budget.maxTokens, limits.maxTurns — all halt severity |
| Cap a non-money resource (writes, rows, bytes) | budget.resources: { writes: 100 } — halts on budget.resource.<name>; accrued from result.counts: { writes: 1 } (cumulative, decomposition-proof) |
| Warn before a hard cap (monitor, don't block) | budget.softRatio: 0.8 — emits a non-blocking budget_warn audit line at 80% of any cap; never halts |
| Join a request to its outcome in the audit | every check() returns decision.aid; pass it to record(action, result, { aid }) (or use gate.run, which threads it) — joins even byte-identical actions |
| Cap concurrent / nested children | limits.maxChildren, limits.maxDepth — action severity |
| Allowlist commands per-tool | bash.allow: ["git", "ls"] |
| Deny destructive command patterns | bash.denyPatterns: [/sudo/, /rm\s+-rf/] |
| Tier shell commands by severity → map to ceremony | bash.classify: true — classifies each command safe/destructive/super_destructive (Linux/macOS/Windows); tiers 2–3 raise the ask with event.classification + event.tier; the humanChannel maps severity → ceremony (PIN, 2-key, auto-deny). Best-effort/defeatable, not a sandbox. Tune via extraDestructive / extraSuperDestructive / reclassify |
| Restrict file paths the agent can read/write | fs.readScope, fs.writeScope, fs.deny |
| Egress allowlist / private-IP block | net.allowDomains, net.denyPrivateIps: true |
| Share budget across parent + child processes | budget.sharedFile: "/path/budget.json" (uses proper-lockfile) |
| Reconstruct family tree from audit | one file at $XDG_STATE_HOME/bareguard/<root-run-id>.jsonl; grep parent_run_id |
| Keep secrets out of the audit log | Default-on (BG-1): keys apiKey/api_key/authorization + Bearer …/sk-… values are blanked on every audit line with no config. Extend with secrets.keys: ["X-Api-Key", "*_token"]; add value rules with secrets.envVars: ["ANTHROPIC_API_KEY"] / secrets.patterns: [/sk-[A-Za-z0-9]{40,}/]; opt out with secrets.redactKeys: false |
| Ask the human before destructive verbs | safe-default content.askPatterns ship; provide humanChannel callback |
| Deny/ask on a structured field (e.g. a memory adopter's verdict) | flags: { provenance: { web: "ask" }, injectionRisk: { high: "deny" } } — reads action[field] directly, before the allowlist |
Confirm the human before every call of a tool (e.g. every bash) |
flags: { type: { bash: "ask" } } — gates the always-present type field; fires even when the tool is allowlisted, routes through the one humanChannel (no separate approval channel) |
| Pre-filter MCP catalog by what the agent CAN call | await gate.allows(action) — pure boolean, no audit, no budget delta |
| One-shot wrapper: check + execute + record | await gate.run(action, executor) |
| Stop the run cleanly with a paper trail | await gate.terminate(reason) |
| Get deterministic stats at halt time | await gate.haltContext() — spend, turns, rate over audit log |
| Run tests without temp files | audit: { path: null } — in-memory gate.audit.entries (v0.4) |
| Halt BEFORE overspend (not after) | budget: { strict: true } — pre-flight halt via trailing-avg projection (v0.4, opt-in) |
| Route halt prompts in multi-tenant | event.action._ctx is preserved verbatim on halt events (v0.4) |
Most projects start with Gate({ tools, budget, limits, humanChannel }). Add primitives as needed.
import { Gate } from "bareguard";
const gate = new Gate({
tools: { allowlist: ["bash", "read", "fetch"] },
bash: { allow: ["git", "ls"], denyPatterns: [/sudo/, /rm\s+-rf/] },
budget: { maxCostUsd: 5.00, maxTokens: 100_000 },
humanChannel: async (event) => {
// Your UX: TUI, Slack, web button, PIN — bareguard knows none of it.
return { decision: "allow" };
},
});
await gate.init();
// In your agent loop:
const action = { type: "bash", cmd: "git status" };
const decision = await gate.check(action); // audit auto-redacts if `secrets` is set
if (decision.outcome === "allow") {
const result = await yourExecutor(action); // your code
await gate.record(action, result); // budget + audit
}
// decision.outcome is always "allow" or "deny" — never "askHuman".
// bareguard resolves askHuman internally via humanChannel.bareguard collapses every human escalation into ONE callback. You register it;
bareguard calls it whenever a content askPattern matches OR a halt-severity
limit is hit. The runner only ever branches on terminal allow / deny.
const gate = new Gate({
// ...
humanChannel: async (event) => {
// event.kind: "ask" | "halt"
// event.action: the action being checked (ALWAYS present in v0.4+; for
// halts, the cap was already exhausted on entry — this
// action didn't trip it, but it carries any caller-attached
// routing context like action._ctx.chatId).
// Use event.kind === "halt" as the discriminator, NOT
// event.action == null.
// event.severity: "action" | "halt"
// event.rule: e.g., "content.askPatterns" | "budget.maxCostUsd"
// event.reason: human-readable
// event.context: deterministic stats (spend, turns, rate, time-elapsed)
if (event.kind === "halt") {
// Run-level pause. Your UX should be loud and blocking.
// Show event.context (spend, rate, last 5 ticks) so the human can decide.
const choice = await loudPrompt(event);
if (choice === "topup") return { decision: "topup", newCap: 10.00 };
if (choice === "terminate") return { decision: "terminate", reason: "operator stopped" };
return { decision: "deny" };
}
// event.kind === "ask" — per-action confirmation
const ok = await inlinePrompt(event);
return { decision: ok ? "allow" : "deny" };
},
});What bareguard does with each return value:
{ decision: "allow" }— emitphase:"approval"audit line, gate.check returns terminalallow.{ decision: "deny", reason }— emitphase:"approval", gate.check returns terminaldeny(severity preserved from original ask/halt).{ decision: "topup", newCap, reason }— only meaningful for halt. raises the cap atomically, emitsphase:"topup", re-evaluates gate.check. If still halts after re-eval, calls humanChannel again (capped at 5 iterations to prevent loops).{ decision: "terminate", reason }— emitphase:"approval"+phase:"terminate", gate becomes sticky-terminated, every subsequent check returns deny.
If humanChannel is not registered and the eval reaches askHuman/halt: gate.check returns { outcome: "deny", severity: "halt", rule: "...originalRule...", reason: "...originalReason... (no humanChannel registered)" }. Never silently allow.
Optional humanChannelTimeoutMs (default: unset = wait forever). If set, bareguard races your channel against a timer; if the timer wins, gate.check returns { outcome: "deny", severity: "halt", rule, reason: "humanChannel timeout after Xms" } and emits a phase:"approval" audit line with the timeout reason. The timeout always denies — there is no "allow on timeout". If you want allow-on-timeout for an autonomous fleet, implement it inside your own humanChannel (return { decision: "allow" } after your own setTimeout) so the policy is explicit in user code, not a bareguard default. The pending channel promise is not cancelled; if it later resolves, the result is dropped (the agent will re-prompt on the next gate.check).
Parent and children share one budget file via proper-lockfile.
// Parent
const gate = new Gate({
budget: { maxCostUsd: 5.00, sharedFile: "/run/agent/budget.json" },
limits: { maxChildren: 4, maxDepth: 3 },
humanChannel: parentHumanChannel,
});
// When parent spawns a child, set env vars:
const child = spawn("node", ["worker.js"], {
env: {
...process.env,
BAREGUARD_BUDGET_FILE: "/run/agent/budget.json",
BAREGUARD_AUDIT_PATH: gate.audit.filePath, // ONE audit file for the whole family
BAREGUARD_PARENT_RUN_ID: gate.runId,
BAREGUARD_SPAWN_DEPTH: String(gate.spawnDepth + 1),
},
});
// Child code (worker.js):
const childGate = new Gate({
budget: { /* cap inherited from shared file */ },
// path / parent_run_id / spawn_depth all picked up from env vars automatically
});
await childGate.init();The child writes to the same audit file via O_APPEND atomicity (POSIX
guarantees < 4KB; Windows uses lock fallback automatically). Reconstruct the
family tree with one grep:
grep '"parent_run_id":"<parent-run-id>"' run.jsonlRedaction is DEFAULT-ON (BG-1). Even with no secrets block, the gate blanks
a narrow set on every audit line: case-insensitive keys apiKey / api_key /
authorization (by name, regardless of value) plus value patterns Bearer …
and sk-…. This is the backstop for the unknowing adopter who threads a live
provider into _ctx — action._ctx.provider.apiKey never lands raw on disk.
Redaction is audit-only and non-mutating: eval/execute see the real action
(policy matching is never weakened) and the caller's object is untouched.
The default is deliberately narrow — it excludes *_token / *_secret
globs because those false-positive on page_token / csrf_token and would
corrupt the audit. Extend or opt out via config:
const gate = new Gate({
secrets: {
keys: ["X-Api-Key", "*_token"], // extend default-on key set (suffix glob ok)
envVars: ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"], // also blank these env *values*
patterns: [/ghp_[A-Za-z0-9]{36}/], // and these value patterns
// redactKeys: false, // disable the default-on backstop entirely
// // (explicit envVars/patterns/keys still apply)
},
// ...
});
// No pre-redaction. Eval runs on the real action; only the persisted log is masked.
await gate.check(rawAction);
await gate.record(rawAction, rawResult);
// `redact()` is exported for ad-hoc redaction outside the gate (also default-on).
import { redact } from "bareguard";
const masked = redact(anyObject, { patterns: [/sk-[A-Za-z0-9]{40,}/] });Format: [REDACTED:key=apiKey] for key-name matches, [REDACTED:ANTHROPIC_API_KEY]
for env-var matches, [REDACTED:pattern=sk-...] for pattern matches. Never
shows full secrets or the suffix. Env-var redaction needs values ≥ 8 chars (a
short env var like a port isn't redacted). Caller is still responsible for the
shape of results before gate.record — but the same redactor runs over them.
PRE-EVAL (cross-cutting, all halt severity)
P-1. safeAction(action) ← own-props-only null-proto copy (no inherited field can flip a decision); run() also executes this copy
P0. secrets.redact(action) ← mutation, not a decision step
P1. budget.check() ← halt if exceeded
P2. limits.maxTurns ← halt if exceeded
P3. gate.terminated check ← halt if previously terminated
THE 6 STEPS (first match wins; all action severity unless noted)
1. tools.denylist → deny
2. content.denyPatterns → deny (universal, e.g., DROP TABLE)
2b. flags deny → deny (action[field] value maps to "deny", e.g. injectionRisk:"high")
3. per-action-type deny rules → deny
bash.denyPatterns / bash.allow (when action.type === "bash")
fs.deny / fs.readScope / fs.writeScope (when read/write/edit)
net.allowDomains / net.denyPrivateIps (when fetch)
limits.maxChildren / limits.maxDepth (when spawn)
tools.denyArgPatterns (any tool with matching args)
4. content.askPatterns → askHuman (fires even on allowlisted tools)
4b. flags ask → askHuman (action[field] value maps to "ask", e.g. provenance:"web")
5. tools.allowlist enforcement → set+match: allow; set+miss: deny (rule: tools.allowlist.exclusive)
6. default → allow
Universal denies first (1-2b-3), universal asks second (4-4b), capability scope third (5), default last (6). Allowlist is scope-only — does not silence asks (this is a v0.5 amendment to v0.4's original spec). flags reads a named field's value directly (action.provenance / action.injectionRisk), never JSON.stringify — so an adopter passes a structured verdict, not text. Both arms sit before the allowlist, so a flagged action is gated even when its type is allowlisted (floor supremacy). Rule id is flags.<field> (e.g. flags.injectionRisk).
| Scenario | Severity | What the runner does |
|---|---|---|
tools.denylist match |
action | return error to LLM, continue loop |
content.denyPatterns match (e.g., DROP TABLE) |
action | return error to LLM, continue loop |
flags.<field> deny (e.g., injectionRisk: "high") |
action | return error to LLM, continue loop |
flags.<field> ask (e.g., provenance: "web", after humanChannel resolves) |
action | terminal allow or deny |
bash.denyPatterns (e.g., sudo) |
action | return error to LLM, continue loop |
fs.deny, fs.readScope, fs.writeScope |
action | return error to LLM, continue loop |
net.allowDomains, net.denyPrivateIps |
action | return error to LLM, continue loop |
tools.allowlist.exclusive (not in scope) |
action | return error to LLM, continue loop |
tools.denyArgPatterns |
action | return error to LLM, continue loop |
fs.invalidPath, net.invalidUrl, bash.invalidCmd (path/url/cmd present but not a string) |
action | return error to LLM, continue loop |
limits.maxChildren, limits.maxDepth |
action | return error to LLM, continue loop |
content.askPatterns (after humanChannel resolves) |
action | terminal allow or deny |
budget.maxCostUsd, budget.maxTokens |
halt | escalate to humanChannel; never bubble to LLM |
limits.maxTurns |
halt | escalate to humanChannel; never bubble to LLM |
gate.terminated (after previous terminate) |
halt | all subsequent checks deny+halt; agent loop exits cleanly |
Action severity: the LLM sees a structured error and can adapt (try a different tool, ask the user, give up gracefully).
Halt severity: the run is over unless a human approves a topup. The LLM must not see this — it would loop trying to retry. bareguard handles this by calling humanChannel internally and only returning terminal allow/deny to the runner.
import {
Gate, // the orchestrator
redact, // standalone redaction helper
defaultAuditPath, // path resolver matching the env-var convention
BudgetUnavailableError, // thrown on lock failure / corrupt budget file
SAFE_DEFAULT_DENY_PATTERNS, // exposed in case you want to extend
SAFE_DEFAULT_ASK_PATTERNS, // exposed in case you want to extend
routeAnnotation, // pure Axis-B routing fn (surface × reversible × knob)
globToRegex, matchAny, // glob helpers (v0.1: `*` only)
} from "bareguard";
// Ships TypeScript types (generated from JSDoc; v0.5). Config + decision/event
// shapes are fully typed — no @types package needed. Named config types are
// importable from the root or the `bareguard/types` subpath:
// import { Gate, type GateConfig } from "bareguard";
// Gate methods (all async unless noted):
const gate = new Gate(config);
await gate.init(); // creates audit file, reads/writes shared budget
gate.redact(action); // SYNC — pre-eval secrets redaction
await gate.check(action); // returns { outcome, severity, rule, reason }
await gate.allows(action); // pure boolean — no audit, no budget delta
await gate.record(action, result); // updates budget + emits record audit line
await gate.run(action, executor); // check + execute + record (one call)
await gate.annotate(fact); // Axis B: buffer a return-time judge fact (rides the next ask)
gate.drainAnnotations(); // SYNC — return + clear buffered facts (agent feedback)
await gate.terminate(reason); // sticky terminate
await gate.raiseCap(dimension, newCap); // explicit cap raise (separate from humanChannel topup)
await gate.haltContext(); // deterministic stats over audit logOne JSONL file. Default path: $XDG_STATE_HOME/bareguard/<root-run-id>.jsonl. Override via audit.path in config or BAREGUARD_AUDIT_PATH env var.
Phases:
phase |
When | Key extra fields |
|---|---|---|
gate |
every gate.check() decision |
action, decision, severity, rule, reason |
record |
every gate.record() |
action, result (incl. costUsd, tokens) |
approval |
humanChannel returned a decision | decision, reason, newCap |
halt |
dedicated grep target on halt | dimension (costUsd/tokens/turns), spent, cap, rule, awaiting |
topup |
runner / humanChannel raised a cap | dimension, oldCap, newCap |
terminate |
gate terminated (graceful) | reason |
Every line carries: ts, seq, run_id, parent_run_id, spawn_depth. Use parent_run_id to reconstruct the family tree.
# What stopped the run last night?
grep '"phase":"halt"' run.jsonl | jq
# Spend total
jq 'select(.phase=="record") | .result.costUsd' run.jsonl | paste -sd+ | bc
# Just child runs
jq 'select(.spawn_depth >= 1)' run.jsonl- Serial calls per Gate instance.
gate.checkandgate.recordMUST be called serially perGate. Concurrent calls produce undefinedseqordering. Multiple Gate instances (parent + child processes) MAY run concurrently — they're independent. - Caller redacts results. bareguard auto-redacts actions if
secretsconfig is provided; results are the caller's responsibility beforegate.record. humanChannelreturns a structured decision.{ decision: "allow" | "deny" | "topup" | "terminate", newCap?, reason? }. Neverundefined. Throwing or returning unknown decisions is treated as deny.- No
gate.checkBatchin v0.1. If a runner needs concurrent action evaluation, that's v0.2. - bareguard never invokes I/O on its own. It calls a function YOU registered (
humanChannel,executoringate.run). All TUIs, prompts, and PINs are runner-side.
These are deliberately NOT in bareguard. Don't look for them — build them or use a different layer.
| Pattern | Not built in because | How to do it |
|---|---|---|
| Content guardrails (toxicity, PII, schema) | Different layer — model output, not action | guardrails-ai for content; bareguard for actions. They compose. |
| Sandboxing (containment of effects) | Different layer — bareguard prevents calls; sandbox contains effects | Docker / gVisor / Firecracker. Wrap your executor in a sandbox call. |
| Identity / authn / authz | bareguard sees actions, not principals | Pass a different Gate instance per user. |
| PIN / second-factor for approvals | Authentication is the runner's UX | Implement in humanChannel: prompt for PIN before returning decision. |
| Rate limits against external APIs | The API's job, or a separate rate-limit lib | Wrap the executor; bareguard doesn't know about external services. |
| Scheduler / daemon | bareguard is a library, not a service | bareagent's defer tool + cron + a wake.sh script. |
| Telemetry / SaaS / dashboards | Bare-suite philosophy | JSONL is grep-able. Pipe it to whatever you want — Datadog, Loki, S3 are caller's adapters. |
| Per-tool ask-patterns | Use tools.denyArgPatterns for tool-specific rules; ask is universal |
If you really need a per-tool ask, narrow content.askPatterns to match the tool name + the dangerous arg. |
- Allowlist does NOT silence asks. Allowlisting
bashdoes not bypasscontent.askPatterns: [/\bdelete\b/i]. Adeletein the bash command still triggers humanChannel. This is intentional (v0.5 §4) — the v0.4 short-circuit was a foot-gun that silently disabled safe defaults. To silence, narrowcontent.askPatterns. - Budget caps are SOFT. Cross-process budget can be exceeded by one action's spend before next refresh. Halt fires reliably on the next check after a record. Don't rely on hard cents-precision enforcement.
- Audit line size capped at 3.5KB. POSIX
O_APPENDatomicity requires < PIPE_BUF (4KB). Largeraction.argsare auto-truncated with[TRUNCATED:...]markers. Don't put 10MB blobs in your action. - Glob is
*-only in v0.1. No?, no[abc], no escapes.mcp:*/admin_*matches anything in the middle, including/. v0.2 may add**. - Secrets redaction is default-on but narrow. Key-aware redaction (BG-1) fires with no config for
apiKey/api_key/authorization+Bearer …/sk-…values — but NOT for*_token/*_secret-named keys (false-positive risk onpage_token); add those viasecrets.keys. Env-var redaction additionally needs values ≥ 8 chars (a short env var likePORT=5432isn't redacted — likely not a secret and would over-match). Disable the whole default-on backstop withsecrets.redactKeys: false. gate.allows()is a catalog pre-filter, NOT an authorization gate. It returnstruefor askHuman actions (so ask-gated tools still show in a catalog and the human is prompted at invoke time) — it only returnsfalsefor outrightdeny/halt. Always callgate.check()before executing; never useallows()as the security decision.gate.run(action, executor)returns the executor's result on allow, OR{ error: { type: "policy_denied", rule, reason, action_summary } }on deny. Doesn't throw. Halt severity insiderunreturns the same error shape withseverity: "halt".- Topup loop max 5 iterations. If humanChannel returns topup but the new cap still halts, bareguard re-calls humanChannel up to 5 times before forcing a deny+halt with reason
"topup loop exceeded 5 iterations". Defensive guard against runaway humans. - Children inherit the audit file via env, not config. Set
BAREGUARD_AUDIT_PATH(andBAREGUARD_BUDGET_FILE,BAREGUARD_PARENT_RUN_ID,BAREGUARD_SPAWN_DEPTH) when spawning. The child'snew Gate({})picks them up automatically. gate.terminate()is sticky. Once called, every subsequentgate.checkreturns deny+halt withrule: "gate.terminated". Your runner's loop should exit cleanly on this rule.- Windows uses a lock fallback for audit.
process.platform === "win32"triggersproper-lockfilearound audit appends. Slower than the POSIXO_APPENDfast path but correct. limits.maxTurnscounts everygate.recordcall, not "LLM rounds". One LLM record + one tool record per round means 1 round = 2 turns. For a "tool-calling-rounds" budget uselimits.maxToolRounds: N(v0.4.2) — sibling halt counter that ticks only on records whereaction.type !== "llm". Name your LLM recordstype: "llm"to opt in.- bash / fs / net primitives accept either flat or nested action shape (v0.4.1).
{type: "bash", cmd}and{type: "bash", args: {cmd | command}}both work; same for fs (path) and net (url). Flat wins when both are set. Makes wireGate-style{type, args, _ctx}adapters compose without a translation layer. - fs scope/deny is lexically normalized, not symlink-resolved.
./..segments are collapsed before matching, and scopes/deny entries match on path segments (so/app/datadoes NOT cover/app/data-secrets) — traversal like/app/data/../../etc/passwdcan't escapereadScope: ["/app/data"]. But a symlink inside an allowed scope that points outside it is not caught; canonicalize (fs.realpath) before the gate if your filesystem has untrusted symlinks. net.denyPrivateIpsis hostname-based, not post-DNS. It blocks IPv4 private/loopback/link-local (incl. cloud-metadata169.254.169.254and0.0.0.0), IPv6 loopback/ULA/link-local (brackets stripped), and IPv4-mapped IPv6. It does NOT resolve DNS, so a public hostname that resolves to a private address (DNS rebinding) is not caught — resolve-then-check upstream if that's in your threat model. Pair withnet.allowDomainsfor a positive egress allowlist.bash.allowfails closed on shell metacharacters (v0.4.5). Whenbash.allowis set, any command containing;,|,&,$,`,(,),<,>, or a newline is denied (rulebash.allow.shellMeta) — a prefix allowlist can't bound what runs after a chain/pipe/substitution. This also denies legitimate pipes likegit log | head. If you need chaining, don't rely onbash.allowas the boundary — usecontent.denyPatterns(which scans the whole command) orbash.denyPatterns.- Audit auto-redacts on every line — DEFAULT-ON (BG-1), not just when
secretsis configured. Key-aware redaction (apiKey/api_key/authorization+Bearer …/sk-…) runs with zero config;secrets.envVars/patterns/keyslayer on top;secrets.redactKeys: falsedisables the default-on backstop. The gate redactsaction,result,reason,where, andmetaat write time. Eval runs on the unredacted action (matching is never weakened) and the redactor is non-mutating (the caller's object is untouched); only the persisted log is masked. Don't pre-redact beforecheck()/record()— it's redundant and would weaken policy matching. bash.classifypatterns are ReDoS-safe (linear-time). The shipped severity corpus avoids catastrophic backtracking — a crafted command string (e.g.rm -rfrfrf…) classifies in linear time (1 MB ≈ 16 ms), so a hostile/confused agent can't hang the gate via the classifier. If you add your ownextraDestructive/extraSuperDestructivepatterns, keep them linear too: avoid multiple consecutive unbounded quantifiers over the same class ([a-z]*x[a-z]*y[a-z]*); prefer non-consuming lookaheads. Defense-in-depth: classify runs at the ask step (4), after the deny floor (steps 1–3) — it can only escalate to a human ask, never downgrade a deny. It is best-effort UX tiering, not a sandbox.
const result = await gate.run(action, async (action) => {
return await yourExecutor(action);
});
// result is either the executor's return OR { error: { type: "policy_denied", ... } } on deny.import { Gate } from "bareguard";
const gate = new Gate({
tools: { allowlist: ["bash", "fetch", "mcp:linear.app/*"] },
});
await gate.init();
const catalog = await mcpServer.listTools(); // your code
const visible = [];
for (const t of catalog) {
if (await gate.allows({ type: t.name, args: t.exampleArgs })) {
visible.push(t);
}
}
// `visible` excludes tools that would be denied. Tools that would ASK
// the human at invoke time STAY visible — the human is in the loop later.// parent.js
import { Gate } from "bareguard";
import { spawn } from "node:child_process";
const gate = new Gate({
budget: { maxCostUsd: 5.00, sharedFile: "/run/agent/budget.json" },
limits: { maxChildren: 4, maxDepth: 3 },
humanChannel: async (event) => { /* loud prompt */ },
});
await gate.init();
const decision = await gate.check({ type: "spawn", config: "child" });
if (decision.outcome === "allow") {
const child = spawn("node", ["child.js"], {
env: {
...process.env,
BAREGUARD_BUDGET_FILE: gate.budget.sharedFile,
BAREGUARD_AUDIT_PATH: gate.audit.filePath,
BAREGUARD_PARENT_RUN_ID: gate.runId,
BAREGUARD_SPAWN_DEPTH: String(gate.spawnDepth + 1),
},
});
await gate.record({ type: "spawn", config: "child" }, { child_run_id: child.pid, costUsd: 0 });
}import readline from "node:readline";
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise(r => rl.question(q, r));
const gate = new Gate({
humanChannel: async (event) => {
if (event.kind === "halt") {
console.log(`\n[HALT] ${event.rule}: ${event.reason}`);
console.log(`Spent: $${event.context.spent.costUsd.toFixed(4)} of $${event.context.cap.costUsd}`);
console.log(`Last 5 ticks: ${event.context.spendRate.last5.map(x => x.toFixed(4)).join(", ")}`);
const choice = await ask("(a)llow more — by how much? (t)erminate / (d)eny: ");
if (choice.startsWith("t")) return { decision: "terminate", reason: "operator stopped" };
if (choice.startsWith("d")) return { decision: "deny" };
const amt = parseFloat(await ask("New cap (USD): "));
return { decision: "topup", newCap: amt, reason: "operator approved topup" };
}
// event.kind === "ask"
console.log(`\n[ASK] ${event.rule}: ${event.reason}`);
console.log(`Action: ${JSON.stringify(event.action).slice(0, 200)}`);
const ok = (await ask("Allow? (y/N): ")).toLowerCase().startsWith("y");
return { decision: ok ? "allow" : "deny" };
},
});const gate = new Gate({
humanChannel: async (event) => {
const msg = await slack.chat.postMessage({
channel: "#approvals",
text: `*${event.kind.toUpperCase()}*: ${event.rule}\nReason: ${event.reason}\n\`\`\`${JSON.stringify(event.action || event.context, null, 2).slice(0, 1500)}\`\`\``,
});
await slack.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "white_check_mark" });
await slack.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "x" });
// poll for which reaction the operator added (or wait for events API)
const choice = await waitForReaction(msg, ["white_check_mark", "x"], 300_000);
if (!choice) return { decision: "deny", reason: "approval timed out" };
if (choice === "x") return { decision: "deny" };
if (event.kind === "halt") return { decision: "topup", newCap: event.context.cap.costUsd * 2 };
return { decision: "allow" };
},
});const gate = new Gate({
humanChannel: async (event) => {
const pin = await promptPin();
if (!verifyPin(pin)) return { decision: "deny", reason: "PIN mismatch" };
if (event.kind === "halt") return { decision: "topup", newCap: event.context.cap.costUsd + 5 };
return { decision: "allow" };
},
});
// PIN is bareguard's NO-GO list — authentication is YOUR layer. The recipe
// shows how to wire a PIN-checking runner; bareguard never sees the PIN.const gate = new Gate({
tools: {
allowlist: ["mcp:linear.app/*"],
denyArgPatterns: {
"mcp:linear.app/update_issue": [/priority.*critical/i],
"mcp:linear.app/delete_comment": [/.*/], // never allow this one
},
},
});
// Even though Linear is allowlisted broadly, specific dangerous shapes are denied.import { Gate } from "bareguard";
import { Loop, createMCPBridge } from "bare-agent";
const gate = new Gate({
tools: {
allowlist: ["mcp:beeperbox/*"],
denylist: ["mcp:beeperbox/delete_*"], // belt and suspenders
denyArgPatterns: {
"mcp:beeperbox/send_message": [/"chat_id":\s*"finance"/], // no automated messages to finance team
},
},
budget: { maxCostUsd: 1.00 }, // small cap — manual approve to extend
humanChannel: yourSlackOrPinChannel,
});
await gate.init();
const bridge = await createMCPBridge(); // bareagent discovers beeperbox MCP server
const loop = new Loop({
provider: yourProvider,
policy: async (toolName, args) => {
// bareagent's policy hook → forward to bareguard.gate.check
const decision = await gate.check({ type: toolName, args });
return decision.outcome === "allow";
},
audit: gate.audit.filePath, // share the same JSONL file
});
const result = await loop.run([{ role: "user", content: "Tell mom I'm running late" }], bridge.tools);beeperbox provides send_message, list_chats, get_messages, mark_as_read etc. across WhatsApp / iMessage / Signal / Telegram / Slack / Discord / RCS / SMS / and many more — one Docker container, one MCP server, all under one bareguard policy.
Cap how many defer and spawn actions can pass through the gate per minute. Counted from the audit log (no separate counter file), per-family (across the spawn tree rooted at the topmost run_id).
const gate = new Gate({
defer: { ratePerMinute: 30 }, // default: 15
spawn: { ratePerMinute: 5 }, // default: 10
limits: { maxChildren: 8, maxDepth: 3 }, // concurrency caps still apply
// ...
});
// Eval order is unchanged — defer-rate / spawn-rate sit at step 3 alongside
// bash, fs, net, limits.maxChildren, tools.denyArgPatterns. First match wins.
const dec = await gate.check({ type: "defer", args: { action, when: "1h" } });
// dec.outcome === "deny", dec.rule === "defer.ratePerMinute" if exceededDefense in depth on defer. A defer is two distinct gate.check calls: the defer action at emit (counts toward defer.ratePerMinute) and the inner action at fire (counts toward whatever rules apply to its own type). The audit log records both decisions.
Per-family scope is automatic. The audit file is keyed by root_run_id, and spawned children inherit it via BAREGUARD_AUDIT_PATH. Counting that one file = the family's rate. Cross-family runs use different audit files, so they don't see each other's counts.
See bareagent v0.9.0 for the consumer side. The defer and spawn tools that exercise these caps shipped in bare-agent@0.9.0, with examples/wake.sh as the wake-script reference and examples/orchestrator/ showing parent + child agents sharing one rate cap via inherited audit path.
bareguard never caches humanChannel returns — every ask reaches it fresh. If your UX wants "ask once, remember the answer" semantics, wrap the channel with a TTL'd decision cache:
import { stickyApprovals } from "./your-wrapper.js"; // ~25 LOC — see README Recipe 8 for the body
const gate = new Gate({
humanChannel: stickyApprovals(myActualHumanChannel, { ttlMs: 30 * 60 * 1000 }),
});The wrapper caches allow returns (deny / halt / topup / terminate always bypass) keyed by the action shape minus _ctx, and tags the cached return's reason so phase: "approval" audit lines still show every cached and fresh decision. Why this is a recipe and not a primitive: "same action" has no universal definition — same args? same arg shape? same session? what TTL? — and the gate would have to pick one. PRD §17 records this as a NO-GO. Full body in README Recipe 8.
The primitives gate the action; gate.annotate carries a fact about the result — did it honor the user's request? You compute the fact (a deterministic check, or a caller-side LLM judge returning a decisive honored/broke — bareguard never runs the LLM). bareguard buffers it, audits it, and lets it ride the next human ask so the approver sees independent facts, not the agent's spin. It never blocks alone.
const gate = new Gate({
flags: { needsReview: { yes: "ask" } },
// operator declares which action TYPES are undoable — read off the gated action,
// never the fact / agent / model (a hallucinated "reversible" must not auto-pass):
axisB: { reversibleEscalation: "strict", reversible: ["recall", "search"] },
humanChannel: async (event) => {
if (event.annotations) for (const a of event.annotations) console.log("⚠", a.where);
return { decision: "allow" };
},
});
await gate.init();
// your caller-side judge over (verbatim request, returned value) → honored | broke:
const verdict = await myJudge(userRequest, toolResult); // "honored" | "broke"
await gate.annotate({ surface: verdict !== "honored", verdict, where: "you said under €300; the booking is €400" });
await gate.check({ type: "book", needsReview: "yes" }); // the buffered fact rides this ask
const facts = gate.drainAnnotations(); // and/or feed back to the agent each turnRouting is routeAnnotation(surface, reversible, knob) (pure, exported): a broke fact on an irreversible action — or on a reversible one under strict — rides the ask; under relaxed a reversible broke goes to audit + agent-feedback only. The knob is pure noise control, never safety. Why the judge is caller-side: bareguard putting an LLM inside the floor would drop a fallible model into the decision path; keep the model out — bareguard only buffers, routes, and sinks the fact you computed.
docs/01-product/bareguard-prd.md— unified PRD (v0.7).docs/02-features/harness-cookbook.md— operator-vetted capability bundles: tighten-only presets over one floor (incl. the empty-allowlist-fails-OPEN foot-gun).docs/04-process/non-roadmap.md— the NO-GO list.docs/04-process/decisions-log.md— decisions resolved across versions.CHANGELOG.md— release-by-release diff.- bareagent — the loop runner that imports bareguard.
- beeperbox — 50+ messenger reach via MCP.