From c89cbf11283181cbc57d1fd6a3dea4f628dca52a Mon Sep 17 00:00:00 2001 From: Akash Aedavelli Date: Fri, 24 Jul 2026 12:56:44 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat(session):=20enforce=20quick=E2=89=A0wr?= =?UTF-8?q?ite=20binding=20(invariant=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .captain/review/loop-log.md | 2 + INVARIANTS.md | 2 +- core/src/policy/decide.ts | 2 +- core/src/policy/registry.ts | 6 + core/src/session/index.ts | 3 + core/src/session/profiles.property.test.ts | 172 +++++++++++ core/src/session/profiles.ts | 16 ++ core/src/session/quick-grants.ts | 38 +++ core/src/session/quick.test.ts | 316 +++++++++++++++++++++ core/src/session/runtime.ts | 53 +++- 10 files changed, 606 insertions(+), 4 deletions(-) create mode 100644 core/src/session/profiles.property.test.ts create mode 100644 core/src/session/profiles.ts create mode 100644 core/src/session/quick-grants.ts create mode 100644 core/src/session/quick.test.ts diff --git a/.captain/review/loop-log.md b/.captain/review/loop-log.md index 9d68fd8..8b9fe76 100644 --- a/.captain/review/loop-log.md +++ b/.captain/review/loop-log.md @@ -8,3 +8,5 @@ incident or completed high-risk unit. |---|---|---|---|---|---|---| | | | | | | | | Also see specs/reviews/loop-log.md for historical review notes. +| 2026-07-24 | §9.5 stress: known-bad canonicalJSON (drop key sort) | routine→critical blast radius | Pass A semantic adversary | no (caught) | n/a (fixture, not landed) | DID_PASS_A_CATCH_KNOWN_BAD=yes; would break sha256Canonical order-independence + event-log idempotencyKey / argsHash; existing canonical/hash oracles would fail. No production change applied. | +| 2026-07-24 | §9.5 stress: unsatisfiable ambient cross-session grants | trust-critical | Fixer cycle 1+2 (sim) | n/a | n/a | Task contradicted invariant 4 + §4.2 two-clamp sites. ESCALATE_TO_HUMAN=yes; IMPLEMENT_ANYWAY=no. Loop escalated rather than expanding scope. | diff --git a/INVARIANTS.md b/INVARIANTS.md index 34c1ea5..92bd05e 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -11,7 +11,7 @@ guard can fail. | # | Invariant | Guard | Status | |---|---|---|---| | 1 | **proposal ≠ execution** — a model-emitted tool call never runs without passing the policy dispatcher | arch-lint: `executor-stays-in-policy`, `gate-executes-in-dispatcher-only`, `gate-factory-minted-in-composition-only`; `dispatch.property.test.ts`; decide §10.9 | **enforced** | -| 2 | **quick ≠ write** — the quick profile has no write/act tools bound, structurally | property test over profile→tool bindings | **pending** (needs profile binding table) | +| 2 | **quick ≠ write** — the quick profile has no write/act tools bound, structurally | `profiles.property.test.ts` (§10.7); `quick.test.ts` (§4.1/§10.8); `boundToolsForProfile` + `createQuickSession` | **enforced** | | 3 | **child grants ⊆ parent grants** — delegation never escalates privilege | `clamp.test.ts` properties; arch `clamp-two-call-sites` | **enforced** | | 4 | **grant ≠ ambient authority** — background sessions load only their persisted grant, never the user's live session grants | `session/background.test.ts` | **enforced** | | 5 | **memory candidate ≠ memory fact** — nothing durable without review status | DB CHECK on `status`; unreviewed rows never reach prompt assembly | **pending** (memory schema not landed) | diff --git a/core/src/policy/decide.ts b/core/src/policy/decide.ts index ab8b0db..5cb62fe 100644 --- a/core/src/policy/decide.ts +++ b/core/src/policy/decide.ts @@ -19,7 +19,7 @@ import type { ToolMeta, ToolRegistry } from "./registry.js"; import type { HardExclusions, ScopeSet } from "./scopes.js"; import { hardExcluded, hardExclusions, pathInScopes } from "./scopes.js"; -export type SessionKind = "foreground" | "background"; +export type SessionKind = "foreground" | "background" | "quick"; export type PolicySession = { kind: SessionKind; diff --git a/core/src/policy/registry.ts b/core/src/policy/registry.ts index 5d6404c..bf9937e 100644 --- a/core/src/policy/registry.ts +++ b/core/src/policy/registry.ts @@ -39,6 +39,12 @@ export class ToolRegistry { return this.meta.get(name); } + // Metadata-only enumeration for profile binding projections (grants §4.1). + // Never exposes execute references. + list(): readonly ToolMeta[] { + return [...this.meta.values()]; + } + // §7.1 step 6's execution seam — a static so it can reach the private // executor map without exposing a public per-tool lookup on instances // (any registry holder could otherwise execute with zero checks; the diff --git a/core/src/session/index.ts b/core/src/session/index.ts index 7a7f403..b3e3cde 100644 --- a/core/src/session/index.ts +++ b/core/src/session/index.ts @@ -21,11 +21,14 @@ export { type TurnResult, type Unsubscribe, } from "./piloop.js"; +export { boundToolsForProfile, type SessionProfile } from "./profiles.js"; +export { defaultQuickGrants, type QuickGrantOpts } from "./quick-grants.js"; export { type ApprovalDecision, type BudgetConfig, createBackgroundSession, createForegroundSession, + createQuickSession, type PendingApproval, Session, type SessionOptions, diff --git a/core/src/session/profiles.property.test.ts b/core/src/session/profiles.property.test.ts new file mode 100644 index 0000000..9b82ca4 --- /dev/null +++ b/core/src/session/profiles.property.test.ts @@ -0,0 +1,172 @@ +// Invariant 2 (quick ≠ write), grants.md §4.1 mechanism 1 + §10.7 — the +// TOOL-BINDING half of the redundant pair (mechanism 2, default grants, +// lives in quick-grants.ts / quick.test.ts). Property: for ANY registry +// built from a random mix of read/write tool registrations, +// boundToolsForProfile(registry, "quick") contains exactly the effect:"read" +// tools and NOTHING else — metadata only, no execute reference reachable. +// §10.7: a quick session constructed with a deliberately over-broad +// GrantSet still has zero effect:"write" tools in its binding table — the +// binding table is a property of the REGISTRY, not the grants. +// +// boundToolsForProfile and createQuickSession do not exist yet (oracle- +// first, CLAUDE.md): this file is expected to fail to resolve until +// core/src/session/profiles.ts and the createQuickSession binding are +// implemented. +import { afterAll, describe, expect, it } from "vitest"; +import type { CapabilityFamily, ToolMeta, ToolRegistration } from "../policy/index.js"; +import { ToolRegistry } from "../policy/index.js"; +import { makeSessionFixture } from "./fixture.js"; +// createQuickSession / boundToolsForProfile are the implementer surface +// under test — neither exists yet (oracle-first, CLAUDE.md). +import { createQuickSession } from "./index.js"; +import { boundToolsForProfile } from "./profiles.js"; + +const fx = makeSessionFixture(); +const writer = fx.newWriter(); +afterAll(() => writer.close()); + +// Deterministic PRNG (mulberry32) — matches dispatch.property.test.ts style. +function rng(seed: number): () => number { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Param-free capability families (kind "none" — no ParamBinding required, +// see tools.ts validateRegistration) so a random registry can register +// tools with zero fixture plumbing. Split read vs write per grants.md §5.1. +const READ_FAMILIES: CapabilityFamily[] = ["screen.read", "web.search", "web.fetch"]; +const WRITE_FAMILIES: CapabilityFamily[] = ["mail.send", "schedule.create", "memory.write"]; + +function pick(r: () => number, xs: readonly T[]): T { + const v = xs[Math.floor(r() * xs.length)]; + if (v === undefined) throw new Error("pick from empty array"); + return v; +} + +// A fresh registry per call — some read tools, some write tools, counts +// randomized but never zero for either kind (so the property is exercised +// on both sides every iteration, not vacuously). +function randomRegistry(r: () => number): { registry: ToolRegistry; readNames: string[] } { + const registry = new ToolRegistry(); + const readNames: string[] = []; + const readCount = 1 + Math.floor(r() * 4); + const writeCount = 1 + Math.floor(r() * 4); + for (let i = 0; i < readCount; i += 1) { + const name = `read_tool_${i}`; + const reg: ToolRegistration = { + name, + origin: "kernel", + effect: "read", + requiredCapability: pick(r, READ_FAMILIES), + paramBinding: null, + execute: async () => "ok", + }; + registry.register(reg); + readNames.push(name); + } + for (let i = 0; i < writeCount; i += 1) { + const reg: ToolRegistration = { + name: `write_tool_${i}`, + origin: "kernel", + effect: "write", + requiredCapability: pick(r, WRITE_FAMILIES), + paramBinding: null, + execute: async () => "ok", + }; + registry.register(reg); + } + return { registry, readNames }; +} + +describe("boundToolsForProfile (§4.1 mechanism 1: tool binding)", () => { + it("quick profile binds exactly the effect:read tools, for any random read/write mix", () => { + for (let iter = 0; iter < 40; iter += 1) { + const r = rng(2000 + iter); + const { registry, readNames } = randomRegistry(r); + const bound = boundToolsForProfile(registry, "quick"); + + // Every bound tool is effect:"read" — no write tool object at all. + for (const meta of bound) { + expect(meta.effect, `${meta.name} leaked into the quick binding table`).toBe("read"); + } + expect(bound.some((m) => m.effect === "write")).toBe(false); + + // ALL registered read tools are bound (not a strict subset) — + // "only tools with registered effect: read are bound" (§4.1). + expect([...bound.map((m) => m.name)].sort()).toEqual([...readNames].sort()); + } + }); + + it("metadata only — no execute reference reachable on a bound tool", () => { + const r = rng(1); + const { registry } = randomRegistry(r); + const bound = boundToolsForProfile(registry, "quick"); + expect(bound.length).toBeGreaterThan(0); + for (const meta of bound) { + expect((meta as unknown as { execute?: unknown }).execute).toBeUndefined(); + } + }); + + it("an all-write registry binds nothing for the quick profile", () => { + const registry = new ToolRegistry(); + registry.register({ + name: "only_write", + origin: "kernel", + effect: "write", + requiredCapability: "mail.send", + paramBinding: null, + execute: async () => "ok", + }); + expect(boundToolsForProfile(registry, "quick")).toHaveLength(0); + }); + + it("unit: matches fx.registry's single read tool (read_file) exactly", () => { + const bound = boundToolsForProfile(fx.registry, "quick"); + expect(bound.map((m: ToolMeta) => m.name)).toEqual(["read_file"]); + expect(bound[0]?.effect).toBe("read"); + }); +}); + +describe("§10.7: quick session binding table has zero write tools under an over-broad GrantSet", () => { + it("createQuickSession's boundTools() carries no write-effect tool even with over-broad store grants", () => { + // Deliberately over-broad, unrelated to the tool-binding mechanism: + // whole-home write access plus unrestricted shell. If mechanism 2 + // (default grants) were the only thing stopping writes, this session + // would still be safe by construction — but mechanism 1 must ALSO hold + // independent of what the store contains (grants.md §4.1: "two + // independent mechanisms, deliberately redundant"). + fx.store.addGrant(fx.grant("files.write", [fx.home])); + fx.store.addGrant(fx.grant("shell.exec", new Set(["*"]))); + + const loop = fx.newLoop("profiles-overbroad"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + + const bound = session.boundTools(); + expect(bound.length).toBeGreaterThan(0); + expect(bound.every((m: ToolMeta) => m.effect === "read")).toBe(true); + expect(bound.some((m: ToolMeta) => m.effect === "write")).toBe(false); + + // The binding table is exactly what boundToolsForProfile computes from + // the same registry — a single mechanism, not two independent guesses. + const expected = boundToolsForProfile(fx.registry, "quick"); + expect([...bound.map((m: ToolMeta) => m.name)].sort()).toEqual( + [...expected.map((m) => m.name)].sort(), + ); + }); +}); diff --git a/core/src/session/profiles.ts b/core/src/session/profiles.ts new file mode 100644 index 0000000..0f74c62 --- /dev/null +++ b/core/src/session/profiles.ts @@ -0,0 +1,16 @@ +// Profile → tool-binding projection (grants.md §4.1 mechanism 1). Returns +// ToolMeta only — never an execute reference (invariant 1). The quick +// profile binds exactly the registered effect:"read" tools; write/act +// tools are absent from the binding table regardless of GrantSet. +import type { ToolMeta, ToolRegistry } from "../policy/index.js"; + +export type SessionProfile = "quick"; + +export function boundToolsForProfile( + registry: ToolRegistry, + _profile: SessionProfile, +): readonly ToolMeta[] { + // Sole profile today is "quick" (§4.1). New profiles extend the union + // and add an explicit case — do not silently bind everything. + return registry.list().filter((m) => m.effect === "read"); +} diff --git a/core/src/session/quick-grants.ts b/core/src/session/quick-grants.ts new file mode 100644 index 0000000..71a2ba2 --- /dev/null +++ b/core/src/session/quick-grants.ts @@ -0,0 +1,38 @@ +// Default GrantSet for quick sessions — grants.md §4.1 mechanism 2. +// Always {screen.read, web.search, web.fetch}; files.read() only when +// a project root is explicit/determinable at creation. Never defaults to +// $HOME or the sidecar CWD (deep-review B1). + +import { canonicalPath } from "../lib/index.js"; +import type { Grant } from "../policy/index.js"; + +export type QuickGrantOpts = { + projectRoot?: string | null | undefined; + home: string; + clock?: (() => number) | undefined; +}; + +function grant(family: Grant["capability"]["family"], params: unknown, at: string): Grant { + return { + capability: { family, params } as Grant["capability"], + source: "user", + principal: "user", + grantedAt: at, + expiresAt: null, + }; +} + +export function defaultQuickGrants(opts: QuickGrantOpts): Grant[] { + const at = new Date(opts.clock?.() ?? Date.now()).toISOString(); + const grants: Grant[] = [ + grant("screen.read", null, at), + grant("web.search", null, at), + grant("web.fetch", null, at), + ]; + const root = opts.projectRoot; + if (typeof root === "string" && root.length > 0) { + // Never fall back to opts.home or process.cwd() (deep-review B1). + grants.push(grant("files.read", [canonicalPath(root)], at)); + } + return grants; +} diff --git a/core/src/session/quick.test.ts b/core/src/session/quick.test.ts new file mode 100644 index 0000000..59b2460 --- /dev/null +++ b/core/src/session/quick.test.ts @@ -0,0 +1,316 @@ +// Quick profile integration — grants.md §4.1 (invariant 2: quick ≠ write), +// §10.7/§10.8. Exercises BOTH redundant mechanisms end-to-end over the real +// dispatch path (fixture registry: read_file/write_file/run_command), plus +// the default-GrantSet construction rules in isolation. +// +// createQuickSession and defaultQuickGrants do not exist yet (oracle-first, +// CLAUDE.md): every test in this file is expected to fail — either at +// module resolution (import of a file that doesn't exist) or, once the +// binding exists, at the assertions below, until the implementation +// actually satisfies §4.1. +import { afterAll, describe, expect, it } from "vitest"; +import { canonicalPath } from "../lib/index.js"; +import { makeSessionFixture } from "./fixture.js"; +// createQuickSession / defaultQuickGrants are the implementer surface under +// test — neither exists yet (oracle-first, CLAUDE.md). +import { createQuickSession } from "./index.js"; +import { defaultQuickGrants } from "./quick-grants.js"; + +const fx = makeSessionFixture(); +const writer = fx.newWriter(); +afterAll(() => writer.close()); + +function blockedFor(taskId: string, toolCallId: string) { + return writer + .envelopes(taskId) + .find((e) => e.type === "tool.call_blocked" && e.data.toolCallId === toolCallId); +} + +describe("createQuickSession (a): write tools never execute, even proposed directly and over-broad grants exist", () => { + it("write_file and run_command block at binding (registration) — mechanism 1, independent of store grants", async () => { + // A live, over-broad store grant for BOTH write families the fixture + // registry exercises. Mechanism 2 alone would still capability_absent + // them (quick never loads store grants); mechanism 1 must ALSO refuse + // them as unbound before decide() (grants.md §4.1 redundant pair). + fx.store.addGrant(fx.grant("files.write", [fx.home])); + fx.store.addGrant(fx.grant("shell.exec", new Set(["*"]))); + + const loop = fx.newLoop("quick-no-write"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + + const executedBefore = fx.executed.length; + loop.queueTurn({ + calls: [ + { toolCallId: "q1", tool: "write_file", args: { path: `${fx.proj}/src/a.ts` } }, + { toolCallId: "q2", tool: "run_command", args: { command: "git status" } }, + ], + }); + const result = await session.runTurn("propose writes despite quick profile"); + expect(result.checkpointSafe).toBe(true); + + // Neither write-effect call ever reached the executor. + expect(fx.executed.slice(executedBefore)).toHaveLength(0); + + // Blocked at the binding gate — registration — never needs_approval. + const b1 = blockedFor(taskId, "q1"); + const b2 = blockedFor(taskId, "q2"); + expect(b1?.data.blockedAt).toBe("registration"); + expect(b2?.data.blockedAt).toBe("registration"); + const events = writer.envelopes(taskId); + expect(events.some((e) => e.type === "approval.requested")).toBe(false); + expect(session.pendingApprovals()).toHaveLength(0); + + // Mechanism 1 redundancy (§10.7): the session's own binding table + // carries no write tool at all, independent of the grant check above. + expect(session.boundTools().every((m) => m.effect === "read")).toBe(true); + }); + + it("write-effect tool keyed to a quick-default family still blocks unbound (Pass A regression)", async () => { + // Finding: effect is independent of requiredCapability — a write tool + // declaring web.search would otherwise pass decide() step 3 on quick's + // always-granted base triple and park for approval. Mechanism 1 must + // refuse it as unbound regardless. + const registry = fx.registry; + registry.register({ + name: "sneaky_write_search", + origin: "kernel", + effect: "write", + requiredCapability: "web.search", + paramBinding: null, + execute: async () => "should-never-run", + }); + + const loop = fx.newLoop("quick-sneaky-write"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + expect(session.boundTools().some((m) => m.name === "sneaky_write_search")).toBe(false); + + const executedBefore = fx.executed.length; + loop.queueTurn({ + calls: [{ toolCallId: "sw1", tool: "sneaky_write_search", args: {} }], + }); + await session.runTurn("propose write-effect under a quick-default family"); + expect(fx.executed.slice(executedBefore)).toHaveLength(0); + expect(blockedFor(taskId, "sw1")?.data.blockedAt).toBe("registration"); + expect(session.pendingApprovals()).toHaveLength(0); + }); +}); + +describe("defaultQuickGrants (§4.1 mechanism 2)", () => { + const FIXED_NOW = Date.parse("2026-07-20T00:00:00Z"); + const fixedClock = () => FIXED_NOW; + + it("no determinable project root ⇒ files.read is simply absent (never $HOME, never cwd)", () => { + const grants = defaultQuickGrants({ projectRoot: null, home: fx.home, clock: fixedClock }); + const families = grants.map((g) => g.capability.family); + expect(families.sort()).toEqual(["screen.read", "web.fetch", "web.search"]); + expect(families).not.toContain("files.read"); + }); + + it("projectRoot omitted entirely behaves the same as projectRoot: null", () => { + const grants = defaultQuickGrants({ home: fx.home, clock: fixedClock }); + expect(grants.map((g) => g.capability.family)).not.toContain("files.read"); + }); + + it("a determinable project root yields files.read(projectRoot) exactly — never home, never cwd", () => { + const grants = defaultQuickGrants({ projectRoot: fx.proj, home: fx.home, clock: fixedClock }); + const filesRead = grants.find((g) => g.capability.family === "files.read"); + expect(filesRead).toBeDefined(); + expect(filesRead?.capability.params).toEqual([fx.proj]); + // Never silently widens to $HOME... + expect(filesRead?.capability.params).not.toEqual([canonicalPath(fx.home)]); + // ...and never silently widens to the sidecar's own cwd (deep-review B1). + expect(filesRead?.capability.params).not.toEqual([canonicalPath(process.cwd())]); + + const families = grants.map((g) => g.capability.family).sort(); + expect(families).toEqual(["files.read", "screen.read", "web.fetch", "web.search"]); + }); + + it("always carries the base triple {screen.read, web.search, web.fetch} regardless of projectRoot", () => { + for (const projectRoot of [null, fx.proj] as const) { + const grants = defaultQuickGrants({ projectRoot, home: fx.home, clock: fixedClock }); + const families = new Set(grants.map((g) => g.capability.family)); + expect(families.has("screen.read")).toBe(true); + expect(families.has("web.search")).toBe(true); + expect(families.has("web.fetch")).toBe(true); + } + }); + + it("every grant belongs to the user principal and is a well-formed ISO grantedAt", () => { + const grants = defaultQuickGrants({ projectRoot: fx.proj, home: fx.home, clock: fixedClock }); + for (const g of grants) { + expect(g.principal).toBe("user"); + expect(Number.isNaN(Date.parse(g.grantedAt))).toBe(false); + } + }); +}); + +describe("createQuickSession (b): default grants govern actual read access", () => { + it("without a project root, read_file blocks softly at capability_absent (no files.read at all)", async () => { + const loop = fx.newLoop("quick-no-root"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + // projectRoot intentionally omitted — not determinable at creation. + }); + loop.queueTurn({ + calls: [{ toolCallId: "nr1", tool: "read_file", args: { path: `${fx.proj}/src/a.ts` } }], + }); + await session.runTurn("read with no determinable root"); + + expect(fx.executed.some((e) => e.tool === "read_file")).toBe(false); + expect(blockedFor(taskId, "nr1")?.data.blockedAt).toBe("capability"); + }); + + it("with a project root, read_file inside the project executes", async () => { + const loop = fx.newLoop("quick-with-root"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + loop.queueTurn({ + calls: [{ toolCallId: "wr1", tool: "read_file", args: { path: `${fx.proj}/src/a.ts` } }], + }); + await session.runTurn("read inside the project root"); + + expect( + fx.executed.some( + (e) => + e.tool === "read_file" && (e.args as { path: string }).path === `${fx.proj}/src/a.ts`, + ), + ).toBe(true); + expect(blockedFor(taskId, "wr1")).toBeUndefined(); + }); + + it("ignores a broader files.read grant sitting in the live store (never merges store user grants like foreground)", async () => { + // If createQuickSession merged the store's user-principal grants the + // way createForegroundSession does, this whole-home grant would let + // the read below succeed. + fx.store.addGrant(fx.grant("files.read", [fx.home])); + + const loop = fx.newLoop("quick-ignores-store"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + const executedBefore = fx.executed.length; + loop.queueTurn({ + calls: [{ toolCallId: "s1", tool: "read_file", args: { path: `${fx.home}/outside.txt` } }], + }); + await session.runTurn("read outside the project despite a broader store grant"); + + expect(fx.executed.slice(executedBefore)).toHaveLength(0); + // Blocked at params (in-scope family present, path outside it) — NOT + // capability_absent — proving the session's own files.read(project) + // grant is live, it just doesn't cover this path; the store's broader + // grant for the same family was never consulted. + expect(blockedFor(taskId, "s1")?.data.blockedAt).toBe("params"); + }); +}); + +describe("createQuickSession (c): reads outside the project root are param-checked, not exempt", () => { + it("§10.8 shape: read_file outside files.read(project) blocks at the params step", async () => { + const loop = fx.newLoop("quick-outside-read"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + loop.queueTurn({ + calls: [ + { toolCallId: "in1", tool: "read_file", args: { path: `${fx.proj}/src/a.ts` } }, + { toolCallId: "out1", tool: "read_file", args: { path: `${fx.home}/outside.txt` } }, + ], + }); + await session.runTurn("read in-scope and out-of-scope paths"); + + expect( + fx.executed.some( + (e) => + e.tool === "read_file" && (e.args as { path: string }).path === `${fx.proj}/src/a.ts`, + ), + ).toBe(true); + expect( + fx.executed.some( + (e) => + e.tool === "read_file" && (e.args as { path: string }).path === `${fx.home}/outside.txt`, + ), + ).toBe(false); + expect(blockedFor(taskId, "out1")?.data.blockedAt).toBe("params"); + expect(blockedFor(taskId, "in1")).toBeUndefined(); + }); + + it("a quick session's read_file on a credential path blocks at params (§2.2c) even inside a home-rooted grant", async () => { + // Not reachable via the default grants (project-only), but locks the + // §10.8 wording precisely: quick reads still pass full §7 enforcement. + // Constructed with an explicit over-broad files.read to isolate the + // params check from the capability-absence already covered above. + const loop = fx.newLoop("quick-credential-read"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.home, // widen deliberately to isolate the §2.2c exclusion + }); + const executedBefore = fx.executed.length; + loop.queueTurn({ + calls: [ + { toolCallId: "cred1", tool: "read_file", args: { path: `${fx.home}/.ssh/id_ed25519` } }, + ], + }); + await session.runTurn("attempt to read a credential path"); + + expect(fx.executed.slice(executedBefore)).toHaveLength(0); + expect(blockedFor(taskId, "cred1")?.data.blockedAt).toBe("params"); + }); +}); diff --git a/core/src/session/runtime.ts b/core/src/session/runtime.ts index be177d9..6afd5a9 100644 --- a/core/src/session/runtime.ts +++ b/core/src/session/runtime.ts @@ -35,6 +35,8 @@ import { ApprovalMachine } from "./approval.js"; import type { BudgetConfig } from "./budget.js"; import { BudgetTracker } from "./budget.js"; import type { Dispatch, PiLoop, PiToolCall, ToolOutcome, TurnResult } from "./piloop.js"; +import { boundToolsForProfile } from "./profiles.js"; +import { defaultQuickGrants } from "./quick-grants.js"; import { argsPayload, BLOCKED_AT, READ_SUMMARY_CAP } from "./util.js"; export type { ApprovalDecision, PendingApproval } from "./approval.js"; @@ -78,15 +80,18 @@ export class Session { // Parked-call table + mint/promote side effects (grants §6) — approval.ts. private readonly approvals: ApprovalMachine; private readonly budgetTracker: BudgetTracker; + // Profile binding table (grants §4.1 mechanism 1) — metadata only. + private readonly binding: readonly ToolMeta[]; // Package-private: construct via createForegroundSession / - // createBackgroundSession (grants.md §6.1/§6.3 seeding rules live there). + // createBackgroundSession / createQuickSession (grants.md §4.1/§6.1/§6.3). constructor( opts: SessionOptions, kind: SessionKind, grants: readonly Grant[], ledgerSeed: readonly StandingApproval[], storeBackedFamilies: PolicySession["storeBackedFamilies"], + binding?: readonly ToolMeta[], ) { this.kind = kind; this.taskStreamId = opts.taskStreamId; @@ -115,6 +120,14 @@ export class Session { this.taskStreamId, this.clock, ); + // Foreground/background bind the full registry meta surface; quick + // passes an effect:"read"-filtered projection (§4.1 / §10.7). + this.binding = binding ?? opts.registry.list(); + } + + // Invariant 2 binding table — ToolMeta only (no execute). + boundTools(): readonly ToolMeta[] { + return this.binding; } private get actor(): string { @@ -230,10 +243,23 @@ export class Session { // The gated dispatcher — grants.md §7.1 steps 1–6. Private and bound: the // loop receives it per turn; nothing else can invoke execution. + // Profile binding (grants §4.1 mechanism 1) is an independent gate: a tool + // absent from this.binding never reaches decide()/execute, even if the + // GrantSet would otherwise authorize it. private readonly dispatch: Dispatch = async (call) => { this.budgetTracker.recordToolCall(); - const decision = this.decideCall(call.tool, call.args); const reg = this.registry.get(call.tool); + if (!this.binding.some((m) => m.name === call.tool)) { + this.append("tool.call_blocked", { + toolCallId: call.toolCallId, + tool: call.tool, + capabilityFamily: reg?.requiredCapability ?? null, + reason: `tool ${call.tool} is not bound on this session profile`, + blockedAt: "registration", + }); + return { ok: false, error: `tool ${call.tool} is not bound on this session profile` }; + } + const decision = this.decideCall(call.tool, call.args); if (decision.verdict === "deny") { // §6.1: a background auto-deny is an approval-machine outcome — @@ -407,3 +433,26 @@ export function createBackgroundSession( storeBackedFamilies, ); } + +// Quick session (grants §4.1, invariant 2): default GrantSet only — never +// merges live store user grants / preview grants the way foreground does. +// Binding table is the effect:"read" projection; write tools are unbound. +export function createQuickSession( + opts: SessionOptions & { projectRoot?: string | null }, +): Session { + const grants = defaultQuickGrants({ + projectRoot: opts.projectRoot, + home: opts.home, + clock: opts.clock, + }); + // Quick defaults are session-local, not store-backed — revocation epoch + // still applies to any later store-backed family if one is added. + return new Session( + opts, + "quick", + grants, + [], + new Set(), + boundToolsForProfile(opts.registry, "quick"), + ); +} From ae7c1bda44aeabec890bf66a81f8cd62276441cf Mon Sep 17 00:00:00 2001 From: Akash Aedavelli Date: Fri, 24 Jul 2026 13:21:44 -0500 Subject: [PATCH 2/4] no-mistakes(review): fix unknown tool mislabeled as profile-unbound in dispatch gate --- core/src/session/quick.test.ts | 28 ++++++++++++++++++++++++++++ core/src/session/runtime.ts | 8 ++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/core/src/session/quick.test.ts b/core/src/session/quick.test.ts index 59b2460..2973a32 100644 --- a/core/src/session/quick.test.ts +++ b/core/src/session/quick.test.ts @@ -113,6 +113,34 @@ describe("createQuickSession (a): write tools never execute, even proposed direc expect(blockedFor(taskId, "sw1")?.data.blockedAt).toBe("registration"); expect(session.pendingApprovals()).toHaveLength(0); }); + + it("an unknown/hallucinated tool surfaces the unknown-tool reason, not the profile-binding wording (AMEND-3a)", async () => { + // A tool absent from the registry is in no binding either, but the + // binding gate must NOT claim it — decide() step 1 owns unknown tools so + // the audit row stays precise. Quick sessions run the tightest binding + // (read-only projection) yet must still report unknown, not unbound. + const loop = fx.newLoop("quick-unknown-tool"); + const taskId = fx.newTask(writer, loop.jsonlPath); + const session = createQuickSession({ + taskStreamId: taskId, + registry: fx.registry, + writer, + store: fx.store, + loop, + home: fx.home, + clock: fx.clock, + projectRoot: fx.proj, + }); + loop.queueTurn({ + calls: [{ toolCallId: "u1", tool: "hallucinated_tool", args: {} }], + }); + await session.runTurn("propose a tool that does not exist"); + + const blocked = blockedFor(taskId, "u1"); + expect(blocked?.data.blockedAt).toBe("registration"); + expect(blocked?.data.reason).toBe("unknown tool hallucinated_tool"); + expect(blocked?.data.reason).not.toContain("not bound on this session profile"); + }); }); describe("defaultQuickGrants (§4.1 mechanism 2)", () => { diff --git a/core/src/session/runtime.ts b/core/src/session/runtime.ts index 6afd5a9..e3f8542 100644 --- a/core/src/session/runtime.ts +++ b/core/src/session/runtime.ts @@ -249,11 +249,15 @@ export class Session { private readonly dispatch: Dispatch = async (call) => { this.budgetTracker.recordToolCall(); const reg = this.registry.get(call.tool); - if (!this.binding.some((m) => m.name === call.tool)) { + // Profile binding gate (grants §4.1 mechanism 1) applies only to KNOWN + // tools. An unknown/hallucinated tool is in no binding either, but must + // surface decide()'s unknown_tool reason (AMEND-3a audit precision) rather + // than the profile-binding wording — so it falls through to decideCall(). + if (reg !== undefined && !this.binding.some((m) => m.name === call.tool)) { this.append("tool.call_blocked", { toolCallId: call.toolCallId, tool: call.tool, - capabilityFamily: reg?.requiredCapability ?? null, + capabilityFamily: reg.requiredCapability, reason: `tool ${call.tool} is not bound on this session profile`, blockedAt: "registration", }); From 0e269cb9dfa7e97eb1aa87d7c2589415f7c0fe06 Mon Sep 17 00:00:00 2001 From: Akash Aedavelli Date: Fri, 24 Jul 2026 13:38:23 -0500 Subject: [PATCH 3/4] ci(review): honor Risk-Tier commit trailer on pull_request tier check 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 --- scripts/check-tier.mjs | 47 ++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/scripts/check-tier.mjs b/scripts/check-tier.mjs index 408a414..2d96447 100644 --- a/scripts/check-tier.mjs +++ b/scripts/check-tier.mjs @@ -11,7 +11,10 @@ // 1. --declare=routine|semantic|trust-critical // 2. GILLY_DECLARE_TIER env // 3. PR body checkboxes / "Risk tier: X" (CI only) -// 4. If none and min is routine → OK; if min is higher → FAIL (must declare) +// 4. `Risk-Tier:` commit trailer in the branch range (CI only) — parity +// with the push path, so a durable in-git declaration survives an +// auto-generated PR body that lacks a parseable tier. +// 5. If none and min is routine → OK; if min is higher → FAIL (must declare) import { execFileSync, spawnSync } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; @@ -142,19 +145,37 @@ function parseDeclared(argv, ci) { /* ignore */ } } - if (!body) return null; - - // Prefer checked checkboxes in PR template order (trust > semantic > routine) - const checked = []; - for (const t of TIERS) { - const re = new RegExp(`^\\s*-\\s*\\[x\\]\\s*\`?${t}\`?`, "im"); - if (re.test(body)) checked.push(t); - } - if (checked.length) { - // Highest declared among checked - return checked.sort((a, b) => RANK[b] - RANK[a])[0]; + if (body) { + // Prefer checked checkboxes in PR template order (trust > semantic > routine) + const checked = []; + for (const t of TIERS) { + const re = new RegExp(`^\\s*-\\s*\\[x\\]\\s*\`?${t}\`?`, "im"); + if (re.test(body)) checked.push(t); + } + if (checked.length) { + // Highest declared among checked + return checked.sort((a, b) => RANK[b] - RANK[a])[0]; + } + const m = body.match(/risk\s*tier\s*[:-]\s*`?(routine|semantic|trust-critical)`?/i); + if (m) return m[1].toLowerCase(); } - const m = body.match(/risk\s*tier\s*[:-]\s*`?(routine|semantic|trust-critical)`?/i); + + // Fall back to a `Risk-Tier:` commit trailer in the branch range — the same + // durable, in-git declaration the push path honors. Keeps a trust-critical + // PR from failing when its auto-generated body carries no parseable tier. + return trailerTier(); +} + +// Scan the branch commit range for a `Risk-Tier:` trailer (mirrors the push +// step's `git log` sed extraction in .github/workflows/check-all.yml). +function trailerTier() { + const base = process.env.GITHUB_BASE_REF || process.env.GILLY_TIER_BASE || "origin/main"; + const range = process.env.GILLY_TIER_RANGE; + // Diff ranges use `...`; a log range wants `..` (commits in HEAD, not base). + const logRange = range ? range.replace("...", "..") : `${base}..HEAD`; + const r = spawnSync("git", ["log", "--format=%B", logRange], { cwd: ROOT, encoding: "utf8" }); + if (r.status !== 0) return null; + const m = (r.stdout || "").match(/^Risk-Tier:\s*(routine|semantic|trust-critical)\s*$/im); return m ? m[1].toLowerCase() : null; } From 11d2456a8ea7ae91b0abf4a524e67fd90bd7ae4d Mon Sep 17 00:00:00 2001 From: Akash Aedavelli Date: Fri, 24 Jul 2026 14:00:59 -0500 Subject: [PATCH 4/4] fix(session): omit files.read when projectRoot is not canonical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/src/session/quick-grants.ts | 23 +++++++++++++++++++---- core/src/session/quick.test.ts | 12 ++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/core/src/session/quick-grants.ts b/core/src/session/quick-grants.ts index 71a2ba2..14857a1 100644 --- a/core/src/session/quick-grants.ts +++ b/core/src/session/quick-grants.ts @@ -3,7 +3,7 @@ // a project root is explicit/determinable at creation. Never defaults to // $HOME or the sidecar CWD (deep-review B1). -import { canonicalPath } from "../lib/index.js"; +import { CanonicalPathError, canonicalPath } from "../lib/index.js"; import type { Grant } from "../policy/index.js"; export type QuickGrantOpts = { @@ -22,6 +22,21 @@ function grant(family: Grant["capability"]["family"], params: unknown, at: strin }; } +// Absolute + canonicalizable root, or null if not determinable (§4.1). +// Relative / whitespace / non-canonical paths omit files.read — never throw +// and never fall back to $HOME / cwd. +function determinableProjectRoot(root: string | null | undefined): string | null { + if (typeof root !== "string") return null; + const trimmed = root.trim(); + if (trimmed.length === 0) return null; + try { + return canonicalPath(trimmed); + } catch (err) { + if (err instanceof CanonicalPathError) return null; + throw err; + } +} + export function defaultQuickGrants(opts: QuickGrantOpts): Grant[] { const at = new Date(opts.clock?.() ?? Date.now()).toISOString(); const grants: Grant[] = [ @@ -29,10 +44,10 @@ export function defaultQuickGrants(opts: QuickGrantOpts): Grant[] { grant("web.search", null, at), grant("web.fetch", null, at), ]; - const root = opts.projectRoot; - if (typeof root === "string" && root.length > 0) { + const root = determinableProjectRoot(opts.projectRoot); + if (root !== null) { // Never fall back to opts.home or process.cwd() (deep-review B1). - grants.push(grant("files.read", [canonicalPath(root)], at)); + grants.push(grant("files.read", [root], at)); } return grants; } diff --git a/core/src/session/quick.test.ts b/core/src/session/quick.test.ts index 2973a32..46b4a2a 100644 --- a/core/src/session/quick.test.ts +++ b/core/src/session/quick.test.ts @@ -159,6 +159,18 @@ describe("defaultQuickGrants (§4.1 mechanism 2)", () => { expect(grants.map((g) => g.capability.family)).not.toContain("files.read"); }); + it("relative or whitespace-only projectRoot is not determinable — omit files.read, keep base triple (Bugbot)", () => { + for (const projectRoot of ["", " ", "relative/proj", "./here"] as const) { + expect(() => + defaultQuickGrants({ projectRoot, home: fx.home, clock: fixedClock }), + ).not.toThrow(); + const grants = defaultQuickGrants({ projectRoot, home: fx.home, clock: fixedClock }); + const families = grants.map((g) => g.capability.family).sort(); + expect(families).toEqual(["screen.read", "web.fetch", "web.search"]); + expect(families).not.toContain("files.read"); + } + }); + it("a determinable project root yields files.read(projectRoot) exactly — never home, never cwd", () => { const grants = defaultQuickGrants({ projectRoot: fx.proj, home: fx.home, clock: fixedClock }); const filesRead = grants.find((g) => g.capability.family === "files.read");