Skip to content

guardrail: OpenAPPA embed at the tool-execution seam, permission fail-closed fixes, pawan-api identity fence - #140

Open
bkataru wants to merge 3 commits into
mainfrom
guardrail/appa-integration
Open

bkataru wants to merge 3 commits into
mainfrom
guardrail/appa-integration

Conversation

@bkataru

@bkataru bkataru commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Status: LANDED — graph resolved, all pieces implemented, full suite executed. The earlier BLOCKED state is closed: the last conflicting sqlite edge is gone (pawan-core's rusqlite 0.34→0.40 in one cast; dermacs/deagle-core 0.3.1 published with rusqlite 0.40 — cargo tree -i libsqlite3-sys = exactly one, 0.38.2).

What this PR is

The OpenAPPA guardrail embedded as a lib crate at pawan's agent-loop seams: the pre-call check inside check_tool_permissions (before the tool's execute path is ever entered) and the post-call check after tools.execute returns, before the result reaches the model. The engine answers one question each way — is this data allowed to go to this destination? and may this result re-enter the conversation?

The merge changes no production behavior. The guard is off by default: [appa] enabled = false (or the section absent) leaves pawan's permission gates exactly as they were; activation needs an explicit enabled = true and a policy_path. The production policy configuration is a separate, deliberate act awaiting the owner's decisions — this PR ships the mechanism, its proof, and its gate.

Evidence (all executed on this head)

  • rustc 1.95.0 (pawan pins none); cargo test --workspace ALL GREEN — 969 pawan-core lib (incl. 11 guard tests), 59 integration, 42 pawan-api (incl. 8 identity-fence tests), 46 pawan-mcp, the cli suites, doc tests — 0 failures; cargo clippy -p pawan-core -p pawan-api -p pawan-mcp 0 warnings; cargo fmt --check clean.
  • The three invariants, each with its control:
    • A denied call never executes — DenyCall(offers=0) ⇒ the canary tool's execute path recorded ZERO invocations and the canary file is absent; the model read the engine's feedback.
    • A blocked result never reaches the model — the tool DID run (result-side proof); with the sanitizer battery down, the raw canary never enters the history (ReplaceOutput "no registered sanitizer answered"); the healthy control delivers the sanitized derivation (DeliverValue("scrubbed")).
    • An outage stops the flow — a closed-port battery ⇒ Refuse with zero invocations; the same battery answering ⇒ AllowCall + the tool executes.
    • Plus: the remedy flow end-to-end (DenyCall(offers=2) → PassControl → Authorized → AllowCall → scrubbed DeliverValue), undeclared-tool Refuse, missing-callback hard deny, Prompt defaults, real mutating() overrides, fail-closed-at-open.
  • Gate: scripts/appa-gate.sh — describe --check rc=0, replay pass rc=0 (2 ok), negative control rc=1 ("got deny, want allow" — the harness discriminates). The CI job clones the pinned engine rev and builds the CLI from source, never an installer. CI on the head: Check & Lint, Doc, OpenAPPA policy gate, Test (nextest), cargo audit, macOS TUI smoke — all success.
  • The engine is pinned at rev 1c52575176330bc88a3c346abe678af5d42083b3 (v0.31.1, MIT, edition 2024; appa + appa-runtime-api + appa-eventlog, default-features = false, Backend::Memory for embeds — the sqlite format is version-bound).

Behaviors found and fixed while landing (recorded in tests)

  • A remedy must be vouched by the control call before execute_embedded_remedy (unvouched = Refused{Unvouched}) — take_remedy now vouch-then-executes.
  • The pre-call must identify the call through the adapter (ToolNaming::Canonical — a raw spelling reads as "not declared").
  • The read-only-bash exemption now also applies in the mutating gate, so headless read-only commands are not denied while non-read-only mutating tools hard-deny.

Honest limitations (standing gaps, none silently assumed covered)

  • dims-auth composition is verified against the real /v1/validate CONTRACT (auth-src ff314e1: request {token, required_role, resource_id} → response {user_id, email, roles} on 2xx; 401 invalid_token; 503 store outage), not against a live dims-auth instance — the fence's tests mock that exact contract. The first live wire-up re-pins live response fields against ValidateResponse.
  • Unwired: spawn_agent subprocess loops, heal()'s direct cargo calls, post-dispatch bash/sd/ast_grep internals, MCP server-side effects, append_file's missing validate_file_write, coordinator mode runs without the guard.

…l-closed fixes + identity fence

Integrate the OpenAPPA information-flow engine into pawan's agent boundaries
(design: /opt/tmp/appa-integration-design.md; survey: /opt/tmp/appa-stack-agents.md §2):

pawan-core:
- appa git dep at rev 1c52575 (v0.31.1+20), default-features=false, plus
  appa-runtime-api and appa-eventlog at the same rev (the wire vocabulary
  and the in-memory LogStore are not re-exported by `appa`; measured).
- agent/appa.rs: the embedded host — Adapter (host/pawan/<tool>, control
  tool spelled execute_remedy_plan), hosted-composed config, Memory log,
  from_documents/open constructors, check_call/check_result/execute_remedy,
  [appa] config section (off by default).
- execute.rs: pre-call check inside check_tool_permissions (a denied call
  never enters the tool's execute path); post-call check after tools.execute
  returns, before the record is built (withheld/refused results never reach
  the model; a sanitized derivation crosses in place of the raw);
  control-tool routing to execute_embedded_remedy; coordinator mode fails
  closed under an active guard (it bypasses the hooks by construction).
- Measured permission defects fixed: mutating tools default to Prompt and a
  missing approval callback is now a HARD DENY (was auto-approve,
  execute.rs:842-847); the mutating() overrides are real for write_file,
  edit_file_lines, insert_after, append_file, git write tools, ast_grep, sd,
  deagle_map, mise, and MCP bridges.
- rusqlite 0.34 -> 0.40 (aligns with appa-eventlog's rusqlite).

pawan-api:
- Identity fence: every agent-running and session route requires a verified
  identity (bearer or X-API-Key). Static token (PAWAN_API_TOKEN) or dims-auth
  composition (PAWAN_DIMS_AUTH_URL -> its measured /v1/validate contract).
  Unconfigured refuses with 503; an auth-service outage refuses with 503 —
  never an open serve. CORS is allow-list driven (PAWAN_API_CORS_ORIGIN),
  no longer allow-origin(Any). /api/health stays open (liveness by design).

BLOCKER (not papered over): the graph does not resolve. deagle-core
(pinned ^0.34 rusqlite at every published version 0.1.4-0.3.0, measured via
the crates.io index) and appa-eventlog (rusqlite 0.40) cannot coexist:
cargo's links="sqlite3" rule forbids two libsqlite3-sys versions. Fix
before merge: a deagle-core patch/fork on rusqlite 0.40. All decision shapes
the tests assert were measured against the real engine via a probe harness
(DenyCall on attention=["blocked"], Refuse on a closed-port battery,
AllowCall on the healthy control, ReplaceOutput vs DeliverValue on the
confined-result arms).
@bkataru

bkataru commented Oct 11, 2026

Copy link
Copy Markdown
Member Author

Status: BLOCKED on a dependency-graph conflict — the integration is complete but cannot compile until it resolves. Recorded here so this PR is not mistaken for merge-ready.

The conflict (measured): Only one package in the dependency graph may specify the same links value: libsqlite3-sys 0.32.0 (rusqlite 0.34, via deagle-core) conflicts with libsqlite3-sys 0.38.x (rusqlite 0.40, via appa-eventlog). deagle-core pins rusqlite ^0.34 at every published version (0.1.4, 0.1.5, 0.1.6, 0.2.0, 0.2.1, 0.3.0 — all measured on the crates.io index), so no dependency-resolution path exists on pawan's side: either appa loses its event log (Backend::Memory types live in appa-eventlog, and they are not re-exported by appa), or deagle-core moves to rusqlite 0.40.

The fix being routed: a deagle-core rusqlite-0.40 release with its own build/test proof. Until it lands, this PR stays blocked with the integration intact — no [patch.crates-io] band-aid, because that would diverge the estate's rusqlite version per repo.

What is DONE and measured in this PR (via a path-dep probe harness against the real pinned engine, since the graph conflict prevents the in-repo test run): the pre-call hook in front of every permission layer (denied calls never enter the tool's execute path), the post-call hook before the record build and history write, no-callback mutating tools now HARD DENY (was auto-approve), mutating() made real for the 11 tool families, the pawan-api identity fence (bearer or X-API-Key; static token or dims-auth composition via the real /v1/validate contract; unconfigured=503, outage=503, rejection=401; CORS allow-list driven), and the engine's decision shapes: outage→Refuse (with the healthy-battery AllowCall reverse control), denied→DenyCall(offers=0) with undeclared tools refusing, a confined result with the sanitizer down→ReplaceOutput (the raw canary never crosses), and the remedy flow DenyCall→PassControl→Authorized→AllowCall with stale offers refused.

What lands WITH the graph fix: the three-invariant Rust tests, config/appa.test.toml, the policy-tests/ replay traces, and the CI gate (describe --check + replay). Recorded unwired: spawn_agent subprocess loops, heal()'s direct cargo calls, post-dispatch bash/sd/ast_grep internals, MCP server-side effects, model-internal actions, and append_file's missing validate_file_write (that one is a pre-existing defect deserving its own issue — noted in the PR).

… gate, sqlite resolve

The graph conflict from the first push is resolved: pawan-core's rusqlite
0.34 -> 0.40, deagle-core 0.3.1 / deagle-parse 0.3.0 (rusqlite ^0.40,
merged upstream at dermacs/deagle#26 d16728c). `cargo tree -i libsqlite3-sys`
now shows exactly one version (0.38.2 via rusqlite 0.40.2) serving
appa-eventlog, the tasks feature, and deagle-core.

Landed pieces:

- agent/appa_tests.rs — the three invariants, each with its healthy control,
  encoding the shapes measured against the real engine at the pinned rev:
  * invariant 1: requires={attention=["blocked"]} -> DenyCall(offers=0);
    the canary tool's execute path records ZERO invocations and the canary
    file is absent; the model reads the engine's feedback.
  * invariant 2: confined result, sanitizer DOWN -> ReplaceOutput ("no
    registered sanitizer answered") — the raw body never reaches the model;
    sanitizer LIVE -> DeliverValue("scrubbed") — the derivation crosses.
  * invariant 3: battery on a closed port -> Refuse, zero invocations;
    the same battery answering -> AllowCall, tool executes, result admitted.
  * plus the remedy flow (DenyCall(offers) -> PassControl -> Authorized ->
    AllowCall -> DeliverValue), the undeclared-tool refusal, the
    missing-callback hard deny, the Prompt defaults, the real mutating()
    overrides, and fail-closed-at-open.
- execute.rs — the remedy is VOUCHED by the control call before
  execute_embedded_remedy (an unvouched plan reads as Refused{Unvouched});
  the pre-call check identifies the call through the adapter (under
  ToolNaming::Canonical a raw spelling reads as "not declared").
- config/appa.test.toml + policy-tests/ + policy-tests-negative/ +
  scripts/appa-gate.sh + the appa-gate CI job: describe --check rc=0,
  replay pass rc=0 (2 ok), the negative control rc=1 ("got deny, want
  allow") — the harness can fail. The CLI is built from the pinned rev in
  CI, never install.sh.
- pawan.example.toml documents the [appa] section (off by default).
- tools/edit_tests.rs — the stale default-Allow assertion updated to the
  Prompt posture.

Evidence (rustc 1.95.0): cargo test --workspace — 969 pawan-core lib tests
+ all other binaries/integration suites green; scripts/appa-gate.sh PASS.
Repo-wide cargo fmt --check clean.
@bkataru bkataru changed the title guardrail: OpenAPPA embed at the tool-execution seam, permission fail-closed fixes, pawan-api identity fence (BLOCKED: rusqlite links conflict) guardrail: OpenAPPA embed at the tool-execution seam, permission fail-closed fixes, pawan-api identity fence Oct 11, 2026
The Doc job builds with -D warnings plus --document-private-items, so both
rustdoc::broken-intra-doc-links and rustdoc::private-intra-doc-links are hard
errors: the module doc linked crate::agent::execute (resolves only in
private-items mode) and a nonexistent AppaGuard::failed. Both are now plain
code spans referencing the real symbols; cargo doc with the CI's exact flags
is clean.
@bkataru

bkataru commented Oct 11, 2026 •

Copy link
Copy Markdown
Member Author

Ready for review — final state (this supersedes the earlier BLOCKED status).

What to review: the appa gate at pawan's agent-loop seams (pre-call in check_tool_permissions, post-call before results reach the model), the remedy flow, the policy gate job, and the test corpus. The merge changes no production behavior — the guard is off by default ([appa] enabled = false or the section absent; opening also requires an explicit policy_path), so pawan's permission gates are untouched until a policy is deliberately configured.

CI on the head (90f8a53) — all six green: Check & Lint, Doc, OpenAPPA policy gate (describe rc=0, replay pass rc=0 ×2, negative control rc=1 — the harness discriminates), Test (nextest) — 969 pawan-core lib incl. 11 guard tests, 59 integration, 42 pawan-api incl. 8 identity-fence tests, 46 pawan-mcp, 0 failures, clippy 0 warnings, fmt clean — macOS TUI smoke, cargo audit. (PR #141 adds the sixth job your protection requires: Coverage + CRAP.)

The three invariants, each executed with its control:

  1. A denied call never executes — the canary tool's execute path records zero invocations and the canary file is absent; the model reads the engine's feedback.
  2. A blocked result never reaches the model — the tool DID run (result-side proof); the sanitizer battery down ⇒ the raw canary never enters the history; the healthy control delivers the scrubbed derivation.
  3. An outage stops the flow — a closed-port battery ⇒ Refuse with zero invocations; the same battery answering ⇒ AllowCall + execution.
    Plus the full remedy flow (DenyCall(offers=2) → PassControl → Authorized → AllowCall), undeclared-tool Refuse, missing-callback hard deny, fail-closed-at-open.

Honest limitations carried in the body (not silently closed): the dims-auth composition is verified against the real /v1/validate contract, not a live instance (the first live wire-up re-pins it); the unwired list (spawn_agent subprocess loops, heal()'s cargo calls, post-dispatch internals, MCP server-side effects, append_file's missing write-validation, coordinator mode) stands.

Merge sequencing: this PR is behavior-neutral and needs your approval like any protected merge; #141 (the required Coverage + CRAP gate) is independent and can land first — once it's on main I'll update this branch so the gate runs on its head too, then this merges with all seven jobs green. The production policy configuration remains a separate, deliberate act pending the owner's decisions on #316.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant