Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .captain/review/loop-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
2 changes: 1 addition & 1 deletion INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion core/src/policy/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions core/src/policy/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
172 changes: 172 additions & 0 deletions core/src/session/profiles.property.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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(),
);
});
});
16 changes: 16 additions & 0 deletions core/src/session/profiles.ts
Original file line number Diff line number Diff line change
@@ -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");
}
53 changes: 53 additions & 0 deletions core/src/session/quick-grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Default GrantSet for quick sessions — grants.md §4.1 mechanism 2.
// Always {screen.read, web.search, web.fetch}; files.read(<root>) only when
// a project root is explicit/determinable at creation. Never defaults to
// $HOME or the sidecar CWD (deep-review B1).

import { CanonicalPathError, 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,
};
}

// 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[] = [
grant("screen.read", null, at),
grant("web.search", null, at),
grant("web.fetch", null, at),
];
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", [root], at));
}
return grants;
}
Loading
Loading