From ae575fca79a28662f00e915d9a207f8ca418ad35 Mon Sep 17 00:00:00 2001 From: Mark Russell Date: Sun, 15 Nov 2026 10:16:45 -0500 Subject: [PATCH] fix(client): the admission examines each nested call that its caller does not declare A procedure of code with declared effects answered for each of its nested calls, also without metadata.calls. Now the exemption covers one declared edge: an entry of the calls of the caller names the callee, and the effects of the callee are in the declaration of the caller. - examinesNestedCall() gets the optional parameter callee. Without it, the function gives true. - edgeFaults() is the one rule of an edge. checkEffects() and the admission use it. - Five tests: a caller without calls, a callee outside the calls, a pattern entry with more effects, a callee without effects, and no callee. Each one fails with the old rule. Co-Authored-By: Claude Opus 5.5 --- .changeset/nested-call-declared-edge.md | 11 +++ packages/client/docs/CAPABILITIES.md | 4 +- .../client/src/procedures/capability.test.ts | 84 +++++++++++++++-- packages/client/src/procedures/capability.ts | 89 ++++++++++++------- packages/client/src/procedures/invoke.ts | 2 +- packages/client/test/api/public-api.txt | 2 +- packages/documentation/DECISIONS-2026-10.md | 2 +- 7 files changed, 153 insertions(+), 41 deletions(-) create mode 100644 .changeset/nested-call-declared-edge.md diff --git a/.changeset/nested-call-declared-edge.md b/.changeset/nested-call-declared-edge.md new file mode 100644 index 000000000..6bb8e94b6 --- /dev/null +++ b/.changeset/nested-call-declared-edge.md @@ -0,0 +1,11 @@ +--- +"@procedures/client": patch +--- + +The admission examines each nested call that its caller does not declare. A procedure of code with declared effects answers for a nested call only in one case. An entry of its `metadata.calls` names the callee, and the effects of the callee are in its declaration. `checkEffects()` examines the same edges. Before, such a procedure answered for each of its nested calls, also without `calls`. + +Declare each nested callee of a procedure of code in `metadata.calls`. A nested call that the caller does not declare passes only when the capability permits the callee as a direct call. + +`examinesNestedCall()` gets the optional parameter `callee`. Without it, the function gives true. + +PATCH: a fix of the admission, and a new optional parameter. diff --git a/packages/client/docs/CAPABILITIES.md b/packages/client/docs/CAPABILITIES.md index c2068dfb8..93805209f 100644 --- a/packages/client/docs/CAPABILITIES.md +++ b/packages/client/docs/CAPABILITIES.md @@ -136,7 +136,9 @@ await ctx.client.call(["notes", "list"], {}, { narrow: { clearance: "public" } } An interceptor can narrow too: `next({ capability: { effects: { writes: [] } } })`. The framework calculates the result, so a request for more has no effect. -The admission examines each call of a host, each nested call of a data-driven procedure (`client.chain` and the other procedures that start refs, because their caller chose the refs), and each nested call with a narrowed capability. A procedure of code that declares its effects answers for its own nested calls. `checkEffects(procedures)` examines those declarations against `metadata.calls`: a declaration must include the reads and the writes of each procedure in its `calls`. Two things are the claim of the caller. The first is what a program that it starts does (the `process` of a callee). The second is the flags `destructive` and `idempotent`, because they depend on the input that the caller gives. +The admission examines each call of a host, each nested call of a data-driven procedure (`client.chain` and the other procedures that start refs, because their caller chose the refs), and each nested call with a narrowed capability. A procedure of code that declares its effects answers for a declared edge only. An entry of its `metadata.calls` names the callee, and the effects of the callee are in its declaration. The admission examines each other nested call, also each nested call of a caller without `calls`. + +`checkEffects(procedures)` examines the same edges over the registry: a declaration must include the reads and the writes of each procedure in its `calls`. Two things are the claim of the caller. The first is what a program that it starts does (the `process` of a callee). The second is the flags `destructive` and `idempotent`, because they depend on the input that the caller gives. ## Deadlines diff --git a/packages/client/src/procedures/capability.test.ts b/packages/client/src/procedures/capability.test.ts index 188eff044..8456fd305 100644 --- a/packages/client/src/procedures/capability.test.ts +++ b/packages/client/src/procedures/capability.test.ts @@ -10,6 +10,7 @@ import { UNLIMITED_CAPABILITY, capabilityRefusal, checkEffects, + examinesNestedCall, explainInvocation, ladder, narrowCapability, @@ -49,13 +50,42 @@ function makeRegistry(log: string[] = []): ProcedureRegistry { registry.register(proc(["files", "read"], () => { log.push("read"); return "content"; }, { effects: READS_FS })); registry.register(proc(["files", "write"], () => { log.push("write"); return "written"; }, { effects: WRITES_FS })); registry.register(proc(["files", "unknown"], () => { log.push("unknown"); return "?"; })); - // A procedure of code that declares its effects and uses a helper that declares none + // A procedure that starts a program: its caller declares what the program does + registry.register(proc(["files", "program"], () => { log.push("program"); return "program"; }, { effects: { reads: [], writes: ["process"] } })); + // A procedure of code that declares its effects and its call of the program registry.register( - proc(["files", "summary"], (_input, ctx) => ctx.client.call(["files", "unknown"], {}), { + proc(["files", "summary"], (_input, ctx) => ctx.client.call(["files", "program"], {}), { + effects: READS_FS, + calls: ["files.program"], + }) + ); + // A procedure of code that declares its call of a helper that declares no effects + registry.register( + proc(["files", "helped"], (_input, ctx) => ctx.client.call(["files", "unknown"], {}), { effects: READS_FS, calls: ["files.unknown"], }) ); + // A procedure of code that declares its effects and no calls, and changes a file + registry.register( + proc(["files", "export"], (_input, ctx) => ctx.client.call(["files", "write"], {}), { + effects: { reads: ["fs"], writes: [] }, + }) + ); + // A procedure of code whose calls do not name the procedure that it calls + registry.register( + proc(["files", "reach"], (_input, ctx) => ctx.client.call(["files", "write"], {}), { + effects: { reads: ["fs"], writes: [] }, + calls: ["files.read"], + }) + ); + // A procedure of code whose calls name the writer with a pattern, but whose effects do not include it + registry.register( + proc(["files", "wide"], (_input, ctx) => ctx.client.call(["files", "write"], {}), { + effects: { reads: ["fs"], writes: [] }, + calls: ["files.*"], + }) + ); // The same caller, but it narrows its nested call registry.register( proc(["files", "narrowing"], (_input, ctx) => ctx.client.call(["files", "unknown"], {}, { narrow: { effects: { writes: [] } } }), { @@ -208,11 +238,55 @@ describe("invocations under a capability", () => { expect(log).toEqual(["read"]); }); - it("lets a procedure of code with declared effects answer for its nested calls", async () => { + it("lets a procedure of code answer for a declared nested call in its effects", async () => { + const log: string[] = []; + const registry = makeRegistry(log); + // As a direct call, a read-only capability refuses the program + expect((await refusal(run(registry, ["files", "program"], {}, { capability: READ_ONLY }))).reason).toBe("effects-exceeded"); + expect(await run(registry, ["files", "summary"], {}, { capability: READ_ONLY })).toBe("program"); + expect(log).toEqual(["program"]); + }); + + it("examines each nested call of a caller that declares effects and no calls", async () => { + const log: string[] = []; + const registry = makeRegistry(log); + const error = await refusal(run(registry, ["files", "export"], {}, { capability: READ_ONLY })); + expect(error.code).toBe("FORBIDDEN"); + expect(error.reason).toBe("effects-exceeded"); + expect(error.message).toContain("files.write"); + expect(log).toEqual([]); + // With no capability, the nested call starts as before + expect(await run(registry, ["files", "export"], {})).toBe("written"); + expect(log).toEqual(["write"]); + }); + + it("examines a nested call that the calls of its caller do not name", async () => { + const log: string[] = []; + const error = await refusal(run(makeRegistry(log), ["files", "reach"], {}, { capability: READ_ONLY })); + expect(error.reason).toBe("effects-exceeded"); + expect(log).toEqual([]); + }); + + it("examines a declared nested call whose effects are not in the declaration of its caller", async () => { + const log: string[] = []; + const error = await refusal(run(makeRegistry(log), ["files", "wide"], {}, { capability: READ_ONLY })); + expect(error.reason).toBe("effects-exceeded"); + expect(log).toEqual([]); + }); + + it("examines a declared nested call of a callee that declares no effects", async () => { const log: string[] = []; const registry = makeRegistry(log); - expect(await run(registry, ["files", "summary"], {}, { capability: READ_ONLY })).toBe("?"); - expect(log).toEqual(["unknown"]); + expect((await refusal(run(registry, ["files", "helped"], {}, { capability: READ_ONLY }))).reason).toBe("effects-undeclared"); + expect(log).toEqual([]); + expect(await run(registry, ["files", "helped"], {})).toBe("?"); + }); + + it("examines a nested call when the function does not get the callee", () => { + const caller = proc(["a", "caller"], () => 1, { effects: READS_FS, calls: ["a.*"] }); + const callee = proc(["a", "reader"], () => 1, { effects: READS_FS }); + expect(examinesNestedCall(caller, READ_ONLY, READ_ONLY, callee)).toBe(false); + expect(examinesNestedCall(caller, READ_ONLY, READ_ONLY)).toBe(true); }); it("examines a nested call that the caller narrows", async () => { diff --git a/packages/client/src/procedures/capability.ts b/packages/client/src/procedures/capability.ts index fb1588f93..0523445f3 100644 --- a/packages/client/src/procedures/capability.ts +++ b/packages/client/src/procedures/capability.ts @@ -9,9 +9,10 @@ * A procedure declares its effects in `metadata.effects`. The admission (`capabilityRefusal()`) * compares them with the permitted effects. It examines each call of a host, each nested call * of a data-driven procedure (its caller chose the refs), and each nested call with a narrowed - * capability. A procedure of code that declares its effects answers for its own nested calls: - * `checkEffects()` examines those declarations against the call graph, as `metadata.calls` - * makes the reach of a procedure visible. + * capability. A procedure of code that declares its effects answers for each nested call that + * its `metadata.calls` declares, when the effects of the callee are in its own declaration. + * `checkEffects()` examines the same edges against the call graph. The admission examines each + * other nested call. */ import type { @@ -261,13 +262,55 @@ export function capabilityRefusal( } /** - * True when the admission examines a nested call: the caller is data-driven (its caller chose - * the refs), the caller declares no effects, or the capability of the nested call is narrower - * than the capability of its caller. A procedure of code with declared effects answers for its - * own nested calls (`checkEffects()` examines them). + * True when the admission examines a nested call. The admission skips only a declared edge: a + * procedure of code with declared effects calls a procedure that an entry of its `metadata.calls` + * names, with the same capability, and the effects of the callee are in the declaration of the + * caller (`edgeFaults()` gives nothing). A caller without `calls` answers for no nested call. + * Without the callee, the function cannot name the edge, so it gives true. */ -export function examinesNestedCall(caller: AnyProcedure, callerCapability: Capability, capability: Capability): boolean { - return isDataDriven(caller) || effectsOf(caller) === undefined || capability !== callerCapability; +export function examinesNestedCall( + caller: AnyProcedure, + callerCapability: Capability, + capability: Capability, + callee?: AnyProcedure +): boolean { + if (callee === undefined || isDataDriven(caller) || capability !== callerCapability) return true; + const own = effectsOf(caller); + if (own === undefined) return true; + const key = pathToKey(callee.path); + if (!(declaredCalls(caller) ?? []).some((entry) => callMatches(entry, key))) return true; + return edgeFaults(own, callee).length > 0; +} + +/** The entries of `metadata.calls` of a procedure, or undefined when it declares none. */ +function declaredCalls(procedure: AnyProcedure): readonly string[] | undefined { + const calls = (procedure.metadata as { calls?: unknown } | undefined)?.calls; + return Array.isArray(calls) ? (calls as string[]) : undefined; +} + +/** + * The faults of one edge of the call graph: each read or change of the callee that the + * declaration of the caller does not include. A callee that writes `process` starts a program, + * and the caller declares what that program does, so the check skips `process`. A kind that the + * caller does not declare has no limit here: the admission refuses that caller under a capability + * that limits the kind. + */ +function edgeFaults(own: Effects, callee: AnyProcedure): string[] { + const theirs = effectsOf(callee); + if (!theirs) return ["the callee declares no effects"]; + const faults: string[] = []; + for (const kind of ["reads", "writes"] as const) { + const mine = own[kind]; + const declared = theirs[kind]; + if (declared === undefined) { + faults.push(`the callee does not declare what it ${VERB[kind]}`); + continue; + } + if (mine === undefined) continue; + const missing = declared.filter((name) => name !== "process" && !mine.includes(name)); + if (missing.length > 0) faults.push(`the declaration does not include ${missing.join(", ")} in ${kind}`); + } + return faults; } /** The result of `explainInvocation()`. */ @@ -321,34 +364,16 @@ export function checkEffects(procedures: readonly AnyProcedure[]): EffectsViolat const violations: EffectsViolation[] = []; for (const procedure of procedures) { const own = effectsOf(procedure); - const calls = (procedure.metadata as { calls?: unknown } | undefined)?.calls; - if (!own || !Array.isArray(calls) || isDataDriven(procedure)) continue; + const calls = declaredCalls(procedure); + if (!own || !calls || isDataDriven(procedure)) continue; const key = pathToKey(procedure.path); const callees = new Set(); - for (const entry of calls as string[]) { + for (const entry of calls) { for (const candidate of byKey.keys()) if (callMatches(entry, candidate)) callees.add(candidate); } for (const calleeKey of [...callees].sort()) { - const callee = byKey.get(calleeKey)!; - const theirs = effectsOf(callee); - const report = (message: string): void => { - violations.push({ path: procedure.path, callee: calleeKey, message: `${key} calls ${calleeKey}: ${message}` }); - }; - if (!theirs) { - report("the callee declares no effects"); - continue; - } - for (const kind of ["reads", "writes"] as const) { - const mine = own[kind]; - const needed = (theirs[kind] ?? []).filter((name) => name !== "process"); - if (theirs[kind] === undefined) { - report(`the callee does not declare what it ${VERB[kind]}`); - } else if (mine === undefined) { - continue; - } else { - const missing = needed.filter((name) => !mine.includes(name)); - if (missing.length > 0) report(`the declaration does not include ${missing.join(", ")} in ${kind}`); - } + for (const fault of edgeFaults(own, byKey.get(calleeKey)!)) { + violations.push({ path: procedure.path, callee: calleeKey, message: `${key} calls ${calleeKey}: ${fault}` }); } } } diff --git a/packages/client/src/procedures/invoke.ts b/packages/client/src/procedures/invoke.ts index 5f12e2897..ce672b214 100644 --- a/packages/client/src/procedures/invoke.ts +++ b/packages/client/src/procedures/invoke.ts @@ -466,7 +466,7 @@ async function runInvocation(invocation: Invocation, options: InvokeOpt // The admission: before the input validation, so a refused call learns nothing from its input const parent = invocation.parent; - if (!parent || examinesNestedCall(parent.procedure, parent.capability, invocation.capability)) { + if (!parent || examinesNestedCall(parent.procedure, parent.capability, invocation.capability, procedure)) { const refusal = capabilityRefusal(procedure, invocation.capability); if (refusal?.reason === "deadline") throw new InvocationError("TIMEOUT", refusal.message, path); if (refusal) throw new InvocationError("FORBIDDEN", refusal.message, path, refusal.reason); diff --git a/packages/client/test/api/public-api.txt b/packages/client/test/api/public-api.txt index cc142d627..b2b17deea 100644 --- a/packages/client/test/api/public-api.txt +++ b/packages/client/test/api/public-api.txt @@ -4205,7 +4205,7 @@ export declare function effectsOf(procedure: { }): Effects | undefined; ### dist/procedures/capability.d.ts#examinesNestedCall -export declare function examinesNestedCall(caller: AnyProcedure, callerCapability: Capability, capability: Capability): boolean; +export declare function examinesNestedCall(caller: AnyProcedure, callerCapability: Capability, capability: Capability, callee?: AnyProcedure): boolean; ### dist/procedures/capability.d.ts#explainInvocation export declare function explainInvocation(path: ProcedurePath, capability: Capability | undefined, registry: { diff --git a/packages/documentation/DECISIONS-2026-10.md b/packages/documentation/DECISIONS-2026-10.md index 92eed83e6..70aa75c63 100644 --- a/packages/documentation/DECISIONS-2026-10.md +++ b/packages/documentation/DECISIONS-2026-10.md @@ -18,7 +18,7 @@ This log records the decisions I made while working without the owner present. E The owner accepted the default answers of CAPABILITIES-2026-10 and gave the rest of the decisions to the implementation, for generality and the long term. The proposal lists the 14 decisions. The judgment calls that a user sees: - **`ctx.capability` and `ctx.resources` are required fields of `ProcedureContext`.** A handler gets typed resources without `?`. A test that makes a context by hand adds `capability: {}` and `resources: {}`. -- **A procedure of code with declared effects answers for its nested calls.** The admission does not examine them again, as `metadata.calls` already does for the reach. Without this rule, a read-only session cannot start `git.status`, because it uses `shell.run`. +- **A procedure of code with declared effects answers for its nested calls.** The admission does not examine them again, as `metadata.calls` already does for the reach. Without this rule, a read-only session cannot start `git.status`, because it uses `shell.run`. Since 2026-11-15, the rule covers a declared edge only. The `calls` of the caller name the callee, and the effects of the callee are in its declaration. The admission examines each other nested call. - **The output filter examines only the output that leaves the host.** A nested result is not filtered, so a parent never gets a stream with holes. - **`git.remote` is two procedures** (CAP-5 in the status). A caller of `git.remote` with `url` calls `git.remote.set` now. - **The four findings CAP-1 to CAP-4** stay open, or are a decision (CAP-4): the declarations tell the truth about the code, and a read-only session refuses those procedures.