From b21d72cab10ecc553c1dcdf562a0670670417dd3 Mon Sep 17 00:00:00 2001 From: Nicolas Wormser Date: Sun, 5 Jul 2026 19:07:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=A7=AD=20realign=20docs=20and=20e?= =?UTF-8?q?ngine=20contracts=20to=20the=20steering-first=20direction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR set rewritten around the new direction: ADR-009 (Pi + Claude Code as the only Tier 1 adapters), ADR-010 (user-input phase, spec only), ADR-003 (minimal createEngine()/evaluate() embeddable API), ADR-008 (no policy-as-code, not a security boundary), ADR-002 (three phases, Tier 1 capability table). README rewritten (steering hero, three doors, honest pack status), new LIMITATIONS.md, SECURITY.md pairing answer. Code alignment only: createEngine facade, internals marked @internal, Tier 1 capability constants, user-input types/validation plumbing (engine wiring lands with the feature PR), and the confirmβ†’block fallback per ADR-002 (was confirmβ†’suggest). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FUpa7w5hYzrXyCYkK8K1DC --- .gitignore | 1 + CONTRIBUTING.md | 6 +- LIMITATIONS.md | 39 ++++ README.md | 146 ++++++--------- SECURITY.md | 6 +- docs/adrs/001-layered-architecture.md | 12 +- docs/adrs/002-behavior-model.md | 207 ++++++--------------- docs/adrs/003-public-api-contract.md | 110 ++++------- docs/adrs/005-yaml-rule-packs.md | 2 +- docs/adrs/006-observability-strategy.md | 11 +- docs/adrs/007-trust-and-self-protection.md | 38 ++-- docs/adrs/008-non-goals.md | 59 ++---- docs/adrs/009-adapter-scope-and-tiering.md | 35 ++++ docs/adrs/010-user-input-mediation.md | 46 +++++ docs/getting-started.md | 92 ++++----- docs/rule-pack-guide.md | 82 ++++++-- src/core/harness-capabilities.ts | 60 ++++++ src/core/normalizer.ts | 4 +- src/core/types.ts | 39 +++- src/core/validator.ts | 16 +- src/engine/create-engine.ts | 59 ++++++ src/index.ts | 60 +++--- src/resolver/action-resolver.test.ts | 12 +- src/resolver/action-resolver.ts | 16 +- 24 files changed, 653 insertions(+), 505 deletions(-) create mode 100644 LIMITATIONS.md create mode 100644 docs/adrs/009-adapter-scope-and-tiering.md create mode 100644 docs/adrs/010-user-input-mediation.md create mode 100644 src/core/harness-capabilities.ts create mode 100644 src/engine/create-engine.ts diff --git a/.gitignore b/.gitignore index ce24193..d698e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .opencode .pi .claude +CLAUDE.md # Built artifacts node_modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0496efb..9243922 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,14 +43,14 @@ Ideas for new packs: AWS SSM/Secrets Manager, Terraform, HashiCorp Vault, databa Want to add support for another AI coding harness? Adapters are thin shims: 1. Normalize the harness event β†’ `ToolCallContext` -2. Call `matchAndResolve()` from the engine +2. Call `engine.evaluate()` from the public API 3. Translate the result back to the harness's native mechanism -The existing Pi and OpenCode adapters show the pattern. Community adapters for Codex, Claude Code, Aider, and others are welcome starting at v0.2.0. +The first-party Pi and Claude Code adapters show the pattern. Adapters for other harnesses (OpenCode, Codex, Aider, …) are community-owned and live outside this repo, built against the stable interface ([ADR-009](docs/adrs/009-adapter-scope-and-tiering.md)). ### πŸ”΄ Deeper Work: Engine Improvements -Changes to the matcher registry, resolver, action fallback chain, or type system require more context. Start with the [Architecture Decisions](docs/adrs/) (read in order 1–5), then open an issue to discuss your approach before diving in. +Changes to the matcher registry, resolver, action fallback chain, or type system require more context. Start with the [Architecture Decisions](docs/adrs/) (read in order), then open an issue to discuss your approach before diving in. ## Code Style diff --git a/LIMITATIONS.md b/LIMITATIONS.md new file mode 100644 index 0000000..f160b7b --- /dev/null +++ b/LIMITATIONS.md @@ -0,0 +1,39 @@ +# Limitations + +Agent Guardrails is a steering layer: it changes what happens on the events that flow through an agent harness's hooks. This document states plainly what that architecture cannot do, so you can decide what to pair it with. + +## Not a security boundary + +The engine runs wherever the adapter runs, and that placement is a declared, per-adapter fact β€” `tamperResistant` in `HarnessCapabilities` ([ADR-007](docs/adrs/007-trust-and-self-protection.md)): + +| Adapter | Placement | `tamperResistant` | What that means | +| ----------- | -------------------------------------- | :---------------: | -------------------------------------------------------------------------------- | +| Pi | in-process plugin inside the harness | ❌ | An agent that can execute code in the harness process can, in principle, disable or bypass the plugin. | +| Claude Code | external hook subprocess | βœ… | The agent can't reach into the hook process β€” but the hook _configuration_ is a user-editable file the agent may be able to write. | + +Even the strongest adapter placement is not containment. If you need a guarantee that an adversarial agent *cannot* perform an action at all, use an OS-level tool built for that β€” [agentjail](https://github.com/LuD1161/agentjail) (fail-closed daemon, kernel-backed tiers) is the containment companion this project pairs with, not competes with. Division of labor: agentjail answers "can the agent do X at all"; Agent Guardrails changes _what happens_ on the calls that run. + +## Fail-open by default, and what `--strict` trades + +An unmatched event **allows** by default β€” a coding agent that halts on every unmatched call is unusable. This is a deliberate decision ([ADR-007](docs/adrs/007-trust-and-self-protection.md)), and it means a call no rule covers passes through untouched. + +`--strict` trades ergonomics for posture: anything secret-shaped that slips past a named rule hits a `confirm` gate instead of silently passing, and the `hardening` pack is locked on. Expect more interruptions; that's the trade. + +Two failure cases are unconditionally fail-**closed** and not configurable: an engine crash/timeout resolves to `block` on every adapter, and oversized input is rejected at the matcher's input-length cap rather than skipped. + +## Pattern matching has a ceiling + +Rules are deterministic β€” substring pre-filters, structural regex, wrapper detection, TypeScript predicates. The `hardening` pack catches common shell evasion (`eval`, `bash -c`, `$()`), but exotic obfuscation (encoding, staged construction across turns) can evade any deterministic matcher. `redact` on tool output is the backstop for what no rule predicted, not a guarantee. + +## Prompt injection is out of scope + +Agent Guardrails mediates events, not the model's reasoning. If the agent's context is compromised by injected instructions, the rules still apply to what it *does* β€” but steering a compromised agent is containment work (see above), and detecting the injection itself is a different product category entirely. + +## Redaction vs. never-inject + +A credential-injection proxy (never expose the secret to the agent at all) is structurally stronger than after-the-fact redaction β€” *for secrets registered ahead of time on flows the proxy sees*. It doesn't cover the long tail: unregistered secrets, tokens embedded in files the agent reads, output of arbitrary commands, or what the user pastes into a prompt. Never-inject what you know about; redact what you didn't. Pairing, not either/or. + +## See also + +- [SECURITY.md](SECURITY.md) β€” reporting and disclosure +- [ADR-008](docs/adrs/008-non-goals.md) β€” what this project deliberately does not build diff --git a/README.md b/README.md index de1b9ba..457d3bf 100644 --- a/README.md +++ b/README.md @@ -19,124 +19,102 @@ -### What is this? +Your agent just tried `cat .env`. It got back the keys β€” not the values β€” and kept working. -A pattern-based steering engine for AI coding agent workflows. -**Keep your agent shipping β€” not spilling secrets no one's watching for.** Agent Guardrails steers agents away from costly mistakes instead of just stopping them cold. + -### Is this just a set of guards to block bad behaviour? +That's the whole idea: a steering layer for AI coding agents. Instead of stopping the agent cold, rules hand it a safer or better move it can act on immediately β€” `suggest` a redacted read, `run` a rewritten command, `redact` a leaked value, `confirm` with a human, or `block` when nothing safe exists. -No β€” mostly it transforms. Wherever a Safer Alternative exists, that's the default: reading `.env` or decrypting a SOPS file resolves to `suggest`, handing the agent a redacted command it can retry immediately instead of stalling out. `block` is what's left for the cases with nothing safe to transform into β€” pulling a full secret out of a password manager, an adversarial shell wrapper. `redact` catches what no rule predicted, scrubbing output after the fact. `run` (executing the Safer Alternative for the agent instead of just suggesting it) and `confirm` are supported by the engine for harnesses and rule packs that need them, but nothing ships wired to them by default today. +## Batteries included -### Scenarios +Rules work out of the box β€” no config, no flags: -What ships out of the box β€” no config, no flags: - -| Agent tries to... | Without | With | -| ------------------------------------- | -------------------------------------- | -------------------------------------------------------------- | -| `cat .env` "just to see the setup" | Secret leaks into context | `suggest` swaps in a redacted read β€” keys visible, values gone | -| `sops -d secrets.yaml` | Full decrypted secret reaches the LLM | `suggest` offers a format-aware redacted pipe instead | -| `git push --force` | Rewrites shared history | `suggest` offers `--force-with-lease` | -| Reads an untracked `credentials.json` | Silent leak, no rule covers it | `redact` scrubs the secret shape from the output anyway | -| `pass show secret/path` | Full secret value reaches the LLM | `block` β€” no safe partial exists, nothing to transform into | - -## How It Works - -``` -Agent Tool Call β†’ Match Rules β†’ Resolve Action β†’ Enforce - ↓ ↓ ↓ ↓ -ToolCallContext Rule Packs GuardrailAction Harness Specific - (YAML) + Fallback Chain Behaviour +```bash +npx agent-guardrails install pi # or: install claude-code ``` -1. **ToolCallContext** β€” the adapter normalizes harness-specific events into this common shape -2. **Rule Packs** β€” match conditions (regex, file-path, predicate) and default actions that define what to watch for -3. **GuardrailAction** β€” the engine evaluates rules and resolves the action through a fallback chain -4. **Behaviour** β€” the adapter translates the resolved behaviour (`block`, `suggest`, `run`, `redact`, `confirm`) into harness-specific enforcement - -## Features - -### Core Engine - -- **Multi-layer matching** β€” substring pre-filter, structural regex, and adversarial wrapper detection -- **Action fallback chain** β€” `run β†’ suggest β†’ block` when capabilities are missing -- **Harness capability model** β€” each adapter declares what behaviors its harness supports -- **YAML rule packs** β€” author guardrails without writing TypeScript -- **Zero runtime dependencies** β€” the engine itself is pure logic -- **Hub-and-spoke architecture** β€” a dependency-free `core/` sits at the center; every other module imports into it and never back out, keeping the domain logic trivially testable ([details](docs/getting-started.md#architecture)) +Try `cat .env` in your agent β€” it comes back with a redacted read instead of the raw file. A stricter posture is on the roadmap: `--strict` will confirm-gate anything secret-shaped even without a specific rule and lock the `hardening` pack on ([ADR-007](docs/adrs/007-trust-and-self-protection.md)). -### Behaviors +## Three doors -| Behavior | What it does | Phase | -| --------- | -------------------------------------------------- | ----------- | -| `block` | Stop the tool call, no alternative | before-tool | -| `suggest` | Stop the call, offer a safer replacement | before-tool | -| `run` | Execute the replacement in the hook, return output | before-tool | -| `redact` | Allow the call, sanitize output before LLM sees it | after-tool | -| `confirm` | Ask the user (native UI, falls back to `block`) | before-tool | +- **Pi plugin** β€” in-process, all five behaviors native. `npx agent-guardrails install pi` +- **Claude Code hooks** β€” out-of-process, all five behaviors bound to native hook mechanisms. `npx agent-guardrails install claude-code` +- **TypeScript library** β€” building your own harness? `npm install agent-guardrails`, one function: -### Built-in Rule Packs + ```typescript + import { createEngine, loadAllRulePacks } from "agent-guardrails"; -- **env** β€” Block `.env` file reads (both file-path and bash commands) (_upcoming_) -- **private-key** β€” Block private key access, including SSH directory files (_upcoming_) -- **secret-managers** β€” Block 1Password, gopass, pass, and Bitwarden secret retrieval (_upcoming_) -- **encryption-tools** β€” Block `age`, `gpg`, and `openssl` decrypt commands (_upcoming_) -- **sops** β€” Block SOPS decrypt operations (_upcoming_) -- **hardening** β€” Detect shell wrappers (`eval`, `bash -c`, `$()`) and redirects to sensitive paths (_upcoming_) -- **direnv**, **kubernetes**, **gh-cli** β€” Platform-specific guardrails (_upcoming_) + const engine = createEngine(loadAllRulePacks("./packs", registry), capabilities); + const action = engine.evaluate(ctx); // per tool call / tool result / user prompt + ``` -### Trust & Self-Protection +## Rule packs -Four questions a security-literate evaluator asks first β€” answered by design, not convention: +**Steering (quality of life)** β€” fires every session, keeps the agent on the fast path: -- **Can a locked-down rule be overridden by the user's own config?** No. Built-in rules can be marked `overridable: false`, enforced at the pack loader β€” no `Configured Action` can soften them. Community packs can't claim this privilege for their own rules; the loader silently downgrades it and warns. -- **Can I get a stricter default than "allow" with one flag?** Yes β€” `--strict` sets `defaultDecision: confirm` for the `env`, `private-key`, and `secret-managers` categories, so anything secret-shaped that slips past a named rule still hits a human gate instead of silently passing through, and force-locks the `hardening` pack on. -- **Does the adapter run somewhere the agent it governs could tamper with it?** Depends on the harness, and it's a declared, checkable fact per adapter (`tamperResistant`), not a hand-wave β€” external hook processes (Claude Code, Codex) are tamper-resistant, in-process plugins (Pi, OpenCode) aren't. -- **What happens when nothing matches, or the engine itself breaks?** Two different, deliberate answers. An unmatched call fails **open** (`allow`) by default, since a coding agent that halts on every unmatched call is unusable; `defaultDecision`/`--strict` exist for operators who want to trade that ergonomics away deliberately. An engine crash, timeout, or oversized input (past the matcher's `MAX_MATCH_INPUT_LENGTH` cap) fails **closed** (`block`) instead, unconditionally, on every adapter β€” never derived from `defaultDecision`, never user-tunable, because a crash means the engine never got far enough to know if the call was even in scope. +| Pack | What it does | +| ----------------- | ------------------------------------------------------------------------- | +| `modern-cli` | `grep` β†’ `rg`, `find` β†’ `fd`, `cat` on a huge file β†’ `head` | +| `package-manager` | `npm install` in a pnpm repo β†’ `pnpm add` (and the other mismatches) | +| `git-safety` | commit to a protected branch β†’ confirm; `push --force` β†’ `--force-with-lease` | -See [ADR-007](docs/adrs/007-trust-and-self-protection.md) for the full design. +**Security** β€” fires rarely, saves your week when it does: -### Adapters +| Pack | What it does | +| ------------------ | ------------------------------------------------------------------------ | +| `env` | `.env` reads come back redacted β€” keys visible, values gone | +| `sops` | `sops -d` swapped for a format-aware redacted pipe | +| `private-key` | `.pem`, `.key`, SSH key reads blocked | +| `secret-managers` | `pass show`, `op read`, `gopass`, `bw get` blocked β€” nothing safe to transform into | +| `encryption-tools` | `age -d`, `gpg --decrypt`, `openssl enc -d` blocked | +| `hardening` | shell wrappers (`eval`, `bash -c`, `$()`) around risky commands force-blocked | -- 🚧 **Pi** β€” native plugin for [Pi](https://github.com/earendil-works/pi) β€” WIP -- 🚧 **OpenCode** β€” native plugin for [OpenCode](https://github.com/anomalyco/opencode) β€” WIP -- 🚧 **Codex** β€” coming later -- 🚧 **Claude Code** β€” coming later -- πŸ”Œ **Community adapters welcome** β€” from v0.2.0 onwards, any harness can integrate by implementing a thin adapter shim +Status: the packs above are the v0.1.0 shipping set; anything not listed here doesn't exist yet and isn't claimed. -## Quick Start +Every boundary where a secret can cross into the model's context is mediated: **user input** (a key pasted into the prompt is scrubbed before it reaches the API), **tool calls**, and **tool output**. -> Not yet published. The workflow below shows the expected v0.1.0 experience. +## How it works -```bash -npx agent-guardrails install pi +``` +Agent Tool Call β†’ Match Rules β†’ Resolve Action β†’ Enforce + ↓ ↓ ↓ ↓ +ToolCallContext Rule Packs GuardrailAction Harness Specific + (YAML) + Fallback Chain Behaviour ``` -That's it. Try `cat .env` in your agent β€” it should come back with a redacted read instead of the raw file. +Rules are YAML (plus TypeScript predicates for complex logic). Each adapter declares what its harness can do (`HarnessCapabilities`); when a capability is missing, the engine degrades deterministically (`run β†’ suggest β†’ block`, `confirm β†’ block`, `redact β†’ block`) β€” declared, never silent. Details in the [ADRs](docs/adrs/). -Want a stricter posture β€” confirm-gate anything secret-shaped even without a specific rule, and lock `hardening` so no config can disable it? +## Trust & self-protection -```bash -npx agent-guardrails install pi --strict -``` +- Built-in rules can be locked (`overridable: false`) so no user config softens them; community packs can't claim that privilege. +- Unmatched calls fail **open** (the agent keeps working); an engine crash fails **closed** (`block`), unconditionally, on every adapter. +- Whether the layer runs somewhere the agent could tamper with it is a declared per-adapter fact (`tamperResistant`), not a hand-wave. -To build from source today, see [Getting Started](docs/getting-started.md). +See [ADR-007](docs/adrs/007-trust-and-self-protection.md) and [LIMITATIONS.md](LIMITATIONS.md). -## What this is NOT +## Adapters -Not a security audit tool or sandbox. See [SECURITY.md](SECURITY.md) for details. +- **Pi** β€” first-party, in-process plugin for [Pi](https://github.com/earendil-works/pi) +- **Claude Code** β€” first-party, external hooks +- **Community adapters welcome** β€” any harness integrates against the stable adapter interface ([ADR-009](docs/adrs/009-adapter-scope-and-tiering.md), [ADR-003](docs/adrs/003-public-api-contract.md)) ## Documentation -- [Getting Started](docs/getting-started.md) β€” contributor gateway, full architecture deep dive, adapter capability table, vocabulary -- [Architecture Decisions (ADRs)](docs/adrs/) β€” the _why_ behind core design choices -- [How Matching Works](docs/how-matching-works.md) β€” layer-by-layer walkthrough with real examples -- [Rule Pack Guide](docs/rule-pack-guide.md) β€” complete YAML format spec, action types, multi-matcher coverage tips +- [Getting Started](docs/getting-started.md) β€” install, capability table, embedding quickstart, architecture +- [Rule Pack Guide](docs/rule-pack-guide.md) β€” complete YAML format spec with steering and security examples +- [Pack Gallery](docs/packs.md) β€” every shipped pack and rule, auto-generated +- [How Matching Works](docs/how-matching-works.md) β€” layer-by-layer walkthrough +- [ADRs](docs/adrs/) β€” the _why_ behind core design choices +- [LIMITATIONS.md](LIMITATIONS.md) Β· [SECURITY.md](SECURITY.md) ## Contributing -We welcome rule packs, adapters, and engine improvements. See [CONTRIBUTING.md](CONTRIBUTING.md) to get started β€” the lowest-friction way to contribute is creating and sharing a YAML rule pack. +We welcome rule packs, community adapters, and engine improvements. See [CONTRIBUTING.md](CONTRIBUTING.md) β€” the lowest-friction contribution is a YAML rule pack. ## License MIT β€” see [LICENSE](LICENSE). + +--- + +A steering layer, not a sandbox β€” pair with [agentjail](https://github.com/LuD1161/agentjail) for containment. diff --git a/SECURITY.md b/SECURITY.md index a228c4f..aeeda9b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,11 @@ ## What Agent Guardrails Is (and Isn't) -Agent Guardrails is a **pattern-based policy engine** for AI coding agent workflows. It intercepts tool calls and matches them against rule packs before execution. Whether that's a genuinely independent security layer or cooperative enforcement depends on the harness: external hook process adapters (Claude Code, Codex) add a boundary the agent can't reach into; in-process plugin adapters (Pi, OpenCode) don't (`tamperResistant`, see [ADR-007](docs/adrs/007-trust-and-self-protection.md)). +Agent Guardrails is a **pattern-based steering engine** for AI coding agent workflows. It mediates user input, tool calls, and tool output against rule packs. Whether that's a genuinely independent security layer or cooperative enforcement depends on the harness: the external-hook adapter (Claude Code) adds a process boundary the agent can't reach into; the in-process plugin adapter (Pi) doesn't (`tamperResistant`, see [ADR-007](docs/adrs/007-trust-and-self-protection.md)). -It is **not** a security audit tool, a sandbox, or a complete security boundary. Deterministic regex matching cannot catch every adversarial payload. +It is **not** a security audit tool, a sandbox, or a complete security boundary. Deterministic regex matching cannot catch every adversarial payload. See [LIMITATIONS.md](LIMITATIONS.md) for the full statement of what this architecture cannot do, and for the containment pairing (agentjail). + +**Why redact rather than never-inject?** A credential-injection proxy is stronger for secrets registered ahead of time on flows the proxy sees; it doesn't cover unregistered secrets, tokens in files the agent reads, arbitrary command output, or pasted prompts. Never-inject what you know about; redact what you didn't. Pair them ([LIMITATIONS.md](LIMITATIONS.md)). ## Default Posture: Fail-Open, With Fail-Closed Exceptions diff --git a/docs/adrs/001-layered-architecture.md b/docs/adrs/001-layered-architecture.md index 421ed26..f3ba9c5 100644 --- a/docs/adrs/001-layered-architecture.md +++ b/docs/adrs/001-layered-architecture.md @@ -37,19 +37,19 @@ Adapters depend on `engine`. | `matcher/matchers.ts` | Evaluates `MatchCondition`s via `matchesMatcher()` | Pure | | `matcher/command-splitter.ts` | Shell command splitting | Pure | | `resolver/action-resolver.ts` | Fallback chain, interpolation | Pure | -| `engine/engine.ts` | Orchestrates match β†’ resolve. `matchAndResolve` and `processMatch` are pure functions β€” collaborators (`PredicateRegistry`, `StatsTracker`) are passed as arguments. | Pure (no owned state) | +| `engine/engine.ts` | Orchestrates match β†’ resolve behind `createEngine()` (ADR-003). Evaluation is pure; the engine instance encapsulates its collaborators (`PredicateRegistry`, `StatsTracker`). | Pure evaluation, encapsulated state | | `engine/stats-tracker.ts` | Stats accumulation | Stateful (encapsulated) | | `infrastructure/yaml-pack-loader.ts` | YAML parsing, pack loading | Stateless | ### Concrete Infrastructure (No Ports Yet) -`infrastructure/` contains concrete implementations. Loaders are imported directly β€” no port interfaces. Ports will be introduced when the 3rd adapter forces dependency inversion (see Β§ Package Split Trigger). +`infrastructure/` contains concrete implementations. Loaders are imported directly β€” no port interfaces. Ports will be introduced when the public embeddable-lib API (ADR-003) ships and embedders need to substitute their own loading β€” dependency inversion earns its cost then, not before. ### Package Split Trigger Split into separate packages when: -1. **3+ adapters exist** (the cost of interfaces is amortized) +1. **A community adapter ecosystem exists** (Tier 2 adapters, ADR-009, need a lean core to depend on) 2. **Independent versioning is needed** (adapters and core diverge in release cadence) 3. **Community rule pack registry is needed** (separate publishing lifecycle) @@ -58,7 +58,7 @@ Split into separate packages when: - **Lowest barrier:** One clone, one `npm install`, one `npm test` - **Test isolation:** Each module independently testable via pure functions - **Growth path:** Adding features = adding modules, not growing a monolith -- **No over-engineering:** Ports deferred until the 3rd adapter forces them +- **No over-engineering:** Ports deferred until embedders actually need them ## Consequences @@ -66,5 +66,5 @@ Split into separate packages when: - The layering is a contract: `core` has no dependencies; `resolver` never imports from `infrastructure` - `core/` remains zero-dep; the `yaml` package lives in `infrastructure/` only - When the split trigger fires, each directory maps cleanly to a package -- The engine is a **pure function** at the call site. The `PredicateRegistry` and `StatsTracker` instances are passed as arguments and owned by the caller for its lifetime. -- Adapter bootstrap: `new PredicateRegistry()`, `new StatsTracker()`, `loadAllRulePacks(path, registry)`. Adapters that need shared state across requests hold the same instances for the lifetime of the session. +- Evaluation is **pure** at the call site: `engine.evaluate(ctx)` has no hidden module-level state; the engine instance owns its collaborators for the session's lifetime. +- Adapter bootstrap: `createEngine(loadAllRulePacks(path, registry), capabilities)` β€” see ADR-003 for the full pattern. diff --git a/docs/adrs/002-behavior-model.md b/docs/adrs/002-behavior-model.md index 2ab90a0..378018e 100644 --- a/docs/adrs/002-behavior-model.md +++ b/docs/adrs/002-behavior-model.md @@ -6,26 +6,31 @@ status: accepted ## Context -Different AI coding harnesses (Pi, OpenCode, Claude Code, Codex) support different capabilities. The engine needs a consistent vocabulary for _what to do_ when a rule matches, and _when_ it fires β€” and must fall back gracefully when a harness can't do what the rule asks. +The engine needs a consistent vocabulary for _what to do_ when a rule matches and _when_ it fires, and it must degrade gracefully when a harness can't do what the rule asks. Harnesses are unequal by construction β€” Pi is an in-process plugin with a live handle into the running session; Claude Code is an out-of-process hook subprocess whose only channel back is a declared JSON schema. The behavior model has to make one rule vocabulary portable across that asymmetry. ## Decision ### Five Behaviors -| Behavior | Phase | What it does | Capability required | -| --------- | ------------- | -------------------------------------------------------- | ------------------- | -| `block` | before | Stops tool call with a message | None (universal) | -| `suggest` | before | Stops call, offers a safer replacement | None (universal) | -| `run` | before | Stops call, executes replacement in-hook, returns output | `run` | -| `redact` | after | Allows call, sanitizes output before LLM sees it | `redact` | -| `confirm` | before | Prompts user for approval | `confirm` | +| Behavior | Phase | What it does | Capability required | +| --------- | ------------------------- | -------------------------------------------------------- | ------------------- | +| `block` | before-tool, user-input | Stops the tool call (or prompt) with a message | None (universal) | +| `suggest` | before-tool | Stops call, offers a safer replacement | None (universal) | +| `run` | before-tool | Rewrites the call to a safer equivalent and lets it run | `run` | +| `redact` | after-tool, user-input | Sanitizes content before the model sees it | `redact` / `redactUserInput` | +| `confirm` | before-tool, user-input | Prompts user for approval | `confirm` | `block` and `suggest` work everywhere β€” they're the safe baseline any adapter can rely on. -### Phase-Behavior Constraints +### Three Phases -- `before-tool` β†’ block, suggest, run, confirm -- `after-tool` β†’ redact only +| Phase | Boundary mediated | Allowed behaviors | +| ------------- | -------------------------------------------------------- | ---------------------------------- | +| `user-input` | What the user pastes into the prompt, before it reaches the provider API | `redact`, `block`, `confirm` | +| `before-tool` | The tool call the model is about to make | `block`, `suggest`, `run`, `confirm` | +| `after-tool` | The tool output the model is about to read | `redact` | + +Together the three phases mediate every boundary where a secret can cross into the model's context: user input, tool calls, tool output. `suggest` and `run` are tool-call concepts β€” they presuppose a command to replace β€” and are excluded from `user-input` (see ADR-010). This is a hard constraint enforced at pack load time by `validateRule()` (`src/core/validator.ts`). Invalid combinations fail fast with a clear error message. @@ -42,145 +47,62 @@ suggest β†’ block (when no replacement available) Implemented in `resolveAction()` (`src/resolver/action-resolver.ts`). Adapters just declare their `HarnessCapabilities`; the engine handles the rest. -Every behavior sits on a spectrum of how much autonomous latitude it grants the -agent β€” `confirm` (a human must explicitly decide) is the *most* restrictive, -`suggest` (the agent gets an actionable replacement it can use with no human -involvement) is far more permissive. A fallback chain only ever moves *toward* more -restriction than the rule author asked for, never past it β€” which is why `confirm` -falls back straight to `block` rather than to `suggest`: handing the agent an -actionable replacement would invert the intent of a rule that specifically asked for a -human decision. `run β†’ suggest β†’ block` and `suggest β†’ block` are monotonically -restrictive in the same sense. `redact β†’ block` crosses from after-tool to before-tool -when an after-tool sanitize can't be honored β€” strictly safer than leaking unscrubbed -output, not a phase-crossing anomaly. Because `confirm β†’ block` is the universal -default, ADR-007's `strict` preset needs no special-cased fallback of its own. - -**Should-have, not yet implemented:** since `haltTurn` (below) is strictly more -restrictive than plain `block`, the most faithful degradation on a harness that lacks -`confirm` but has `haltTurnBeforeTool` would be `confirm β†’ block+haltTurn β†’ block`. -Worth wiring up once `haltTurn` capabilities are live per-adapter. +Every behavior sits on a spectrum of how much autonomous latitude it grants the agent β€” `confirm` (a human must explicitly decide) is the _most_ restrictive, `suggest` (the agent gets an actionable replacement it can use with no human involvement) is far more permissive. A fallback chain only ever moves _toward_ more restriction than the rule author asked for, never past it β€” which is why `confirm` falls back straight to `block` rather than to `suggest`: handing the agent an actionable replacement would invert the intent of a rule that specifically asked for a human decision. `run β†’ suggest β†’ block` and `suggest β†’ block` are monotonically restrictive in the same sense. `redact β†’ block` crosses from after-tool to before-tool when an after-tool sanitize can't be honored β€” strictly safer than leaking unscrubbed output, not a phase-crossing anomaly. Because `confirm β†’ block` is the universal default, ADR-007's `strict` preset needs no special-cased fallback of its own. + +The fallback chains justify themselves across exactly two unequal harnesses: in-process (Pi) versus out-of-process (Claude Code) is the asymmetry the capability model exists for. A community adapter for a harness with a thinner hook surface plugs into the same chains without engine changes (ADR-009). + +### Capability Table β€” Tier 1 Adapters + +| Capability | Pi (in-process plugin) | Claude Code (external hooks) | +| --------------------- | ---------------------- | ---------------------------- | +| `block` | βœ… `{ block: true, reason }` return | βœ… `permissionDecision: "deny"` | +| `suggest` | βœ… block + reason fed to model | βœ… deny + reason fed to model | +| `run` | βœ… in-process execution | βœ… PreToolUse `updatedInput` | +| `redact` | βœ… `tool_result` override | βœ… PostToolUse `updatedToolOutput` β€” **version floor β‰₯ 2.1.121**; below it, `redact β†’ block` | +| `confirm` | βœ… `ctx.ui.confirm()` TUI dialog | βœ… `permissionDecision: "ask"` | +| `redactUserInput` | βœ… in-process input interception | βœ… `UserPromptSubmit` hook | +| `haltTurnBeforeTool` | βœ… `ctx.abort()` | βœ… `continue: false` | +| `haltTurnAfterTool` | βœ… `ctx.abort()` | βœ… `continue: false` | +| `tamperResistant` | ❌ in-process (ADR-007) | βœ… out-of-process (ADR-007) | + +Claude Code binds all five behaviors natively β€” no emulation needed. Pi binds them in-process with a live session handle. Community adapters (ADR-009) declare the same flags for their harness; the engine's fallback chains cover whatever is `false`. ### Control-Flow Tiers -`block` and `suggest` sit in the same control-flow tier. Per Claude Code's hook -documentation, a `PreToolUse` deny (`permissionDecision: "deny"` / exit code 2) denies -the call and feeds the reason back to Claude as stderr β€” **the model's turn continues, -reasoning uninterrupted.** `block` and `suggest` differ only in payload richness -(open-ended "no" vs. "no, try this"), not in who regains control. There are three real -control-flow tiers: +`block` and `suggest` sit in the same control-flow tier. A `PreToolUse` deny feeds the reason back to the model β€” **the model's turn continues, reasoning uninterrupted.** `block` and `suggest` differ only in payload richness (open-ended "no" vs. "no, try this"), not in who regains control. There are three real control-flow tiers: | Tier | Who decides next | Mechanism | Behaviors using it | | --- | --- | --- | --- | | **LLM-Continues** | The model, mid-turn, autonomously | deny + reason fed to model | `block`, `suggest` | | **Human-in-the-loop, call-scoped** | A human, synchronously, for this one call; model resumes immediately after | native permission dialog | `confirm` | -| **Turn Halt** | A human, but the whole turn stops β€” model isn't reasoning until re-prompted from outside the loop | `haltTurn` modifier, phase- and harness-dependent | `block` + `haltTurn`, `redact` + `haltTurn` | +| **Turn Halt** | A human, but the whole turn stops β€” model isn't reasoning until re-prompted from outside the loop | `haltTurn` modifier | `block` + `haltTurn`, `redact` + `haltTurn` | -Do not describe `block` as "stronger" in the sense of halting reasoning β€” it isn't, -unless paired with `haltTurn`. +Do not describe `block` as "stronger" in the sense of halting reasoning β€” it isn't, unless paired with `haltTurn`. ### `haltTurn` β€” an orthogonal, phase-scoped modifier -`haltTurn: boolean` is not a sixth behavior and not a peer of the five behaviors β€” it's -a modifier restricted to `Block Action` and `Redact Action`: - -- **Not available on `Suggest Action`.** `suggest`'s purpose is putting a - `replacement` in front of the model so it can autonomously retry; `haltTurn` shows - its message to the human, not the model, so the model never sees the replacement to - act on. At that point the payload is indistinguishable from `block`'s `message` - field β€” there's no scenario `suggest`+`haltTurn` covers that `block`+`haltTurn` - doesn't. -- **Included on `Redact Action`** because Codex's only halt-capable phase is - after-tool, and `redact` is the only after-tool behavior β€” restricting `haltTurn` to - `Block` alone would make it structurally inexpressible on the one harness that most - needs an after-tool halt. Concrete use case: output contained a live credential β€” - scrub it before the model sees it, and stop the turn because this warrants a human - looking at how it became readable at all. -- **Not part of `defaultDecision`'s vocabulary** (ADR-007). `defaultDecision` is the - catch-all for *nothing else matched* β€” the lowest-confidence case in the system. - `haltTurn` belongs on high-confidence, specifically-identified adversarial rules, - not the generic "no rule fired" default. - -**Capabilities: phase-scoped, not a flat boolean.** `capabilities.haltTurnBeforeTool: -boolean` and `capabilities.haltTurnAfterTool: boolean` β€” keeping the flat-boolean -shape `HarnessCapabilities` already uses (no nesting), split by phase because Codex -supports one and not the other. Fallback: when the relevant phase's capability is -`false`, `haltTurn: true` degrades to the plain behavior with no halt β€” silently -dropped, not erroring, same posture as other capability degradation in this ADR. - -**Named `haltTurn`, not `haltSession`.** Confirmed against the runtime source of Pi -and OpenCode, the Rust source of Codex, and Claude Code's hook documentation: every -harness's version of this mechanism stops the current turn/run, not the persistent, -multi-turn session/conversation. Claude Code's `continue:false` stops "processing" for -that turn; Pi's `ctx.abort()` calls `AgentSession.abort()`, which stops the current -streaming turn (the session object and history persist); OpenCode's -`client.session.abort()` calls `promptSvc.cancel(sessionID)`, cancelling the active -runner for that session, not the session itself. - -**Per-harness capability, verified at source level** (same standard `tamperResistant`, -ADR-007, requires for every adapter capability claim): - -| Harness | Mechanism | Architecture | `haltTurnBeforeTool` | `haltTurnAfterTool` | -| --- | --- | --- | --- | --- | -| Claude Code | declarative `continue:false`/`stopReason` in hook JSON output | out-of-process (subprocess, stdin/stdout/exit-code only) | `true` | `true` | -| Codex | declarative `continue:false`/`stopReason` in hook JSON output | out-of-process (subprocess) | `false` β€” structurally rejected, see below | `true` | -| Pi | imperative `ctx.abort()` on `ExtensionContext` | in-process plugin (live reference to running session) | `true` | `true` | -| OpenCode | imperative `client.session.abort()` via the SDK client every plugin receives | in-process plugin (HTTP client to own server, same process/lifecycle) | `true` | `true` | - -Pi and OpenCode support `haltTurn` despite no declarative field in their hook return -types because both are in-process plugins holding a live handle into the running -agent β€” an imperative escape hatch independent of what a specific event's declared -return type supports. Claude Code and Codex hooks are out-of-process subprocesses -whose *only* channel back is the declared JSON schema β€” if that schema has no stop -field for a phase, there is no other way to signal it. This is the same -in-process/out-of-process split that determines `tamperResistant` (ADR-007). - -**Codex's before-tool exclusion is deliberate, confirmed at the Rust source level:** -`codex-rs/hooks/src/events/pre_tool_use.rs`'s `PreToolUseOutcome` has no -continue/stop field at all (`should_block`, `block_reason`, `updated_input` only); a -dedicated test, `unsupported_permission_decision_fails_open`, asserts that a hook -attempting `permissionDecision:"ask"` on `PreToolUse` is rejected and **fails open** -(tool call proceeds anyway). `PostToolUse` (`post_tool_use.rs`) genuinely supports -`continue`/`stopReason` (tested: `continue_false_stops_with_reason`). - -**Rejected: routing Codex's before-tool halt through the after-tool hook via input -substitution.** `codex-rs/core/src/tools/registry.rs` shows that when `PreToolUse` -returns `Blocked`, the registry returns an error immediately and the tool never -executes β€” `PostToolUse` is gated on the tool having actually run and succeeded, so -there's no "after" to hook into once `PreToolUse` denies a call. The only theoretical -workaround β€” use `updatedInput` to rewrite the command into a fabricated no-op, let it -"succeed," then issue `continue:false` from `PostToolUse` β€” was rejected: it stops -being `block`+`haltTurn` and becomes `run`+`haltTurn` on the after-tool phase (a -substitution, not a denial); it requires synthesizing a plausible no-op for every rule -wanting this (no natural no-op exists for "block `eval`"); and it produces a -misleading transcript (a "successful" call the model never really got to make) for one -harness's one edge case. **Decision: don't build it.** Codex declares -`haltTurnBeforeTool: false` and behaves like any other harness without before-tool -halt support β€” plain `block`, no halt. Recorded as a rejected idea in -`future-architecture-decisions.md`, not silently dropped. - -**Concrete use case:** `haltTurn: true` on the `hardening` pack's Layer 3 -adversarial-wrapper rules (`eval`, `bash -c`, `$()`, backticks) specifically, leaving -ordinary secret-pattern rules (env, sops, private-key) as LLM-continues. An adversarial -wrapper attempt is exactly the case where letting the model "adapt and retry" is the -wrong response β€” it might be the injection trying another wrapper shape in the same -turn. Ordinary accidental misuse (agent `cat`s `.env` "just to see setup") stays -LLM-continues; halting the turn on every secret-pattern match would be exhausting and -isn't what the fail-open-by-default rationale (ADR-007) argues for. +`haltTurn: boolean` is not a sixth behavior and not a peer of the five behaviors β€” it's a modifier restricted to `Block Action` and `Redact Action`: + +- **Not available on `Suggest Action`.** `suggest`'s purpose is putting a `replacement` in front of the model so it can autonomously retry; `haltTurn` shows its message to the human, not the model, so the model never sees the replacement to act on. At that point the payload is indistinguishable from `block`'s `message` field. +- **Available on `Redact Action`** for the case where output contained a live credential β€” scrub it before the model sees it, and stop the turn because this warrants a human looking at how it became readable at all. +- **Not part of `defaultDecision`'s vocabulary** (ADR-007). `defaultDecision` is the catch-all for _nothing else matched_ β€” the lowest-confidence case in the system. `haltTurn` belongs on high-confidence, specifically-identified adversarial rules, not the generic "no rule fired" default. + +**Capabilities: phase-scoped, not a flat boolean.** `capabilities.haltTurnBeforeTool: boolean` and `capabilities.haltTurnAfterTool: boolean` β€” flat booleans like the rest of `HarnessCapabilities`, split by phase because a harness's hook schema may support a stop signal in one phase and not the other. Fallback: when the relevant phase's capability is `false`, `haltTurn: true` degrades to the plain behavior with no halt β€” silently dropped, not erroring, same posture as other capability degradation in this ADR. + +**Named `haltTurn`, not `haltSession`.** Verified against harness sources: every harness's version of this mechanism stops the current turn/run, not the persistent multi-turn session. Claude Code's `continue: false` stops processing for that turn; Pi's `ctx.abort()` calls `AgentSession.abort()`, which stops the current streaming turn (the session object and history persist). + +**Concrete use case:** `haltTurn: true` on the `hardening` pack's Layer 3 adversarial-wrapper rules (`eval`, `bash -c`, `$()`, backticks) specifically, leaving ordinary secret-pattern rules (env, sops, private-key) as LLM-continues. An adversarial wrapper attempt is exactly the case where letting the model "adapt and retry" is the wrong response β€” it might be the injection trying another wrapper shape in the same turn. Ordinary accidental misuse (agent `cat`s `.env` "just to see setup") stays LLM-continues; halting the turn on every secret-pattern match would be exhausting and isn't what the fail-open-by-default rationale (ADR-007) argues for. ### `block`'s Mechanism Is Per-Adapter -`block` is universal in *outcome* β€” every adapter can deny a call with no capability -check β€” but not in *mechanism*. Each adapter achieves it differently: +`block` is universal in _outcome_ β€” every adapter can deny a call with no capability check β€” but not in _mechanism_: | Adapter | How `block` is achieved | | --- | --- | -| Claude Code | declarative `permissionDecision: "deny"` / exit code 2 in hook JSON | -| Codex | declarative `continue:false`/`should_block` in hook JSON | -| Pi | `{ block: true, reason }` return value | -| OpenCode | throw inside `tool.execute.before` (throw-to-deny) β€” its hook signature (`(input, output: { args: any }) => Promise`) has no `block`/`deny` field; denial works by throwing inside the hook, which the surrounding Effect pipeline turns into a failed tool-execution effect | +| Pi | `{ block: true, reason }` return value from the `tool_call` hook | +| Claude Code | declarative `permissionDecision: "deny"` in structured hook JSON output | -Each adapter's `spec.md` states its mechanism plainly rather than treating it as -uniform just because the outcome is. +Each adapter's spec states its mechanism plainly rather than treating it as uniform just because the outcome is. Community adapters document theirs the same way (ADR-009). ### Contextual Messages @@ -188,34 +110,21 @@ All action messages support `{matched}` template interpolation, replaced at matc ### Mediation Scope -The project does not and cannot guarantee containment against an adversarial agent -(`SECURITY.md` already concedes this) β€” the behavior model is not, and should not be -described as, a security boundary. **The scope decision:** built-in rule packs stay -safety/secrets-focused (env, private keys, secret managers, encryption tools, -hardening), but the mechanism β€” `Rule`, `Rule Pack`, `Behavior`, -`block`/`suggest`/`run`/`redact`/`confirm`, `Match Condition` β€” is general-purpose by -construction. Community packs may use it for non-security steering (style nudges, -workflow guardrails, cost control) without that requiring a project position change. -Internal vocabulary is unaffected by this β€” these are mechanism names, not brand -claims, and don't need renaming. The outward-facing claim is: this reduces the -operational drag of running coding agents (fewer leaked-secret rotations, fewer wasted -turns on dead-end commands, an agent that adapts instead of hitting a wall), with -safety-shaped wins as the proof-of-value wedge, not the ceiling of the pitch. See -`docs/adrs/008-non-goals.md` for what this project deliberately does not chase. +The project does not and cannot guarantee containment against an adversarial agent (`LIMITATIONS.md`) β€” the behavior model is not, and should not be described as, a security boundary (ADR-008). **The scope decision:** the mechanism β€” `Rule`, `Rule Pack`, `Behavior`, `block`/`suggest`/`run`/`redact`/`confirm`, `Match Condition` β€” is general-purpose by construction, and the shipped packs use it that way: steering (QoL) packs that fire every session (`grep` β†’ `rg`, package-manager mismatch, protected-branch commits) alongside security packs (env, sops, private keys, secret managers, hardening). The outward-facing claim is: this reduces the operational drag of running coding agents β€” fewer leaked-secret rotations, fewer wasted turns on dead-end commands, an agent that adapts instead of hitting a wall. See ADR-008 for what this project deliberately does not chase. ## Rationale - **Universality first:** `block` and `suggest` work in every harness - **Graceful degradation:** Fallback chains mean adapters never duplicate logic +- **Complete boundary coverage:** three phases mediate every point where a secret can cross into the model's context - **Learnability:** `{matched}` makes blocked actions self-explanatory - **Fail-fast validation:** Phase-behavior mismatches caught at load time, not at runtime ## Consequences -- Rules using `redact` in `before-tool` are rejected at load time +- Rules using `redact` in `before-tool`, or `suggest`/`run` in `user-input`, are rejected at load time - Adapters only declare capabilities; fallback is automatic - Adding a new behavior or phase requires updating the phase-behavior matrix and `validateRule()` together - `confirm`'s fallback chain (`confirm β†’ block`) never hands autonomy to the agent on the one behavior meant to require a human -- `haltTurn` requires two capability flags (`haltTurnBeforeTool`, `haltTurnAfterTool`) every adapter's `spec.md` must declare, verified at source level, not from vendor docs alone -- Codex cannot support `haltTurn` on `before-tool` β€” adapters must handle this as a plain, unmodified `block`, not attempt a `run`+`haltTurn` substitution workaround -- Each adapter's `spec.md` documents its own `block` mechanism explicitly (declarative field, return value, or throw-to-deny) rather than assuming a uniform mechanism +- The Claude Code adapter declares `redact` with a version floor of 2.1.121 and degrades `redact β†’ block` below it +- Each adapter's spec documents its own `block` mechanism explicitly rather than assuming a uniform mechanism diff --git a/docs/adrs/003-public-api-contract.md b/docs/adrs/003-public-api-contract.md index 15eb8ad..5b6e3f5 100644 --- a/docs/adrs/003-public-api-contract.md +++ b/docs/adrs/003-public-api-contract.md @@ -6,101 +6,67 @@ status: accepted ## Context -Agent Guardrails ships as a single package with layered internals (`core/`, `matcher/`, `resolver/`, `engine/`, `infrastructure/`). Without a defined public surface, consumers will import from any layer β€” creating coupling that makes refactoring impossible. The API contract defines what's public, what's internal, and why. +The package has two kinds of consumers: the two Tier 1 adapters in this repository, and people building their own harness who want to embed guardrails with one function call. The second door only works if the public surface is small enough to learn in minutes and stable enough to depend on. Without a defined surface, consumers import from any layer β€” creating coupling that makes refactoring impossible. ## Decision -### The Engine Is the Entry Point +### A deliberately minimal surface -All rule evaluation goes through `matchAndResolve()`. Adapters never call the resolver, command splitter, or matcher function directly. This keeps the internal wiring free to change without breaking consumers. +The public API is three things: an engine factory, the pack loader, and the public types. Everything else is `@internal`. ```typescript -import { matchAndResolve, processMatch, PredicateRegistry, StatsTracker } from "agent-guardrails"; -``` - -### Public Exports - -| Export | Layer | Purpose | -| --------------------------- | -------------- | --------------------------------------------------- | -| `matchAndResolve` | engine | Evaluate a tool call against rule packs (action only). Signature: `(ctx, packs, capabilities, registry, stats) => GuardrailAction \| null` | -| `processMatch` | engine | Evaluate a tool call β€” returns action + domain event trace. Same signature; returns `MatchResult`. | -| `StatsTracker` | engine | Class for accumulating intervention counters. Callers own the instance and pass it to `matchAndResolve`. | -| `Stats` | engine | Shape of a stats snapshot: `{ checks, blocks, suggests }` | -| `PredicateRegistry` | core | Register predicate matchers referenced by YAML packs. Callers own the instance and pass it to `matchAndResolve` and `loadAllRulePacks`. | -| `validateRule` | core | Check if a value is a valid GuardrailRule | -| `validateRulePack` | core | Check if a value is a valid RulePack | -| `getRuleErrors` | core | Descriptive validation errors for a rule | -| `getRulePackErrors` | core | Descriptive errors for a rule pack | -| `matchesMatcher` | matcher | Evaluate a `MatchCondition` against a `ToolCallContext` | -| `MAX_MATCH_INPUT_LENGTH` | matcher | Input length cap at which regex matchers fail-closed | -| `isKnownTool`, `extractTargets`, `isMissingRequiredFields` | core | Context validation/extraction helpers used by adapters and tests | -| `loadYamlRulePack` | infrastructure | Load a single YAML rule pack from disk | -| `loadAllRulePacks` | infrastructure | Load all packs from a directory | -| All types | core | `ToolCallContext`, `GuardrailAction`, `RulePack`, `DomainEvent`, `MatchResult`, `MatchCondition`, `PredicateFunction`, etc. | - -### Internal (Not Exported) - -| Module | Why internal | -| -------------------------- | ----------------------------------------------------------------- | -| `matchesMatcher` switch | Engine-internal dispatch on `MatchCondition.type` β€” adapters go through `matchAndResolve` | -| `splitCommands` | Engine-internal preprocessing β€” adapters never need raw splitting | -| `resolveAction` | Engine-internal resolution β€” adapters get the final action from `matchAndResolve` | -| `ResolveContext` | Paired with `resolveAction` β€” both stay internal | +import { createEngine, loadAllRulePacks } from "agent-guardrails"; -### Why `resolveAction` Is Internal +const packs = loadAllRulePacks("./packs", registry); +const engine = createEngine(packs, capabilities); -The resolver handles fallback chains, capability checks, and template interpolation. These are engine concerns. If a future change needs resolver extensibility (e.g., custom transform callbacks), that option threads through `matchAndResolve` β€” not by exposing the resolver directly. +// On each event (tool call, tool result, or user prompt): +const action = engine.evaluate(ctx); // GuardrailAction | null +``` -### Why `splitCommands` Is Internal +| Export | Purpose | +| ------------------ | --------------------------------------------------------------------------------------------- | +| `createEngine` | `(packs, capabilities, options?) => Engine`. Owns the collaborators (predicate registry, stats) internally; accepts pre-built ones via `options` for adapters that need to share them. | +| `Engine.evaluate` | `(ctx: ToolCallContext) => GuardrailAction \| null`. The whole integration: one call per event, covering all three phases via `ctx.phase`. | +| `Engine.processMatch` | Same evaluation, returns `MatchResult` (action + `DomainEvent[]` trace) for audit/telemetry. | +| `Engine.getStats` / `Engine.resetStats` | Session counters (ADR-006). | +| `PredicateRegistry` | Register named TypeScript predicates referenced by YAML packs. | +| `loadYamlRulePack`, `loadAllRulePacks` | Load rule packs from disk (the only file-I/O exports). | +| Validation helpers | `validateRule`, `validateRulePack`, `getRuleErrors`, `getRulePackErrors` β€” for pack authors and tooling. | +| Public types | `ToolCallContext`, `GuardrailAction`, `GuardrailRule`, `RulePack`, `HarnessCapabilities`, `MatchCondition`, `PredicateFunction`, `MatchResult`, `DomainEvent`, `Stats`. | -Command splitting is a preprocessing step the engine applies before matching. Adapters receive the final resolved action; they don't need to split commands themselves. If an adapter needs to reason about sub-commands, that's a signal the engine should expose richer match metadata β€” not that the splitter should be public. +### Internal (`@internal`, not exported) -### Why `matchesMatcher` Is Exported (But Not Required) +The matcher (`matchesMatcher`, command splitter), the resolver (`resolveAction`, fallback chains, interpolation), normalizer helpers, and the stats tracker class are engine plumbing. Adapters and embedders receive the final resolved action from `evaluate()`; they never re-implement matching or resolution. If a future need requires resolver extensibility (e.g. custom transform callbacks), that option threads through `createEngine`'s options β€” not by exporting the resolver. -`matchesMatcher` is the single matching function. It's exported so adapter authors writing tests or custom tooling can evaluate a `MatchCondition` directly, but production code should go through `matchAndResolve` so the full match β†’ resolve β†’ trace pipeline runs. +Visibility is enforced mechanically: every non-public symbol carries `@internal`, and the docs build strips them, so the published API reference _is_ the contract. -### Adapter Bootstrap Pattern +### Adapter bootstrap pattern ```typescript -import { - matchAndResolve, - processMatch, - loadAllRulePacks, - PredicateRegistry, - StatsTracker, -} from "agent-guardrails"; - -// 1. Construct collaborators (the caller owns their lifecycle) -const registry = new PredicateRegistry(); -const stats = new StatsTracker(); +import { createEngine, loadAllRulePacks, PredicateRegistry } from "agent-guardrails"; -// 2. Register adapter-specific predicates (if any) +const registry = new PredicateRegistry(); registry.register("my-check", (ctx) => /* ... */); -// 3. Load rule packs (registry needed to resolve predicate matchers) -const packs = loadAllRulePacks("./path/to/packs", registry); - -// 4. On each tool call -const action = matchAndResolve(ctx, packs, capabilities, registry, stats); -// β€” or, for audit/telemetry, use processMatch() to get the event trace: -// const { action, events } = processMatch(ctx, packs, capabilities, registry, stats); +const engine = createEngine(loadAllRulePacks("./packs", registry), capabilities, { registry }); -// 5. On session end -const snapshot = stats.getStats(); -stats.resetStats(); +// per event: +const action = engine.evaluate(ctx); +// on session end: +const snapshot = engine.getStats(); ``` ## Rationale -- **Stable contract:** Consumers depend on a small, intentional surface β€” not on internal file paths -- **Refactoring freedom:** Internal modules can be restructured without breaking adapters -- **No hidden state:** The engine has no module-level singletons. All collaborators are passed as arguments, so the data flow at every call site is visible and testable. -- **Progressive disclosure:** Types and validators are public for pack authors; engine plumbing is hidden for adapter authors +- **One function to integrate.** A harness author evaluates the library by reading one code block. `evaluate()` is the whole runtime contract. +- **Stable contract for community adapters.** Tier 2 adapters (ADR-009) build against this surface; keeping it minimal keeps it stable, and breaking it is a semver-major event. +- **Refactoring freedom.** The matcher/resolver/normalizer internals can be restructured without breaking anyone. +- **Progressive disclosure.** Types and validators are public for pack authors; engine plumbing is hidden for everyone. ## Consequences -- Adapters import from the package root, never from internal paths -- Adding new internal modules doesn't expand the public surface unless explicitly intended -- `resolveAction` extensibility (e.g., transform callbacks) is added to `matchAndResolve`'s signature, not by exporting the resolver -- The engine is a pure function. The `PredicateRegistry` and `StatsTracker` are passed as arguments; the caller (adapter or test) owns them. -- The barrel (`src/index.ts`) is the single source of truth for what's public β€” if it's not re-exported there, it's internal +- Consumers import from the package root, never from internal paths. +- The barrel (`src/index.ts`) is the single source of truth for what's public β€” if it's not re-exported there, it's internal. +- Adding new internal modules doesn't expand the public surface unless explicitly intended. +- An API-visibility check (typedoc/API-extractor) runs in CI so `@internal` drift is caught mechanically. diff --git a/docs/adrs/005-yaml-rule-packs.md b/docs/adrs/005-yaml-rule-packs.md index 61abbbf..bfd1bf7 100644 --- a/docs/adrs/005-yaml-rule-packs.md +++ b/docs/adrs/005-yaml-rule-packs.md @@ -12,7 +12,7 @@ Not everyone who wants to contribute a guardrail rule knows TypeScript. Security ### YAML for Both Built-in and User Packs -All built-in rule packs (`env`, `sops`, `private-key`, `encryption-tools`, `secret-managers`, `kubernetes`, `direnv`, `gh-cli`, `hardening`) are defined in YAML, not TypeScript. Users extend the system with the same format β€” drop a `.yaml` file in `.agent-guardrails/packs/` and it's loaded automatically. +All built-in rule packs β€” security packs (`env`, `sops`, `private-key`, `encryption-tools`, `secret-managers`, `hardening`) and steering packs (`modern-cli`, `package-manager`, `git-safety`) β€” are defined in YAML, not TypeScript. Users extend the system with the same format β€” drop a `.yaml` file in `.agent-guardrails/packs/` and it's loaded automatically. This means no dual-maintenance: built-in packs and community packs use identical formats and the same validation pipeline. diff --git a/docs/adrs/006-observability-strategy.md b/docs/adrs/006-observability-strategy.md index e41c118..a6b8842 100644 --- a/docs/adrs/006-observability-strategy.md +++ b/docs/adrs/006-observability-strategy.md @@ -29,18 +29,18 @@ source of truth, not two. Tier 3 is an extension of that same persisted log - Total checks, blocks, and suggests -Public API: `stats.getStats()` for snapshots, `stats.resetStats()` to zero counters. Adapters own a `StatsTracker` instance (constructed at startup), pass it to every `matchAndResolve` / `processMatch` call, and call `stats.getStats()` at session end to log to their harness-native output. +Public API: `engine.getStats()` for snapshots, `engine.resetStats()` to zero counters (ADR-003). The engine instance owns the tracker; adapters call `engine.getStats()` at session end to log to their harness-native output. ### Domain Events -The engine produces a decision trace alongside every action via `processMatch()`, which returns a `MatchResult` containing both the resolved `action` and an ordered list of `DomainEvent`s. +The engine produces a decision trace alongside every action via `engine.processMatch()`, which returns a `MatchResult` containing both the resolved `action` and an ordered list of `DomainEvent`s. | Event Type | When Emitted | | ---------------------- | ------------------------------------------------------------------- | | `RuleMatchedEvent` | A rule's match condition fires against the tool call | | `FallbackTriggeredEvent` | The resolver walks the fallback chain (e.g., runβ†’suggestβ†’block) | -`matchAndResolve()` remains the primary public API and returns only the `GuardrailAction`. Adapters that need the trace (audit, telemetry, debugging) call `processMatch()` instead. +`engine.evaluate()` remains the primary public API and returns only the `GuardrailAction`. Adapters that need the trace (audit, telemetry, debugging) call `engine.processMatch()` instead. ### Tier 2: Persisted `DomainEvent` Log (design, deferred) @@ -76,8 +76,7 @@ audit/telemetry use cases. Deferred until a concrete consumer is requested. - Stats are lost on process restart β€” Tier 2 needed for historical analysis - Domain events are not exposed yet β€” the public API returns only actions - Tier 2 requires file I/O infrastructure (append-only writer, age-capped retention, - redaction-at-write reusing the same redaction logic as `redact` β€” see - `change-10-redact-output`); a persisted, replayable event store is low-cost - follow-on work since the trace already exists + redaction-at-write reusing the same redaction logic as `redact`); a persisted, + replayable event store is low-cost follow-on work since the trace already exists - Tier 3 is a streaming extension of Tier 2's log, not an independent design β€” no separate event system needs to be built diff --git a/docs/adrs/007-trust-and-self-protection.md b/docs/adrs/007-trust-and-self-protection.md index 7bf948c..e6d57de 100644 --- a/docs/adrs/007-trust-and-self-protection.md +++ b/docs/adrs/007-trust-and-self-protection.md @@ -30,9 +30,9 @@ no specific matched pattern: - **`run` excluded** β€” a catch-all has no single replacement command to execute; `run` only makes sense tied to a specific matched pattern. - **`redact` excluded** β€” phase mismatch. `defaultDecision` is before-tool only; - catch-all output scrubbing is a separate concern (see Phase 2 / `change-15`). + catch-all output scrubbing is a separate concern. - **`confirm`** gives the synchronous, harness-native human gate, with its existing - capability-driven fallback (now `confirm β†’ block`, see the ADR-002 revision below). + capability-driven fallback (`confirm β†’ block`, ADR-002). - **`suggest`** gives the LLM-directed steer ("tell the agent to ask the user") with no native-UI dependency and no synchronous human involvement. A single `ask` enum can't express both this and `confirm`'s synchronous human gate in one value, @@ -52,8 +52,8 @@ packs already define as "sensitive," rather than inventing a new concept. **`strict` preset:** `{ defaultDecision: 'confirm' }` scoped as above, plus the `hardening` pack forced on and non-overridable. One flag, one trust signal. No preset-specific fallback override is needed β€” `confirm`'s universal fallback chain -(`confirm β†’ block`, see the ADR-002 revision) already produces the safe behavior -`strict` requires on harnesses without native confirm UI. +(`confirm β†’ block`, ADR-002) already produces the safe behavior `strict` requires on +harnesses without native confirm UI. **Adapter contract, distinct from `defaultDecision`.** Engine throw/timeout is not "no rule matched" β€” on a crash the engine failed *before* determining whether the call @@ -62,10 +62,7 @@ was even in `defaultDecision`'s scope, so there's no scope to apply. non-configurably** β€” not derived from `defaultDecision`, not user-tunable. A crash is rare (a bug, not routine traffic), so the ergonomic cost `defaultDecision`'s scoping exists to avoid doesn't apply here, and there is no legitimate case for "allow on -crash." This is currently unspecified β€” nothing today says what an adapter does if -`matchAndResolve`/`processMatch` throws. It is the actual fail-open hole. Enforced as -a testable requirement in each adapter's `spec.md` (`change-3`, `change-4`, `change-6`, -`change-7`). +crash." Enforced as a testable requirement in each adapter's spec. ### 2. `overridable: false` β€” rule-level, built-in-packs-only @@ -90,21 +87,16 @@ designed now β€” tracked as a one-line forward pointer in `future-architecture-decisions.md`. **Resolver requirement:** a `Configured Action` MUST be rejected/ignored for any -built-in rule with `overridable: false`, regardless of `mode`/config precedence. This -closes the gap `design-session-summary.md` flagged as "NonOverridable Block Action" and -never carried into the type system. Tracked as two new requirements in -`change-13-rule-configuration/specs/config/spec.md`: "Configuration Override -Rejection" and "Community Pack Trust Boundary." +built-in rule with `overridable: false`, regardless of `mode`/config precedence. ### 3. `capabilities.tamperResistant: boolean` Added to `HarnessCapabilities`. `true` only for adapters that run as an external hook -process the harness invokes (Claude Code, Codex). `false` for in-process plugin -adapters (Pi, OpenCode). This is a statement of fact about the adapter's deployment, -not a behavior the engine changes β€” no resolver logic needed, just a declared field -each adapter's `spec.md` sets and justifies in one sentence. It is the same -in-process/out-of-process split that determines `haltTurnBeforeTool` (see the ADR-002 -revision) β€” the two findings are related, not coincidental. +process the harness invokes (Claude Code). `false` for in-process plugin adapters +(Pi). This is a statement of fact about the adapter's deployment, not a behavior the +engine changes β€” no resolver logic needed, just a declared field each adapter's spec +sets and justifies in one sentence. Community adapters (ADR-009) declare it for their +own harness the same way. Feeds directly into the README capability table and gives the "external hook process vs. in-process plugin" distinction a concrete, checkable artifact instead of prose in a @@ -114,9 +106,9 @@ research doc. Default posture is `allow` on no-match, because a coding agent that halts on every unmatched call is unusable. `defaultDecision` exists precisely so an operator can opt -into a stricter posture where the ergonomics trade-off is worth it. Previously this was -an implicit `return null`. `SECURITY.md` and ADR-002/ADR-003 should link to this -rationale instead of leaving it to be inferred from a bare `null`. +into a stricter posture where the ergonomics trade-off is worth it. `SECURITY.md` and +`LIMITATIONS.md` link to this rationale instead of leaving it to be inferred from a +bare `null`. ## Rationale @@ -131,7 +123,7 @@ rationale instead of leaving it to be inferred from a bare `null`. ## Consequences -- `HarnessCapabilities` gains `tamperResistant: boolean` β€” every adapter's `spec.md` +- `HarnessCapabilities` gains `tamperResistant: boolean` β€” every adapter's spec must declare and justify it. - `GuardrailRule` gains `overridable?: boolean` (default `true`); the pack loader gains a downgrade-with-warning path for community packs. diff --git a/docs/adrs/008-non-goals.md b/docs/adrs/008-non-goals.md index da1d246..85c2808 100644 --- a/docs/adrs/008-non-goals.md +++ b/docs/adrs/008-non-goals.md @@ -6,56 +6,31 @@ status: accepted ## Context -Once a security-adjacent project positions itself against a fuller platform (e.g. a -sandboxing daemon like agentjail), contributors and evaluators start comparing feature -lists and proposing "closing the gap" on things that were never this project's shape to -build. Non-goals are architectural decisions with rationale β€” they belong here, in ADR -form, not folded into `SECURITY.md`, which is a disclosure/reporting document, not a -scope-setting one. +Once a security-adjacent project positions itself against fuller platforms (sandboxing daemons, policy-as-code engines), contributors and evaluators start comparing feature lists and proposing "closing the gap" on things that were never this project's shape to build. Non-goals are architectural decisions with rationale β€” they belong here, in ADR form, not folded into `SECURITY.md`, which is a disclosure/reporting document, not a scope-setting one. ## Decision Agent Guardrails will **not** build: -- **A daemon process.** The engine is a pure function invoked synchronously inside an - adapter's hook call. A long-running daemon is a different architecture (process - lifecycle, IPC, privilege separation) built for a different guarantee - (`tamperResistant` containment) this project doesn't claim (see ADR-007). This is - agentjail's shape, not ours. -- **An auto-update mechanism.** Agent Guardrails ships as a library/CLI installed via - npm; version management is the package manager's job, not this project's. -- **A telemetry service.** No usage data leaves the user's machine. Tier 2/3 - observability (ADR-006) is a local, opt-in event log β€” not a service this project - operates or receives data from. -- **A local UI server.** No always-on process to serve a dashboard. `npx ag stats` - (ADR-006) is a one-shot CLI query over a local file, not a server. -- **A network egress proxy.** Filtering outbound network traffic requires a process - positioned in the network path (or a kernel-level hook), which is a fundamentally - different architecture than intercepting tool calls inside an agent harness's - process. Pair with an OS-level sandbox or network-policy tool for this. -- **A secrets broker.** Agent Guardrails detects and blocks/redacts secret access; it - does not vault, rotate, or issue credentials. That's a distinct product category - (e.g. a secrets manager) this project integrates around (`secret-managers` rule - pack), not replaces. +- **A security boundary.** The project is a steering layer: it changes what happens on the calls that run, it does not guarantee containment against an adversarial agent. In-process adapters share the agent's process; even out-of-process hook adapters read user-editable config. The companion pairing is explicit: **agentjail** answers "can the agent do X at all" with a fail-closed daemon and kernel-backed tiers (containment); **never-inject credential proxies** keep registered secrets out of the flow entirely β€” never-inject what you know about, redact what you didn't. See `LIMITATIONS.md` for the full statement per adapter. +- **A policy-as-code engine.** No Rego/OPA, no Cedar. Those are authorization languages β€” allow/deny verdicts over principal–action–resource. This project's value is in _transformations with payloads_ (`suggest` carries a replacement, `redact` carries a rewrite, `run` carries a substitution), which have no natural home in an authorization language: they end up as strings smuggled inside policy results. The authoring surface is YAML rules plus registered TypeScript predicates (ADR-005) β€” the lowest contribution barrier in the field, for an audience of individual developers dropping a file in a folder, not security teams auditing shared org policy. +- **A daemon process.** The engine is a pure function invoked synchronously inside an adapter's hook call. A long-running daemon is a different architecture (process lifecycle, IPC, privilege separation) built for a containment guarantee this project doesn't claim. That is agentjail's shape, not ours. +- **An auto-update mechanism.** Agent Guardrails ships as a library/CLI installed via npm; version management is the package manager's job. +- **A telemetry service.** No usage data leaves the user's machine. Tier 2/3 observability (ADR-006) is a local, opt-in event log β€” not a service this project operates or receives data from. +- **A local UI server.** No always-on process to serve a dashboard. `npx ag stats` (ADR-006) is a one-shot CLI query over a local file, not a server. +- **A network egress proxy.** Filtering outbound network traffic requires a process positioned in the network path (or a kernel-level hook) β€” a fundamentally different architecture than intercepting events inside an agent harness. Pair with an OS-level sandbox or network-policy tool for this. +- **A secrets broker.** Agent Guardrails detects and blocks/redacts secret access; it does not vault, rotate, or issue credentials. That's a distinct product category this project integrates around (`secret-managers` rule pack), not replaces. ## Rationale -- **Each exclusion matches an architecture, not a feature checklist.** Every item - above requires a persistent process, privileged position, or service boundary this - project's "pure function inside an adapter hook" shape doesn't have and isn't trying - to grow into. -- **Naming the exclusions pre-empts scope creep.** Without a written non-goals list, - every gap versus a fuller platform reads as an oversight to be closed rather than a - deliberate boundary. -- **This is a living boundary, not a permanent one.** If the project's architecture - changes (e.g. splitting into multiple packages, per `future-architecture-decisions.md`), - these non-goals should be revisited, not assumed permanent. +- **Each exclusion matches an architecture, not a feature checklist.** Every item above requires a persistent process, privileged position, service boundary, or language runtime this project's "pure function inside an adapter hook" shape doesn't have and isn't trying to grow into. +- **The security-boundary exclusion is a positioning decision, not just an architecture one.** Claiming a boundary invites an adversarial evaluation standard the in-process architecture cannot meet; ceding it β€” and naming the companions that do meet it β€” converts the structural weakness into a credibility feature. +- **Naming the exclusions pre-empts scope creep.** Without a written non-goals list, every gap versus a fuller platform reads as an oversight to be closed rather than a deliberate boundary. +- **This is a living boundary, not a permanent one.** If the project's architecture changes, these non-goals should be revisited, not assumed permanent. ## Consequences -- Feature requests matching any bullet above should be closed with a pointer to this - ADR, not silently ignored or endlessly re-litigated. -- `SECURITY.md` links here instead of restating this list. -- `README.md`'s scope framing (ADR-002's Mediation Scope decision) and this ADR say - the same thing from two angles: what the project claims to do (ADR-002), and what it - explicitly won't build (this ADR). +- Feature requests matching any bullet above should be closed with a pointer to this ADR, not silently ignored or endlessly re-litigated. +- `SECURITY.md` and `LIMITATIONS.md` link here instead of restating this list. +- README carries the honesty line: a steering layer, not a sandbox β€” pair with agentjail for containment. +- Rule authoring stays YAML + TypeScript predicates; proposals to adopt a policy language are out of scope. diff --git a/docs/adrs/009-adapter-scope-and-tiering.md b/docs/adrs/009-adapter-scope-and-tiering.md new file mode 100644 index 0000000..cb252c9 --- /dev/null +++ b/docs/adrs/009-adapter-scope-and-tiering.md @@ -0,0 +1,35 @@ +--- +status: accepted +--- + +# ADR-009: Adapter Scope and Tiering + +## Context + +Every adapter is a maintenance commitment that scales with its harness's churn: hook APIs change, capability surfaces grow, version floors move. The project's differentiators β€” the capability model and fallback chains β€” justify themselves across _unequal_ harnesses, but they don't require the project to own an adapter for every harness that exists. The scope question is: which adapters does this project ship and maintain, and how does everything else integrate? + +## Decision + +### Tier 1 β€” Shipped and maintained by this project + +- **Pi** β€” the primary adapter. An in-process TypeScript plugin with a live handle into the running session: `run`, `redact`, `confirm`, `redactUserInput`, and turn-halting all bind natively. Pi is also an uncontested niche β€” no other guardrail project supports it. +- **Claude Code** β€” external hooks, out-of-process. Its hook API natively binds all five behaviors: `permissionDecision: "deny"` (`block`/`suggest`), `updatedInput` (`run`), `updatedToolOutput` β‰₯ 2.1.121 (`redact`), `permissionDecision: "ask"` (`confirm`), and `UserPromptSubmit` (`user-input`). The adapter is thin because the mechanisms are native. + +### Tier 2 β€” Stable interface, community-owned implementations + +Any other harness (OpenCode, Codex, Cursor, …) integrates by implementing the published adapter interface: normalize harness events into `ToolCallContext`, declare `HarnessCapabilities`, and translate resolved `GuardrailAction`s into harness mechanisms. Tier 2 adapters live outside this repository; the project's commitment is interface stability (ADR-003) and documentation, not implementation or maintenance. + +Promotion from Tier 2 to Tier 1 is demand-driven β€” sustained real-world use and a maintainer willing to track the harness's churn. + +## Rationale + +- **Maintenance scales with harness churn.** Two first-party adapters is the bill the project can pay indefinitely; four is not. +- **The multi-harness thesis survives without the multi-harness bill.** The adapter interface, the capability model, and the fallback chains are the portable assets. They prove themselves across two maximally unequal harnesses β€” in-process plugin versus out-of-process subprocess is exactly the asymmetry the capability model and `tamperResistant` flag exist to express. A third or fourth harness adds coverage, not architectural information. +- **The two Tier 1 choices are complementary.** Pi is the daily-driver harness and a durable niche; Claude Code is where the audience is, and its native five-behavior surface makes the adapter cheap to keep current. + +## Consequences + +- This repository contains `adapters/pi/` and `adapters/claude-code/` and nothing else. +- The public API (ADR-003) is the community adapter's contract; breaking it is a semver-major event. +- Docs list OpenCode, Codex, and others only as community-tier possibilities, never as first-party roadmap items. +- Capability tables in docs and code carry rows for Pi and Claude Code; community adapters publish their own. diff --git a/docs/adrs/010-user-input-mediation.md b/docs/adrs/010-user-input-mediation.md new file mode 100644 index 0000000..f7ed110 --- /dev/null +++ b/docs/adrs/010-user-input-mediation.md @@ -0,0 +1,46 @@ +--- +status: accepted +--- + +# ADR-010: User-Input Mediation (`user-input` phase) + +## Context + +Tool-call mediation covers what the model does and what it reads back β€” but not what the user types. An API key pasted into a prompt goes straight to the provider's API and its logs; no tool call is involved, so `before-tool`/`after-tool` rules never see it. Both Tier 1 harnesses expose an interception point for this: Claude Code's `UserPromptSubmit` hook, and Pi's in-process input pipeline. + +## Decision + +`user-input` is the third phase alongside `before-tool` and `after-tool`. Rules in this phase match against the prompt text the user submitted, before it reaches the provider API. + +### Phase-behavior matrix + +| Behavior | `user-input` | Semantics in this phase | +| --------- | ------------ | -------------------------------------------------------------- | +| `redact` | βœ… | Rewrite the prompt β€” secret shapes replaced with placeholders β€” and submit the scrubbed version | +| `block` | βœ… | Reject the prompt with a message; nothing reaches the API | +| `confirm` | βœ… | Ask the user whether to submit as-is | +| `suggest` | ❌ | A tool-call concept β€” there is no "replacement command" for a prompt; the redacted rewrite _is_ the steer | +| `run` | ❌ | A tool-call concept β€” nothing to execute | + +Fallbacks follow ADR-002's monotonic-restriction principle: `redact β†’ block` and `confirm β†’ block` when the harness lacks the capability. + +### Capability + +`HarnessCapabilities` carries `redactUserInput: boolean` β€” whether the harness can rewrite the submitted prompt (as opposed to only blocking it). Pi: `true` (in-process interception). Claude Code: `true` (`UserPromptSubmit` structured output). + +### Context shape + +The engine receives a `ToolCallContext`-compatible context with `phase: "user-input"` and the prompt text as the match target. Bash-command matchers don't apply; substring, regex, and predicate matchers do. + +## Rationale + +- **Completes the boundary story.** With this phase the project mediates every boundary where a secret can cross into the model's context: user input, tool calls, tool output. +- **Small lift.** One more event type through the same engine, matchers, and resolver β€” no new architecture. +- **Underserved surface.** Tool-output scrubbing is widely built; scrubbing what the user pastes in is not, despite being the most common accidental leak path. + +## Consequences + +- `validateRule()` enforces the `user-input` phase-behavior matrix at pack load time. +- Both Tier 1 adapters wire the phase: Claude Code via `UserPromptSubmit`, Pi via input interception. +- Secret-pattern rule packs can declare `user-input` rules reusing the same match conditions as their `after-tool` redaction rules. +- This is a hygiene layer for accidental pastes, not a data-loss-prevention guarantee (ADR-008, LIMITATIONS.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index c857beb..05d9bd7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,10 +1,10 @@ # Getting Started with Agent Guardrails -Welcome! This is the contributor gateway for Agent Guardrails β€” a policy engine that intercepts AI coding agent tool calls and enforces security rules before they execute. +Welcome! This is the contributor gateway for Agent Guardrails β€” a steering engine that mediates AI coding agent events (user input, tool calls, tool output) against rule packs. ## What This Project Does -Your AI coding agent shouldn't read `.env` files, decrypt secrets, or expose private keys. Agent Guardrails stops it before it happens. +Your AI coding agent shouldn't read `.env` files raw, `grep` when `rg` is faster, or force-push over shared history. Agent Guardrails steers it to the better move β€” and blocks when nothing safe exists. ``` Agent Tool Call β†’ Match Rules β†’ Resolve Action β†’ Enforce @@ -28,48 +28,27 @@ No TypeScript required. Write a YAML file describing what to watch for, submit a - **Examples:** Built-in packs in `src/packs/` - **Ideas:** Check the backlog of planned packs below -### 🟑 Medium: Build an Adapter +### 🟑 Medium: Embed the Library or Build an Adapter -Adapters are thin shims between a harness (Pi, OpenCode, Codex, Claude Code) and the engine. If your favorite harness isn't supported yet, this is a high-impact contribution. - -The engine (`matchAndResolve` / `processMatch`) is a pure function with a stable public -API ([ADR-003](adrs/003-public-api-contract.md)). Import it directly as your harness's -permission-system implementation instead of writing rule matching from scratch. You own -the adapter glue; the engine owns matching, resolution, and fallback. See ADR-003's -Adapter Bootstrap Pattern for the full pattern. - -#### Programmatic API - -Adapters use the engine's public API to match and resolve actions: +Building your own harness, or want guardrails in an agent app? The library is one function ([ADR-003](adrs/003-public-api-contract.md)): ```typescript -import { matchAndResolve, loadAllRulePacks, PredicateRegistry, StatsTracker } from "agent-guardrails"; +import { createEngine, loadAllRulePacks, PredicateRegistry } from "agent-guardrails"; -// Construct collaborators β€” the caller owns their lifecycle const registry = new PredicateRegistry(); -const stats = new StatsTracker(); - -// Register adapter-specific predicates (if any) registry.register("my-check", (ctx) => /* ... */); -const packs = loadAllRulePacks("./packs", registry); - -const capabilities = { +const engine = createEngine(loadAllRulePacks("./packs", registry), { block: true, suggest: true, run: false, redact: false, confirm: true, -}; + redactUserInput: false, +}, { registry }); -// On each tool call: -const action = matchAndResolve( - { toolName: "bash", command: "cat .env" }, - packs, - capabilities, - registry, - stats, -); +// On each tool call, tool result, or user prompt: +const action = engine.evaluate({ toolName: "bash", command: "cat .env" }); if (action?.type === "block") { console.log(action.message); @@ -77,21 +56,22 @@ if (action?.type === "block") { } ``` +You own the glue; the engine owns matching, resolution, and fallback. Community adapters for other harnesses (OpenCode, Codex, …) build against this same surface β€” see [ADR-009](adrs/009-adapter-scope-and-tiering.md) for the tiering model. + Run `npm run docs` to generate the full API reference locally (outputs to `docs/api/`). -#### Adapter Capabilities (design target) +#### Adapter Capabilities Not every harness supports every behavior. Source-verified per [ADR-002](adrs/002-behavior-model.md) / [ADR-007](adrs/007-trust-and-self-protection.md). -Unsupported behaviors degrade via the [fallback chain](#fallback-chains). Pi and OpenCode -adapters are WIP; Codex and Claude Code are planned β€” see the main [README](../README.md#adapters). +Unsupported behaviors degrade via the [fallback chain](#fallback-chains). -| Harness | `run` | `redact` | `confirm` | `tamperResistant` | `haltTurnBeforeTool` | `haltTurnAfterTool` | -| ----------- | :---: | :------: | :-------: | :----------------: | :-------------------: | :--------------------: | -| Pi | βœ… | βœ… | βœ… | ❌ | βœ… | βœ… | -| OpenCode | βœ… | βœ… | βœ… | ❌ | βœ… | βœ… | -| Claude Code | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | -| Codex | ❌ | ❌ | βœ… | βœ… | ❌ | βœ… | +| Harness | `run` | `redact` | `confirm` | `redactUserInput` | `tamperResistant` | `haltTurnBeforeTool` | `haltTurnAfterTool` | +| ----------- | :---: | :------: | :-------: | :---------------: | :----------------: | :-------------------: | :--------------------: | +| Pi | βœ… | βœ… | βœ… | βœ… | ❌ | βœ… | βœ… | +| Claude Code | βœ… | βœ… (β‰₯ 2.1.121) | βœ… | βœ… | βœ… | βœ… | βœ… | + +Community adapters declare their own row against the same flags ([ADR-009](adrs/009-adapter-scope-and-tiering.md)). ### πŸ”΄ Deeper: Engine Improvements @@ -172,16 +152,20 @@ L1+L3 match = force-block regardless of L2 (adversarial pattern detected). ### Architectural Decisions -Agent Guardrails is built on six core architectural decisions. Read these in order: - -| # | ADR | What it covers | -| --- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| 1 | [Layered Architecture](adrs/001-layered-architecture.md) | Package structure, dependency direction, module responsibilities | -| 2 | [Behavior Model](adrs/002-behavior-model.md) | What the engine can do (block/suggest/run/redact/confirm), phase constraints, fallback chains | -| 3 | [Public API Contract](adrs/003-public-api-contract.md) | What's exported, what's internal, adapter bootstrap pattern | -| 4 | [Matching Strategy](adrs/004-matching-strategy.md) | Three-layer defense-in-depth, risk escalation, command splitting | -| 5 | [YAML Rule Packs](adrs/005-yaml-rule-packs.md) | Why YAML, built-in packs, predicate limitations | -| 6 | [Observability](adrs/006-observability-strategy.md) | In-memory stats, tiered roadmap | +Read these in order: + +| # | ADR | What it covers | +| --- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| 1 | [Layered Architecture](adrs/001-layered-architecture.md) | Package structure, dependency direction, module responsibilities | +| 2 | [Behavior Model](adrs/002-behavior-model.md) | Five behaviors, three phases, capability table, fallback chains | +| 3 | [Public API Contract](adrs/003-public-api-contract.md) | The minimal embeddable surface: `createEngine`/`evaluate`, pack loader, public types | +| 4 | [Matching Strategy](adrs/004-matching-strategy.md) | Three-layer defense-in-depth, risk escalation, command splitting | +| 5 | [YAML Rule Packs](adrs/005-yaml-rule-packs.md) | Why YAML, built-in packs, predicate limitations | +| 6 | [Observability](adrs/006-observability-strategy.md) | In-memory stats, tiered roadmap | +| 7 | [Trust & Self-Protection](adrs/007-trust-and-self-protection.md) | `defaultDecision`, `--strict`, `overridable`, `tamperResistant`, fail-open/fail-closed | +| 8 | [Non-Goals](adrs/008-non-goals.md) | What this project deliberately does not build | +| 9 | [Adapter Scope & Tiering](adrs/009-adapter-scope-and-tiering.md) | Tier 1 (Pi, Claude Code) vs. community-owned Tier 2 adapters | +| 10 | [User-Input Mediation](adrs/010-user-input-mediation.md) | The `user-input` phase β€” scrubbing what the user pastes into the prompt | ### Practical Guides @@ -200,10 +184,10 @@ Agent Guardrails uses precise terms. Here's what you need to know: | Rule Pack | Named collection of rules (YAML or TypeScript) | | Behavior | Category: block/suggest/run/redact/confirm | | Action | Concrete response object (e.g., a suggest action with replacement + message) | -| Phase | When a rule fires: before-tool or after-tool | +| Phase | When a rule fires: user-input, before-tool, or after-tool | | ToolCallContext | Normalized input from a harness (discriminated union on `toolName`) | -| Adapter | Integration shim for a specific harness (Pi, OpenCode, etc.) | -| Harness | The platform (Pi, OpenCode). NOT the agent (the AI model). | +| Adapter | Integration shim for a specific harness. Tier 1 (first-party): Pi, Claude Code. Tier 2: community-owned ([ADR-009](adrs/009-adapter-scope-and-tiering.md)). | +| Harness | The platform (Pi, Claude Code). NOT the agent (the AI model). | | Fallback Chain | Deterministic degradation when a harness lacks a capability | | Matcher | User-facing name for a match condition: bash-command (regex), file-path (regex), or predicate (TypeScript function). Internally represented as a `MatchCondition` discriminated union. | | Match Condition | A rule's `match` field β€” a declarative spec that the engine evaluates via `matchesMatcher()`. Type alias: `MatchCondition`. | @@ -216,7 +200,7 @@ Agent Guardrails uses precise terms. Here's what you need to know: - **Behavior vs Action:** Behavior is the category (block/suggest/run/redact/confirm). Action is the concrete response object (e.g., a "suggest action" with a replacement and message). - **Rule Pack vs package:** A rule pack is a domain concept (a collection of rules). A package is the npm artifact. -- **Harness vs agent:** The harness is the platform (Pi, OpenCode). The agent is the AI model running inside it. Adapters integrate with harnesses, not agents. +- **Harness vs agent:** The harness is the platform (Pi, Claude Code). The agent is the AI model running inside it. Adapters integrate with harnesses, not agents. ## Code Style (Quick Reference) diff --git a/docs/rule-pack-guide.md b/docs/rule-pack-guide.md index a681e77..b567766 100644 --- a/docs/rule-pack-guide.md +++ b/docs/rule-pack-guide.md @@ -44,7 +44,7 @@ rules: - id: my-pack.rule-name # Stable dotted ID (pack.rule) title: Short title # Appears in match reports description: What this catches and why it matters - phase: before-tool # before-tool or after-tool + phase: before-tool # user-input, before-tool, or after-tool match: type: bash-command # bash-command or file-path pattern: "regex-here" # Regex matching the tool call input @@ -111,6 +111,67 @@ defaultAction: message: "Blocked: `{matched}` β€” confirmation required." ``` +## Steering Rules (Quality of Life) + +The same format covers non-security steering β€” rules that keep the agent on the fast path. These fire every session: + +```yaml +rules: + # grep β†’ rg: the agent gets a faster tool and keeps moving + - id: modern-cli.grep + title: Prefer ripgrep over grep + phase: before-tool + match: + type: bash-command + pattern: "^grep\\b|[;&|]\\s*grep\\b" + defaultAction: + type: suggest + replacement: "rg" + message: "`{matched}` β€” use `rg` instead: faster, respects .gitignore." + + # npm in a pnpm repo: catch the mismatch before it corrupts the lockfile + - id: package-manager.npm-in-pnpm + title: npm install in a pnpm repository + phase: before-tool + match: + type: predicate + predicateName: npm-in-pnpm-repo + defaultAction: + type: suggest + replacement: "pnpm add" + message: "This repo uses pnpm (`pnpm-lock.yaml` present). `{matched}` would create a conflicting lockfile." + + # committing to a protected branch: a human should decide + - id: git-safety.commit-to-main + title: Confirm commits to protected branches + phase: before-tool + match: + type: predicate + predicateName: on-protected-branch + defaultAction: + type: confirm + message: "You're about to commit directly to a protected branch. Proceed?" +``` + +## User-Input Rules + +The `user-input` phase matches the prompt text the user submits, before it reaches the provider API ([ADR-010](adrs/010-user-input-mediation.md)). Use it to scrub pasted secrets: + +```yaml +rules: + - id: env.pasted-key + title: Scrub API keys pasted into prompts + phase: user-input + match: + type: bash-command # matches the prompt text for user-input rules + pattern: "(sk|ghp|xox[bap])-[A-Za-z0-9_-]{20,}" + defaultAction: + type: redact + message: "A secret-shaped value in your prompt was replaced with a placeholder before sending." +``` + +`redact` rewrites the prompt; `block` and `confirm` are also valid in this phase. `suggest` and `run` are tool-call concepts and are rejected at load time. + ## Message Templates Use `{matched}` in any message field. At match time, it's replaced with the actual command or file path that triggered the rule: @@ -122,30 +183,19 @@ message: "Blocked: `{matched}` β€” this file may contain secrets." ## Phase-Behavior Matrix -Not every action works in every phase: +Not every action works in every phase ([ADR-002](adrs/002-behavior-model.md)): | Phase | block | suggest | run | redact | confirm | | ------------- | :---: | :-----: | :-: | :----: | :-----: | +| `user-input` | βœ… | ❌ | ❌ | βœ… | βœ… | | `before-tool` | βœ… | βœ… | βœ… | ❌ | βœ… | | `after-tool` | ❌ | ❌ | ❌ | βœ… | ❌ | -`before-tool` fires _before_ the command runs (most common). `after-tool` fires after and can only redact output. Invalid combinations are rejected at load time. +`user-input` fires on the prompt the user submits. `before-tool` fires _before_ the command runs (most common). `after-tool` fires after and can only redact output. Invalid combinations are rejected at load time. ## Built-in Rule Packs -Agent Guardrails will ship with these packs (_all upcoming_): - -| Pack ID | What it catches | -| ------------------ | ---------------------------------------------------- | -| `env` | `.env` and `.env.*` file reads | -| `sops` | `sops -d` / `sops --decrypt` commands | -| `private-key` | `.pem`, `.key`, SSH key file reads | -| `encryption-tools` | `age -d`, `gpg --decrypt`, `openssl enc -d` | -| `secret-managers` | `op read`, `gopass show`, `pass show`, `bw get` | -| `kubernetes` | `kubectl get secrets`, `kubectl describe secrets` | -| `gh-cli` | `gh secret view`, `gh variable get` | -| `direnv` | `direnv exec`, `source .env` | -| `hardening` | Eval/subshell wrappers, redirects to sensitive paths | +See the [Pack Gallery](packs.md) for the full auto-generated list of shipped packs and rules. Steering packs: `modern-cli`, `package-manager`, `git-safety`. Security packs: `env`, `sops`, `private-key`, `encryption-tools`, `secret-managers`, `hardening`. ## Overriding Built-in Rules diff --git a/src/core/harness-capabilities.ts b/src/core/harness-capabilities.ts new file mode 100644 index 0000000..646145a --- /dev/null +++ b/src/core/harness-capabilities.ts @@ -0,0 +1,60 @@ +import type { HarnessCapabilities } from './types.js' + +/** + * Pi (Tier 1, ADR-009): in-process plugin with a live session handle. + * All behaviors bind natively; in-process placement means no tamper resistance (ADR-007). + */ +export const PI_CAPABILITIES: HarnessCapabilities = { + block: true, + suggest: true, + run: true, + redact: true, + confirm: true, + redactUserInput: true, + tamperResistant: false, + haltTurnBeforeTool: true, + haltTurnAfterTool: true, +} + +/** + * Claude Code version from which PostToolUse `updatedToolOutput` applies to + * all tools, enabling the `redact` behavior (ADR-002). + */ +export const CLAUDE_CODE_REDACT_MIN_VERSION = '2.1.121' + +/** + * Claude Code (Tier 1, ADR-009): out-of-process hooks binding all five + * behaviors natively. `redact` requires Claude Code β‰₯ 2.1.121 β€” use + * {@link claudeCodeCapabilities} to gate on the installed version. + */ +export const CLAUDE_CODE_CAPABILITIES: HarnessCapabilities = { + block: true, + suggest: true, + run: true, + redact: true, + confirm: true, + redactUserInput: true, + tamperResistant: true, + haltTurnBeforeTool: true, + haltTurnAfterTool: true, +} + +/** + * Claude Code capabilities gated on the installed CLI version: below the + * `redact` version floor (2.1.121), `redact` is declared false and the + * engine's `redact β†’ block` fallback applies. + */ +export function claudeCodeCapabilities(version: string): HarnessCapabilities { + const meetsFloor = compareVersions(version, CLAUDE_CODE_REDACT_MIN_VERSION) >= 0 + return { ...CLAUDE_CODE_CAPABILITIES, redact: meetsFloor } +} + +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0) + const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0) + if (diff !== 0) return diff + } + return 0 +} diff --git a/src/core/normalizer.ts b/src/core/normalizer.ts index b22a928..615819f 100644 --- a/src/core/normalizer.ts +++ b/src/core/normalizer.ts @@ -4,6 +4,7 @@ import type { ToolCallContext } from './types.js' * Well-known tool names that require specific fields in ToolCallContext. * - bash requires `command` * - read/write require `filePath` + * - user-input requires `command` (the prompt text, ADR-010) * * Unknown tool names are allowed through with whatever fields are present. * @@ -11,7 +12,7 @@ import type { ToolCallContext } from './types.js' * `isKnownTool` to check membership so the engine's enforcement behavior * cannot be mutated at runtime. */ -export const KNOWN_TOOLS = ['bash', 'read', 'write'] as const +export const KNOWN_TOOLS = ['bash', 'read', 'write', 'user-input'] as const const KNOWN_TOOLS_SET: ReadonlySet = new Set(KNOWN_TOOLS) @@ -54,6 +55,7 @@ export function isMissingRequiredFields( ): boolean { switch (ctx.toolName) { case 'bash': + case 'user-input': return !command case 'read': case 'write': diff --git a/src/core/types.ts b/src/core/types.ts index fdfdd1a..5f59df6 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -23,7 +23,16 @@ export type BeforeToolAction = */ export type AfterToolAction = { type: 'redact'; replacement: string } -/** Union of all possible guardrail actions across both phases. */ +/** + * Actions available in the `user-input` phase. + * `suggest` and `run` are tool-call concepts and are excluded (ADR-010). + */ +export type UserInputAction = + | { type: 'block'; message: string; fallbackReason?: string } + | { type: 'redact'; replacement: string } + | { type: 'confirm'; message: string; fallback?: BeforeToolAction } + +/** Union of all possible guardrail actions across all phases. */ export type GuardrailAction = BeforeToolAction | AfterToolAction /** @@ -46,6 +55,12 @@ export type ToolCallContext = | { toolName: 'bash'; command: string; filePath?: string } | { toolName: 'read'; filePath: string } | { toolName: 'write'; filePath: string } + /** + * The `user-input` phase context (ADR-010): `command` carries the prompt + * text the user submitted, so bash-command and predicate matchers apply + * to it unchanged. The prompt is never command-split. + */ + | { toolName: 'user-input'; command: string; filePath?: undefined } | { toolName: string; command?: string; filePath?: string } /** A single guardrail rule evaluated in the before-tool phase. */ @@ -68,8 +83,18 @@ export interface AfterToolRule { defaultAction: AfterToolAction } -/** Union of rules from both phases. */ -export type GuardrailRule = BeforeToolRule | AfterToolRule +/** A single guardrail rule evaluated in the user-input phase (ADR-010). */ +export interface UserInputRule { + id: string + title: string + description: string + phase: 'user-input' + match: MatchCondition + defaultAction: UserInputAction +} + +/** Union of rules from all phases. */ +export type GuardrailRule = BeforeToolRule | AfterToolRule | UserInputRule // ── Domain Events ─────────────────────────────────────── @@ -118,4 +143,12 @@ export interface HarnessCapabilities { run: boolean redact: boolean confirm: boolean + /** Whether the harness can rewrite the submitted prompt in the `user-input` phase (ADR-010). Defaults to false. */ + redactUserInput?: boolean + /** Whether the adapter runs outside the process of the agent it governs (ADR-007). A declared fact, not engine behavior. */ + tamperResistant?: boolean + /** Whether the harness can stop the current turn from the before-tool phase (ADR-002). */ + haltTurnBeforeTool?: boolean + /** Whether the harness can stop the current turn from the after-tool phase (ADR-002). */ + haltTurnAfterTool?: boolean } diff --git a/src/core/validator.ts b/src/core/validator.ts index 2ca4aa3..8a4b357 100644 --- a/src/core/validator.ts +++ b/src/core/validator.ts @@ -1,12 +1,13 @@ import type { AfterToolAction, BeforeToolAction, + UserInputAction, MatchCondition, GuardrailRule, RulePack, } from './types.js' -const VALID_PHASES = new Set(['before-tool', 'after-tool']) +const VALID_PHASES = new Set(['user-input', 'before-tool', 'after-tool']) function isObject(v: unknown): v is Record { return v !== null && typeof v === 'object' @@ -55,6 +56,14 @@ function isBeforeToolAction(v: unknown): v is BeforeToolAction { } } +function isUserInputAction(v: unknown): v is UserInputAction { + if (!isObject(v)) return false + // suggest and run are tool-call concepts, excluded from user-input (ADR-010) + if (v.type === 'suggest' || v.type === 'run' || v.type === 'allow') return false + if (v.type === 'redact') return isAfterToolAction(v) + return isBeforeToolAction(v) +} + function isAfterToolAction(v: unknown): v is AfterToolAction { return ( isObject(v) && @@ -103,7 +112,7 @@ function checkRequiredStringFields(input: Record): string[] { function checkPhase(input: Record): string[] { if (typeof input.phase !== 'string' || !VALID_PHASES.has(input.phase)) { - return ['Rule "phase" must be "before-tool" or "after-tool"'] + return ['Rule "phase" must be "user-input", "before-tool", or "after-tool"'] } return [] } @@ -125,6 +134,9 @@ function checkActionForPhase(input: Record): string[] { if (input.phase === 'after-tool' && !isAfterToolAction(input.defaultAction)) { return ['Rule "defaultAction" must be a redact action for after-tool phase'] } + if (input.phase === 'user-input' && !isUserInputAction(input.defaultAction)) { + return ['Rule "defaultAction" must be a redact, block, or confirm action for user-input phase'] + } return [] } diff --git a/src/engine/create-engine.ts b/src/engine/create-engine.ts new file mode 100644 index 0000000..64da30f --- /dev/null +++ b/src/engine/create-engine.ts @@ -0,0 +1,59 @@ +import type { + ToolCallContext, + RulePack, + GuardrailAction, + HarnessCapabilities, + MatchResult, +} from '../core/types.js' +import { PredicateRegistry } from '../core/predicate-registry.js' +import { StatsTracker, type Stats } from './stats-tracker.js' +import { matchAndResolve, processMatch } from './engine.js' + +/** Options for {@link createEngine}. */ +export interface CreateEngineOptions { + /** Pre-built registry, for callers that register predicates before loading packs. */ + registry?: PredicateRegistry + /** Pre-built stats tracker, for callers that share one across engines. */ + stats?: StatsTracker +} + +/** + * The engine instance returned by {@link createEngine} β€” the whole runtime + * integration surface (ADR-003). One `evaluate()` call per event, covering + * all three phases via the context shape. + */ +export interface Engine { + /** Evaluate a tool call, tool result, or user prompt. Returns the resolved action, or null when nothing matched. */ + evaluate(ctx: ToolCallContext): GuardrailAction | null + /** Same evaluation, returning the action plus the domain-event trace (ADR-006). */ + processMatch(ctx: ToolCallContext): MatchResult + /** Snapshot of session counters. */ + getStats(): Stats + /** Zero the session counters. */ + resetStats(): void +} + +/** + * Create a guardrail engine over the given rule packs and harness + * capabilities. This is the public entry point for adapters and embedders: + * + * ```typescript + * const engine = createEngine(loadAllRulePacks("./packs", registry), capabilities, { registry }) + * const action = engine.evaluate(ctx) + * ``` + */ +export function createEngine( + packs: RulePack[], + capabilities: HarnessCapabilities, + options: CreateEngineOptions = {} +): Engine { + const registry = options.registry ?? new PredicateRegistry() + const stats = options.stats ?? new StatsTracker() + + return { + evaluate: (ctx) => matchAndResolve(ctx, packs, capabilities, registry, stats), + processMatch: (ctx) => processMatch(ctx, packs, capabilities, registry, stats), + getStats: () => stats.getStats(), + resetStats: () => stats.resetStats(), + } +} diff --git a/src/index.ts b/src/index.ts index 520faa6..285dd10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,26 @@ +// ── Engine Factory (the public integration surface, ADR-003) ── +export { + /** + * Create a guardrail engine over rule packs and harness capabilities. + * The public entry point for adapters and embedders: one `evaluate()` + * call per event, covering all three phases. + */ + createEngine, +} from './engine/create-engine.js' +export type { Engine, CreateEngineOptions } from './engine/create-engine.js' + +// ── Tier 1 Harness Capabilities (ADR-009) ─────────────── +export { + /** Pi capability constant (in-process plugin; all behaviors native). */ + PI_CAPABILITIES, + /** Claude Code capability constant (external hooks; all behaviors native, redact β‰₯ 2.1.121). */ + CLAUDE_CODE_CAPABILITIES, + /** The Claude Code version floor for the `redact` behavior. */ + CLAUDE_CODE_REDACT_MIN_VERSION, + /** Claude Code capabilities gated on the installed CLI version. */ + claudeCodeCapabilities, +} from './core/harness-capabilities.js' + // ── Core Types ────────────────────────────────────────── export type { HarnessCapabilities } from './core/types.js' export type { @@ -19,6 +42,10 @@ export type { BeforeToolAction, /** Actions available in the after-tool phase. */ AfterToolAction, + /** Actions available in the user-input phase (ADR-010). */ + UserInputAction, + /** A single guardrail rule evaluated in the user-input phase. */ + UserInputRule, /** A named collection of guardrail rules. */ RulePack, /** Emitted when a rule's matcher fires against a tool call. */ @@ -33,11 +60,11 @@ export type { // ── Normalizer ───────────────────────────────────────── export { - /** Check whether a tool name is one of the well-known tools with required fields. */ + /** @internal Check whether a tool name is one of the well-known tools with required fields. */ isKnownTool, - /** Extract command and filePath from a ToolCallContext. */ + /** @internal Extract command and filePath from a ToolCallContext. */ extractTargets, - /** Check whether a ToolCallContext is missing required fields. */ + /** @internal Check whether a ToolCallContext is missing required fields. */ isMissingRequiredFields, } from './core/normalizer.js' @@ -60,37 +87,24 @@ export { getRulePackErrors, } from './core/validator.js' -// ── Engine ────────────────────────────────────────────── +// ── Engine internals ──────────────────────────────────── export type { Stats } from './engine/stats-tracker.js' export { - /** - * The main entry point: evaluate a ToolCallContext against RulePacks and - * return the resolved GuardrailAction (or null if no rule matched). - * - * `registry` and `stats` are explicit collaborators β€” the engine does not - * own their lifecycle. Construct fresh ones (or share between calls) - * however fits the caller's use case. - */ + /** @internal Engine plumbing β€” use `createEngine().evaluate()` instead. */ matchAndResolve, - /** - * Internal engine entry point. Returns both the resolved action and - * the domain events that explain how the decision was reached. - */ + /** @internal Engine plumbing β€” use `createEngine().processMatch()` instead. */ processMatch, } from './engine/engine.js' export { - /** Accumulator for intervention stats (checks, blocks, suggests). */ + /** @internal Accumulator for intervention stats β€” owned by the engine instance. */ StatsTracker, } from './engine/stats-tracker.js' -// ── Matcher ───────────────────────────────────────────── +// ── Matcher internals ─────────────────────────────────── export { - /** - * The single entry point for evaluating a MatchCondition against a - * ToolCallContext. Replaces the old registry/handler split. - */ + /** @internal Matching dispatch β€” production code goes through `createEngine().evaluate()`. */ matchesMatcher, - /** Maximum input length before regex matchers fail-closed. */ + /** @internal Maximum input length before regex matchers fail-closed. */ MAX_MATCH_INPUT_LENGTH, } from './matcher/matchers.js' diff --git a/src/resolver/action-resolver.test.ts b/src/resolver/action-resolver.test.ts index 328c869..280e593 100644 --- a/src/resolver/action-resolver.test.ts +++ b/src/resolver/action-resolver.test.ts @@ -173,7 +173,7 @@ describe('resolveAction', () => { expect(result.type).toBe('suggest') }) - it('falls back to suggest via ctx.replacement when confirm unavailable and no fallback defined', () => { + it('falls back to block when confirm unavailable and no fallback defined, even when suggest is available (ADR-002)', () => { const action: GuardrailAction = { type: 'confirm', message: 'Confirm: {matched}', @@ -189,11 +189,7 @@ describe('resolveAction', () => { matched: 'test', replacement: 'safe-cmd', }) - expect(result).toEqual({ - type: 'suggest', - replacement: 'safe-cmd', - message: 'Confirm: test', - }) + expect(result.type).toBe('block') }) it('falls back to block when confirm capability unavailable, no fallback, and no suggest', () => { @@ -227,7 +223,7 @@ describe('resolveAction', () => { expect(result.type).toBe('block') }) - it('confirm β†’ suggest β†’ block', () => { + it('confirm β†’ block', () => { const action: GuardrailAction = { type: 'confirm', message: 'Confirm?' } const noConfirmNoSuggest: HarnessCapabilities = { block: true, @@ -295,7 +291,7 @@ describe('resolveAction', () => { expect(result.type).toBe('block') if (result.type === 'block') { expect(result.fallbackReason).toBe( - '`confirm` capability is not supported, no `fallback` action was defined, and no upstream `replacement` is available. Falling back to a `block`.' + '`confirm` capability is not supported and no `fallback` action was defined. Falling back to a `block`.' ) } }) diff --git a/src/resolver/action-resolver.ts b/src/resolver/action-resolver.ts index c6003a0..79c9bb6 100644 --- a/src/resolver/action-resolver.ts +++ b/src/resolver/action-resolver.ts @@ -28,9 +28,9 @@ function interpolateMessage(message: string | undefined, ctx: ResolveContext): s * Resolve a GuardrailAction against HarnessCapabilities, walking the fallback chain * when a behavior is not supported. * - * Fallback chains: + * Fallback chains (ADR-002): * - run β†’ suggest β†’ block - * - confirm β†’ suggest β†’ block + * - confirm β†’ block * - redact β†’ block (when unsupported) * - suggest β†’ block (when unsupported) */ @@ -150,15 +150,11 @@ function resolveConfirm( if (action.fallback) { return resolveAction(action.fallback, capabilities, ctx) } - if (capabilities.suggest && ctx.replacement) { - return { - type: 'suggest', - replacement: ctx.replacement, - message: interpolate(action.message, ctx), - } - } + // ADR-002: confirm falls back straight to block, never to suggest β€” a rule + // that asked for a human decision must not degrade into handing the agent + // an actionable replacement. return fallbackBlock( - '`confirm` capability is not supported, no `fallback` action was defined, and no upstream `replacement` is available. Falling back to a `block`.', + '`confirm` capability is not supported and no `fallback` action was defined. Falling back to a `block`.', action.message, ctx )