Skip to content

feat(session): enforce quick≠write binding (invariant 2) - #3

Open
akminx wants to merge 4 commits into
mainfrom
feat/quick-profile-binding
Open

feat(session): enforce quick≠write binding (invariant 2)#3
akminx wants to merge 4 commits into
mainfrom
feat/quick-profile-binding

Conversation

@akminx

@akminx akminx commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Intent

Close Phase 0 + Phase 1 of the invariant-gap plan on Gilly.

Phase 0: prove the review workflow before more unattended work — run the DESIGN.md §9.5 stress tests (known-subtly-wrong canonicalJSON key-sort drop; unsatisfiable ambient cross-session grant task that contradicts invariant 4) and record evidence in .captain/review/loop-log.md. Pass A caught the known-bad; the unsatisfiable task escalated to human rather than expanding scope.

Phase 1: make invariant 2 (quick ≠ write) structural — not full M1 e2e. Oracle-first (separate author): profiles.property.test.ts + quick.test.ts locking grants.md §4.1/§10.7/§10.8. Then implement boundToolsForProfile, defaultQuickGrants, createQuickSession (does not merge live store user grants like foreground), Session.boundTools(), and a dispatch-time binding gate so unbound write tools fail at registration before decide()/approval. Deliberate tradeoffs: full M1 e2e (Pi customTools wiring, context/redaction, extension API, routing.yaml) stays deferred; this PR is the independent quick-binding primitive only. Pass A findings (write tool on quick-default family; binding unused by dispatch) were fixed and finding-owner verified. Risk is trust-critical (session/policy/grants).

What Changed

  • Added a profile-binding primitive for quick sessions: boundToolsForProfile projects a profile to its effect:"read" tool set, defaultQuickGrants seeds the base {screen.read, web.search, web.fetch} triple (plus files.read only when a project root is explicit, never $HOME/CWD), and createQuickSession/Session.boundTools() wire these in without merging live store user grants the way foreground sessions do.
  • Added a dispatch-time binding gate in runtime.ts that blocks write/act tools absent from the session's binding at registration (blockedAt: "registration") before decide()/approval runs, with the gate scoped to known tools so an unknown/hallucinated tool still surfaces decide()'s unknown_tool reason rather than the profile-binding wording.
  • Added oracle tests locking grants.md §4.1/§10.7/§10.8 (profiles.property.test.ts, quick.test.ts) and updated the policy registry/decide.ts plus INVARIANTS.md to back invariant 2 (quick ≠ write).

Risk Assessment

✅ Low: The round-1 audit-precision warning was fixed precisely (unknown tools now surface decide()'s unknown_tool reason across all session kinds) with an added regression test, the quick≠write mechanism-1 gate remains intact, and no new issues were introduced.

Testing

Ran the two targeted oracle test files (18 tests, all pass) that lock invariant 2, then produced a product-level artifact by executing a live quick session against a store seeded with whole-home write + unrestricted shell grants and capturing the persisted audit event-log: write_file and run_command are blocked at registration (never executed, never parked for approval), a hallucinated tool correctly reports "unknown tool" rather than the profile-binding wording, and an in-project read executes — demonstrating the quick-binding gate refuses writes before authorization is consulted. Phase 0 review-stress evidence is confirmed present in the committed loop-log. No product or test failures found; targeted validation only (broad regression remains CI's responsibility). This is a session/policy primitive with no rendered UI surface, so the reviewer-visible evidence is the captured audit-log transcript rather than a screenshot.

Evidence: Live quick-session audit trail (invariant 2 working end-to-end)

=== Invariant 2 (quick ≠ write): live quick-session audit trail === Store seeded with DELIBERATELY over-broad user grants: files.write([.../home]) <- would authorize ANY write under home shell.exec({*}) <- would authorize ANY command Quick session binding table (metadata only, no execute ref): boundTools = [read_file(read)] -> write_file / run_command are ABSENT from the binding table. Model proposes 4 tool calls in one turn (q1 write_file, q2 run_command, q3 read_file in-project, q4 hallucinated_tool): Persisted event-log envelopes (the actual audit surface): BLOCKED q1 write_file: blockedAt="registration" reason="tool write_file is not bound on this session profile" BLOCKED q2 run_command: blockedAt="registration" reason="tool run_command is not bound on this session profile" BLOCKED q4 hallucinated_tool: blockedAt="registration" reason="unknown tool hallucinated_tool" Tools that actually reached the executor this turn: read_file Approval cards requested (write tools parked for human approval?): 0 -> writes blocked at REGISTRATION, never reached decide()/approval.

=== Invariant 2 (quick ≠ write): live quick-session audit trail ===

Store seeded with DELIBERATELY over-broad user grants:
  files.write([/private/var/folders/wy/9cqkw08s29vc46p9mjxzlc000000gn/T/gilly-session-PeDDrL/home])   <- would authorize ANY write under home
  shell.exec({*})            <- would authorize ANY command

Quick session binding table (metadata only, no execute ref):
  boundTools = [read_file(read)]
  -> write_file / run_command are ABSENT from the binding table.

Model proposes 4 tool calls in one turn:
  q1 write_file  (write)  q2 run_command (write)
  q3 read_file inside project (read)  q4 hallucinated_tool (unknown)

Persisted event-log envelopes (the actual audit surface):
  BLOCKED q1 write_file: blockedAt="registration" reason="tool write_file is not bound on this session profile"
  BLOCKED q2 run_command: blockedAt="registration" reason="tool run_command is not bound on this session profile"
  BLOCKED q4 hallucinated_tool: blockedAt="registration" reason="unknown tool hallucinated_tool"

Tools that actually reached the executor this turn:
  read_file
Approval cards requested (write tools parked for human approval?): 0
  -> writes blocked at REGISTRATION, never reached decide()/approval.

Result: quick session cannot write even with whole-home write + shell(*)
grants sitting live in the store. Binding gate (mechanism 1) refuses
unbound write tools before authorization is ever consulted.
Evidence: Phase 0 §9.5 review-stress evidence (committed loop-log rows)

Source: Phase 0 §9.5 review-stress evidence (committed loop-log rows)

| 2026-07-24 | §9.5 stress: known-bad canonicalJSON (drop key sort) | routine→critical blast radius | Pass A semantic adversary | no (caught) | ... DID_PASS_A_CATCH_KNOWN_BAD=yes ...
| 2026-07-24 | §9.5 stress: unsatisfiable ambient cross-session grants | trust-critical | Fixer cycle 1+2 (sim) | ... ESCALATE_TO_HUMAN=yes; IMPLEMENT_ANYWAY=no. Loop escalated rather than expanding scope.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • ⚠️ core/src/session/runtime.ts:252 - The new binding gate runs before decide() and, for foreground/background sessions, this.binding defaults to the entire registry (runtime.ts:125). A genuinely unknown/hallucinated tool is not in registry.list(), so it is now caught here and reported to the model and audit log as reason tool X is not bound on this session profile (blockedAt "registration") instead of decide()'s accurate unknown tool X. blockedat.test.ts only asserts blockedAt, so it still passes, but the reason is now misleading for unknown tools in every session kind — a regression against the AMEND-3a audit-precision intent the codebase explicitly maintains. Fix: when reg === undefined, fall through to decide()'s unknown_tool path (or emit an unknown-tool reason), and only produce the 'not bound on this session profile' reason when reg is defined but absent from binding.
  • ℹ️ core/src/session/profiles.ts:13 - boundToolsForProfile ignores _profile and unconditionally returns effect:"read" tools. The comment promises 'New profiles extend the union and add an explicit case — do not silently bind everything,' but nothing enforces that: a future SessionProfile member would silently receive the read-only projection with no compile-time exhaustiveness error. Safe direction today (single 'quick' profile, read-only is the conservative default), but the guard the comment describes does not exist. Consider an exhaustive switch so a new profile fails to compile until it declares its binding.

🔧 Fix: fix unknown tool mislabeled as profile-unbound in dispatch gate
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • npx vitest run src/session/quick.test.ts src/session/profiles.property.test.ts — 18 passed (the invariant-2 oracle: binding gate blocks unbound write tools at registration, defaultQuickGrants base triple + project-only files.read, no store-grant merge, unknown-tool reason precision)
  • Authored + ran a temporary demonstration test (core/src/session/quick.demo.test.ts, removed after capture) that constructs a real createQuickSession with over-broad store grants and records the persisted tool.call_blocked / executor envelopes to the evidence dir
  • Verified .captain/review/loop-log.md records the two Phase 0 §9.5 stress outcomes (known-bad caught by Pass A; unsatisfiable ambient cross-session grant escalated to human)
  • Confirmed clean working tree (git status --short) after removing the temp demo file
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

akminx and others added 4 commits July 24, 2026 12:56
Oracle-first quick profile: bind only effect:read tools, default §4.1
grants without store merge, and reject unbound tools at dispatch so
mechanism 1 is structural—not metadata-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
The push tier check reads a durable `Risk-Tier:` commit trailer, but the
pull_request check only parsed the PR body. When the PR body is auto-generated
without a machine-parseable tier, a legitimately trust-critical change failed
even though its tier was declared. Give the pull_request path parity: fall back
to the branch-range commit trailer as a declaration source.

Risk-Tier: trust-critical
Relative or whitespace-only roots are not determinable per grants §4.1;
defaultQuickGrants keeps the base triple instead of throwing from
canonicalPath.

Co-authored-by: Cursor <cursoragent@cursor.com>
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