Skip to content

Latest commit

 

History

History
616 lines (508 loc) · 39.3 KB

File metadata and controls

616 lines (508 loc) · 39.3 KB

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 — 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.<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.

Minimal wiring

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.

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.

// 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.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 _ctxaction._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.

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.<field> (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.<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.

Public API surface

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/<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

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

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

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

// 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

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

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

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

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)

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).

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, with examples/wake.sh as the wake-script reference and 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:

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.

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.

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