# bareguard — Integration Guide > 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.0 > > Full design spec: [`docs/01-product/bareguard-prd.md`](docs/01-product/bareguard-prd.md) — unified PRD (v0.7). ## What this is 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"` ## Which primitives do I need? | 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.`; 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/.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. ## Minimal wiring ```javascript 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. ``` ## Wiring with humanChannel (the most important section) 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`. ```javascript 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" }` — emit `phase:"approval"` audit line, gate.check returns terminal `allow`. - `{ decision: "deny", reason }` — emit `phase:"approval"`, gate.check returns terminal `deny` (severity preserved from original ask/halt). - `{ decision: "topup", newCap, reason }` — only meaningful for halt. raises the cap atomically, emits `phase:"topup"`, **re-evaluates gate.check**. If still halts after re-eval, calls humanChannel again (capped at 5 iterations to prevent loops). - `{ decision: "terminate", reason }` — emit `phase:"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). ## Wiring shared budget across processes Parent and children share one budget file via `proper-lockfile`. ```javascript // 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: ```bash grep '"parent_run_id":""' run.jsonl ``` ## Wiring secrets redaction **Redaction 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: ```javascript 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. ## Eval order in detail ``` 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.` (e.g. `flags.injectionRisk`). ## Halt vs deny | 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.` deny (e.g., `injectionRisk: "high"`) | action | return error to LLM, continue loop | | `flags.` 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. ## Public API surface ```javascript 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 log ``` ## Audit log format One JSONL file. Default path: `$XDG_STATE_HOME/bareguard/.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. ```bash # 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 ``` ## Key contracts - **Serial calls per Gate instance.** `gate.check` and `gate.record` MUST be called serially per `Gate`. Concurrent calls produce undefined `seq` ordering. Multiple Gate instances (parent + child processes) MAY run concurrently — they're independent. - **Caller redacts results.** bareguard auto-redacts actions if `secrets` config is provided; results are the caller's responsibility before `gate.record`. - **`humanChannel` returns a structured decision.** `{ decision: "allow" | "deny" | "topup" | "terminate", newCap?, reason? }`. Never `undefined`. Throwing or returning unknown decisions is treated as deny. - **No `gate.checkBatch` in 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`, `executor` in `gate.run`). All TUIs, prompts, and PINs are runner-side. ## Patterns, not features 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. | ## Gotchas 1. **Allowlist does NOT silence asks.** Allowlisting `bash` does not bypass `content.askPatterns: [/\bdelete\b/i]`. A `delete` in 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, narrow `content.askPatterns`. 2. **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. 3. **Audit line size capped at 3.5KB.** POSIX `O_APPEND` atomicity requires < PIPE_BUF (4KB). Larger `action.args` are auto-truncated with `[TRUNCATED:...]` markers. Don't put 10MB blobs in your action. 4. **Glob is `*`-only in v0.1.** No `?`, no `[abc]`, no escapes. `mcp:*/admin_*` matches anything in the middle, including `/`. v0.2 may add `**`. 5. **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 on `page_token`); add those via `secrets.keys`. **Env-var** redaction additionally needs values ≥ 8 chars (a short env var like `PORT=5432` isn't redacted — likely not a secret and would over-match). Disable the whole default-on backstop with `secrets.redactKeys: false`. 6. **`gate.allows()` is a catalog pre-filter, NOT an authorization gate.** It returns `true` for askHuman actions (so ask-gated tools still show in a catalog and the human is prompted at invoke time) — it only returns `false` for outright `deny`/halt. **Always call `gate.check()` before executing**; never use `allows()` as the security decision. 7. **`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 inside `run` returns the same error shape with `severity: "halt"`. 8. **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. 9. **Children inherit the audit file via env, not config.** Set `BAREGUARD_AUDIT_PATH` (and `BAREGUARD_BUDGET_FILE`, `BAREGUARD_PARENT_RUN_ID`, `BAREGUARD_SPAWN_DEPTH`) when spawning. The child's `new Gate({})` picks them up automatically. 10. **`gate.terminate()` is sticky.** Once called, every subsequent `gate.check` returns deny+halt with `rule: "gate.terminated"`. Your runner's loop should exit cleanly on this rule. 11. **Windows uses a lock fallback for audit.** `process.platform === "win32"` triggers `proper-lockfile` around audit appends. Slower than the POSIX `O_APPEND` fast path but correct. 12. **`limits.maxTurns` counts every `gate.record` call, not "LLM rounds".** One LLM record + one tool record per round means 1 round = 2 turns. For a "tool-calling-rounds" budget use **`limits.maxToolRounds: N`** (v0.4.2) — sibling halt counter that ticks only on records where `action.type !== "llm"`. Name your LLM records `type: "llm"` to opt in. 13. **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. 14. **fs scope/deny is lexically normalized, not symlink-resolved.** `.`/`..` segments are collapsed before matching, and scopes/deny entries match on path segments (so `/app/data` does NOT cover `/app/data-secrets`) — traversal like `/app/data/../../etc/passwd` can't escape `readScope: ["/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. 15. **`net.denyPrivateIps` is hostname-based, not post-DNS.** It blocks IPv4 private/loopback/link-local (incl. cloud-metadata `169.254.169.254` and `0.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 with `net.allowDomains` for a positive egress allowlist. 16. **`bash.allow` fails closed on shell metacharacters** (v0.4.5). When `bash.allow` is set, any command containing `;`, `|`, `&`, `$`, `` ` ``, `(`, `)`, `<`, `>`, or a newline is **denied** (rule `bash.allow.shellMeta`) — a prefix allowlist can't bound what runs after a chain/pipe/substitution. This also denies legitimate pipes like `git log | head`. If you need chaining, don't rely on `bash.allow` as the boundary — use `content.denyPatterns` (which scans the whole command) or `bash.denyPatterns`. 17. **Audit auto-redacts on every line — DEFAULT-ON (BG-1)**, not just when `secrets` is configured. Key-aware redaction (`apiKey`/`api_key`/`authorization` + `Bearer …`/`sk-…`) runs with zero config; `secrets.envVars`/`patterns`/`keys` layer on top; `secrets.redactKeys: false` disables the default-on backstop. The gate redacts `action`, `result`, `reason`, `where`, and `meta` at 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 before `check()`/`record()` — it's redundant and would weaken policy matching. 18. **`bash.classify` patterns 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 own `extraDestructive` / `extraSuperDestructive` patterns, 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. ## Recipes ### Recipe 1: Wrap an existing executor with `gate.run` ```javascript 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. ``` ### Recipe 2: Catalog pre-filter via `gate.allows` ```javascript 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. ``` ### Recipe 3: Multi-process spawn with shared budget ```javascript // 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 }); } ``` ### Recipe 4: humanChannel via terminal readline ```javascript 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" }; }, }); ``` ### Recipe 5: humanChannel via Slack reaction ```javascript 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" }; }, }); ``` ### Recipe 6: humanChannel with PIN verification ```javascript 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. ``` ### Recipe 7: Per-tool denyArgPatterns for an MCP tool ```javascript 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. ``` ### Recipe 8: bareguard + bareagent + beeperbox (50+ messengers) ```javascript 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. ### Recipe 9: spawn / defer rate caps 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`). ```javascript 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 exceeded ``` **Defense 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](https://www.npmjs.com/package/bare-agent), with [`examples/wake.sh`](https://github.com/hamr0/bareagent/blob/main/examples/wake.sh) as the wake-script reference and [`examples/orchestrator/`](https://github.com/hamr0/bareagent/tree/main/examples/orchestrator) showing parent + child agents sharing one rate cap via inherited audit path. ### Recipe 10: Sticky approvals — humanChannel wrapper 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: ```javascript 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](README.md#8-sticky-approvals--humanchannel-wrapper). ### Recipe 11: Axis B — surface a return-time judge fact on the next approval 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. ```javascript 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 turn ``` Routing 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. ## See also - [`docs/01-product/bareguard-prd.md`](docs/01-product/bareguard-prd.md) — unified PRD (v0.7). - [`docs/02-features/harness-cookbook.md`](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`](docs/04-process/non-roadmap.md) — the NO-GO list. - [`docs/04-process/decisions-log.md`](docs/04-process/decisions-log.md) — decisions resolved across versions. - [`CHANGELOG.md`](CHANGELOG.md) — release-by-release diff. - [bareagent](https://github.com/hamr0/bareagent) — the loop runner that imports bareguard. - [beeperbox](https://github.com/hamr0/beeperbox) — 50+ messenger reach via MCP.