Skip to content

Warden core: deterministic decision layer, scoped grants, audit and approval inbox (W2a) - #402

Merged
oratis merged 29 commits into
mainfrom
feat/warden-core
Oct 3, 2026
Merged

oratis merged 29 commits into
mainfrom
feat/warden-core

Conversation

@oratis

@oratis oratis commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

W2a of docs/PLAN_ALWAYS_ON_UPGRADE_2026-09-30.md: a deterministic layer that decides every tool call on the web surface, with scoped grants, an audit log and an approval inbox.

  • Warden is opt-in. lisa serve --web with no flag behaves exactly as before (auto). --approval warden, or LISA_APPROVAL=warden for an app-launched backend, turns it on. The CLI REPL is unchanged.
  • With Warden on, a side-effecting tool call that the policy will not auto-allow waits for the user's answer instead of being denied. Approve runs it; deny, expiry (10 minutes), a cancelled turn, a shutdown or a restart do not.
  • Nothing is merged, deployed or published by this PR.

Why it stays opt-in

Only the web client renders an approval card, and over HTTP on loopback the server cannot tell the user's browser from a local process. A process the user runs unsandboxed can read an approval and approve it. Until approvals are signed by a native approver (the Mac app with a Keychain-bound key, or a paired phone), Warden protects against a model that has not been given an unconfined shell, not against one that has. That approver, and a real workspace root under launchd and Lisa.app, gate flipping the default.

Design

src/warden/ — no model calls, no I/O in the policy.

File Role
types.ts Categories, data classes, behaviours, origins, grants, inbox events
classify.ts, paths.ts Explicit table: tool + input → category, targets, sandboxed, taint source; symlink-aware paths
preview.ts HMAC payload digest, short redacted preview, full card payload, data-class detection
policy.ts evaluate(req, ctx) — pure
rules.ts, grants.ts, taint.ts, audit.ts, store.ts State under <lisaHome>/warden/, mode 0600
inbox.ts Pending approvals per tenant, full payload in memory, durable mirror, expiry
session.ts createWardenSession — the integration point

Evaluation order: system invariants → a handoff rule → grants (narrowed by what the request is) → forced asks → user rules → default matrix → context floor.

System invariants (not overridable by rules, grants or envelopes)

  • purchase and credential are always handed back to the user.
  • Origin autonomy may only read and write Lisa's own home; everything else is denied and never raises an approval.
  • The cloud surface never allows exec or a host file write.
  • Warden's state directory cannot be written or deleted by a file tool. The check follows symlinks and ignores case.

Default matrix

Category Chat Chat after taint Task / routine / watcher Channel / MCP / remote device
read, self, draft auto auto (see forced asks) auto auto
write inside a sandboxed workspace auto ask preapproved ask
write with no sandbox, or outside the workspace ask ask ask ask
exec, sandboxed auto ask preapproved ask
exec, no sandbox ask ask ask ask
network (non-read) auto ask preapproved ask
send, publish, delete ask ask preapproved ask
purchase, credential handoff handoff handoff handoff

Unsandboxed exec asks for every origin, the local owner included; a user who wants the old behaviour sets tools.bash = auto.

Forced asks

  • Sensitive data to a recipient with no bound grant.
  • In a tainted run: a fetch, ingest, browser-open or MCP call to an address that did not appear verbatim in the conversation.
  • Any read, write or delete of a credential location (~/.ssh, ~/.aws, <lisaHome>/warden, â€Ķ).
  • In a tainted run: a read outside the workspace, and any skill_manage write.
  • A command that names Warden's state, its approval API or its CLI — once, for that exact command.

Grants and the card

  • Grants: once, task, target, 24h, always. Exact match on tool, category, method and trust column. A target grant needs every target covered, and is not offered when the targets cannot be enumerated.
  • Tool-wide grants do not survive taint for side effects. A shell card never offers always or 24h. A user rule stricter than a grant wins.
  • The card shows the whole payload (GET /api/approvals/{id}), in the classifier's order, and approving requires the digest of what was shown.

Full detail, the taint sources and the known limits are in docs/DESIGN_WARDEN.md.

Integration points

createWardenSession(opts: {
  surface: RuntimeSurface; uid: string | null; origin: Origin; taskId?: string;
  sandboxMode: SandboxMode; workspaceRoot: string; inbox: WardenInbox;
  tools?: ToolDefinition[]; envelope?: TaskEnvelope; purpose?: string;
  initialTaint?: boolean; onTaint?: () => void;
  knownUrls?: KnownUrls; userText?: string; home?: string;
  loadRules?: () => Promise<LoadedRules>; loadGrants?: () => Promise<LoadedGrants>;
  approvalTimeoutMs?: number; signal?: AbortSignal;
  dataClassHints?: DataClass[]; log?: (msg: string) => void; now?: () => number;
}): {
  approval: ApprovalCallback;       // pass to runAgent({ approval }) and to toolCtx.approval
  observe(event: AgentEvent): void; // feed from runAgent({ onEvent })
  decide(toolName, toolInput): Promise<{ decision, request, verdict }>;
  readonly tainted: boolean;
}

Inbox emitter: new WardenInbox({ emit: (event, uid) => void }). The host must deliver each event only to subscribers of that uid.

  • approval_requested { type, id, kind: "approval"|"handoff", at, tool, category, targets, preview, purpose?, digest, expiresAt, taskId?, origin, reason, scopes }
  • approval_resolved { type, id, verdict: "approved"|"denied"|"expired"|"dismissed", scope? }

HTTP API (in contracts/lisa-api-v1.openapi.json, additive)

Route Notes
GET /api/approvals Pending items for the caller's tenant, short previews, plus canApprove
GET /api/approvals/{id} One item with its whole payload; approvers only
POST /api/approvals/{id}/approve Body { digest, scope? }; digest is required
POST /api/approvals/{id}/deny Body { reason? }; dismisses a handoff
GET / PUT /api/warden/rules
GET /api/warden/grants, DELETE /api/warden/grants/{id}
GET /api/warden/audit?limit= Newest first, at most 500

State-changing routes require the loopback owner (Mac edition) or a signed-in per-user session, a JSON content type, and a same-origin request.

CLI: lisa approvals [list | show <id> | approve <id> --digest <digest> [--scope â€Ķ] | deny <id>], lisa warden rules|grants|audit.

Review round (findings against 29b21a5)

All thirteen findings and the four lows are addressed; each has a regression test named review N. Summary:

  • A. Opt-in; LISA_APPROVAL accepted.
  • 1. Own-property rule lookups; an unknown behaviour ranks as ask.
  • 2. review_diff no longer passes its target as an option. The same class was fixed in npm_info, speak and the yt-dlp adapter; the audit of the other read/self tools is recorded as tests.
  • 3. Sandbox profiles deny Warden's directory and the server port; unsandboxed exec and writes ask by default.
  • 4. Tainted egress asks unless the exact URL appeared in the conversation.
  • 5. The card shows the whole payload; approving requires its digest.
  • 6. Incomplete targets; target grants need full coverage; guarded exec is once-only.
  • 7. More taint sources; taint persisted per conversation.
  • 8. Grants narrowed under taint and under explicit ask rules.
  • 9. skill_manage asks in a tainted run.
  • 10. MCP tools cannot classify themselves as read.
  • 11. Protected paths resolved through symlinks, compared without case.
  • 12. A target rule loosens only with full coverage.
  • 13. Redaction, key-name-only previews, phone masking, HMAC digest.

Changes outside src/warden/

  • src/web/server.ts: the import, createWebWarden(...), the handleWardenApi hook, the per-turn session, the sandbox port registration, inbox shutdown, and an optional provider option used by the wiring test.
  • src/sandbox/: protect.ts, and the denials in the seatbelt and bwrap profiles.
  • src/tools/review_diff.ts, npm_info.ts, github.ts, src/voice/speak.ts, src/kb/ingest/adapters/ytdlp.ts: option-injection fixes.
  • src/cli.ts, src/cli-args.ts, src/runtime-policy.ts, src/approval.ts: the mode and the subcommands.
  • src/types.ts, src/subagent.ts, src/tools/task.ts: the approval hand-down to subagents.
  • Some of these files also carry Prettier-only changes, because the format gate applies to every touched file.

Tests run

All local gates pass on Node 26: typecheck, typecheck:client, lint (0 errors), format:check, build, check:api-contract, check-md-links, and npm test — 2521 pass, 0 fail, 1 skipped (the existing node-pty test). 183 of those are this PR's.

Not done / known limits

  • No native approver; a process the user runs unsandboxed can approve over loopback (see above).
  • The workspace root is the server's working directory, which is / under launchd and Lisa.app.
  • The string checks (commands naming Warden, shell commands reaching the network) are best effort.
  • On Linux, bubblewrap cannot block one TCP port when the network is allowed.
  • In a tainted run memory, soul_patch, kb_write and kb_add are still auto; the audit log marks them.
  • read, grep and kb_read inside the workspace do not taint.
  • Known URLs are kept in memory and lost on restart.
  • --approval warden in the CLI REPL leaves the task subagent ungated.
  • Only web chat turns are wired; IM channels, heartbeat and idle runs and managed agents are not.
  • No iOS, Island or IM approval UI; a paired device token cannot approve.
  • The inbox waiter is in-process (single instance).
  • A pending approval produces SSE events only; a push for it goes through reachOut() in the integration PR.

ðŸĪ– Generated with Claude Code

oratis and others added 12 commits October 2, 2026 18:57
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķants

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķnts in audit previews

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķpprovals and warden cli

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķlient

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķwarden state

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķize approver trust

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@oratis
oratis marked this pull request as ready for review October 2, 2026 11:39
oratis and others added 17 commits October 2, 2026 19:41
Keeps both the reach-out routes (W3) and the warden routes in server.ts and
in the api contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings in the credential broker and inbound hygiene (W2b, #405); keeps both
the secret and the approvals/warden subcommands in cli.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķpts in (review A)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
review_diff passed its target to git as a raw argv, so --output=<path>
overwrote any file while classified read. Reject a leading dash and add
--end-of-options. Same class fixed in npm_info view, speak (say -o) and the
yt-dlp adapter; github numbers must be positive integers. The audit of every
other read/self tool that spawns a process is recorded as tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķiew 3, part 1)

A sandboxed shell could reach the approval API on loopback and read or write
<lisaHome>/warden. Seatbelt profiles now end with denials for both; bwrap
hides the directory behind a tmpfs. The server registers its bound port.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķs being updated)

Own-property rule lookups and unknown-behaviour strictness (1), target-rule
coverage (12), unsandboxed exec and writes ask by default (3), tainted egress
and credential-path reads (4), full payload held for the card with a required
digest (5), incomplete targets (6), wider taint sources and a persisted taint
store (7), grant narrowing under taint and explicit ask rules (8), tainted
skill writes (9), stricter MCP naming rules (10), symlink- and case-aware
protected paths (11), redaction and HMAC digest (13), bounded regexes and the
inbox abort race (lows). Server wiring, API, client, CLI and tests follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķ 10, 11, 13, lows)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķtaint, target coverage (review 3, 4, 6, 8, 9, 11)

Also holds writes to credential locations to the same bar as reads: a
tool-wide grant on write does not reach ~/.ssh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķrace (review 5, 6, 8, low)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķ, 13, lows)

The session loads the per-home digest key, tracks URLs seen in the
conversation, flags commands that name warden state or its cli, and hands the
full payload to the inbox. The api serves one item with its whole payload to
approvers. The web server passes who the caller is (a caller who cannot
approve gets the remote column), the user text, attachments, and persists
conversation taint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķon taint in memory and on disk

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķdigest (review 5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķs digest (review 5)

The web card fetches GET /api/approvals/{id}, renders every field in the
server's order with long values scrolling, and only then offers the scopes
the server allows. lisa approvals gains show, and approve requires --digest.
The route and the required digest are in the api contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķround

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķat the string guard is not a boundary

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
â€Ķopt-in

The threat model still described taint as lasting one turn and Warden as
planned. Taint now covers the whole conversation and survives a restart,
tainted egress to an address not seen verbatim asks, and Warden, taint and
new-recipient PII ship opt-in behind --approval warden / LISA_APPROVAL.
Only the web client answers approvals today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@oratis
oratis merged commit de8ada1 into main Oct 3, 2026
10 checks passed
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