@intentius/chant 0.72.2 → 0.72.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { foldProject, foldExecutionCounts, resetFoldExecutionCounts } from "../index";
6
+
7
+ /**
8
+ * chant#2455 — `ι = executing`, the third isolation mode (spec `1.8`).
9
+ *
10
+ * chant#2453 made the default strict: a declarator call to a declared project
11
+ * function whose body cannot fold refuses the file rather than importing and
12
+ * invoking it. That closed a real leak — the fold of a file reading
13
+ * `process.env` carried the folding shell's environment, under a `fold`
14
+ * verdict that read as "determined statically".
15
+ *
16
+ * The old behaviour is worth having when the caller means it, so it comes back
17
+ * as a mode. What it cannot be is implicit. `open` is the default and is
18
+ * strict; a build that wants what a run would compute has to ask, and the
19
+ * asking is what makes the environment dependence visible.
20
+ */
21
+ describe("the executing isolation mode (chant#2455)", () => {
22
+ let root: string;
23
+ let app: string;
24
+ let params: string;
25
+
26
+ beforeEach(() => {
27
+ root = mkdtempSync(join(tmpdir(), "chant-executing-"));
28
+ params = join(root, "params.ts");
29
+ app = join(root, "app.ts");
30
+ writeFileSync(
31
+ params,
32
+ 'export function fromEnv() {\n return { prefix: process.env.CHANT_TEST_MODE ?? "fallback" };\n}\n',
33
+ );
34
+ writeFileSync(app, 'import { fromEnv } from "./params";\nexport const v = fromEnv();\n');
35
+ resetFoldExecutionCounts();
36
+ process.env.CHANT_TEST_MODE = "from-the-environment";
37
+ });
38
+
39
+ afterEach(() => {
40
+ delete process.env.CHANT_TEST_MODE;
41
+ rmSync(root, { recursive: true, force: true });
42
+ });
43
+
44
+ test("the default refuses, and nothing of the project runs", async () => {
45
+ const verdict = (await foldProject([app, params], [], {})).get(app)!;
46
+
47
+ expect(verdict.verdict).toBe("run");
48
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
49
+ });
50
+
51
+ test("executing invokes it, and the fold carries what a run would compute", async () => {
52
+ const verdict = (await foldProject([app, params], [], { executing: true })).get(app)!;
53
+
54
+ expect(verdict.verdict).toBe("fold");
55
+ expect(JSON.parse(JSON.stringify(Object.fromEntries(verdict.exports!)))).toEqual({
56
+ v: { prefix: "from-the-environment" },
57
+ });
58
+ // The counter is how the mode is observable from outside: this is a
59
+ // project-owned invocation, which is what F-Call step 6 counts.
60
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(1);
61
+ });
62
+
63
+ test("the environment dependence is real, which is why the mode has to be asked for", async () => {
64
+ process.env.CHANT_TEST_MODE = "one";
65
+ const first = (await foldProject([app, params], [], { executing: true })).get(app)!;
66
+ process.env.CHANT_TEST_MODE = "two";
67
+ const second = (await foldProject([app, params], [], { executing: true })).get(app)!;
68
+
69
+ // Under `executing` the same source folds to different values in different
70
+ // environments. That is the mode behaving as specified, not a defect — and
71
+ // it is exactly what must not happen by default.
72
+ expect(JSON.stringify([...first.exports!])).not.toBe(JSON.stringify([...second.exports!]));
73
+ });
74
+
75
+ test("sandbox still refuses, and is unchanged by the new mode", async () => {
76
+ const verdict = (await foldProject([app, params], [], { sandbox: true })).get(app)!;
77
+
78
+ expect(verdict.verdict).toBe("run");
79
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
80
+ });
81
+
82
+ test("asking for both sandbox and executing is refused rather than resolved", async () => {
83
+ // Not a preference between two readings — a contradiction. One refuses to
84
+ // import project code, the other exists to invoke it, and silently picking
85
+ // either would make the fold's meaning depend on which.
86
+ await expect(foldProject([app, params], [], { sandbox: true, executing: true })).rejects.toThrow(
87
+ /mutually exclusive/,
88
+ );
89
+ });
90
+
91
+ test("the default's reason names the mode that would have folded it", async () => {
92
+ const reason = (await foldProject([app, params], [], {})).get(app)!.reason!;
93
+
94
+ // The body's own diagnostic is kept — it is the actionable half, and here
95
+ // it says to use a build parameter rather than reach for the new mode.
96
+ expect(reason).toContain('ambient "process" read is not foldable');
97
+ expect(reason).toContain("executing");
98
+ });
99
+
100
+ test("a function whose body folds is untouched by the mode", async () => {
101
+ const helper = join(root, "helper.ts");
102
+ writeFileSync(helper, 'export function j() {\n return { j: ["a", "b"].join("-") };\n}\n');
103
+ const caller = join(root, "caller.ts");
104
+ writeFileSync(caller, 'import { j } from "./helper";\nexport const v = j();\n');
105
+
106
+ for (const options of [{}, { executing: true }]) {
107
+ resetFoldExecutionCounts();
108
+ const verdict = (await foldProject([caller, helper], [], options)).get(caller)!;
109
+ expect(verdict.verdict).toBe("fold");
110
+ // Interpreted in both modes: `executing` is a fallback for a body that
111
+ // did NOT fold, not a shortcut past interpretation.
112
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
113
+ }
114
+ });
115
+ });
@@ -261,6 +261,8 @@ export interface FoldSession {
261
261
  * only the fold half would buy nothing there.
262
262
  */
263
263
  readonly sandbox: boolean;
264
+ /** chant#2455 — `ι = executing`: invoke a declared project function whose body did not fold. */
265
+ readonly executing: boolean;
264
266
  /**
265
267
  * chant #1023 — per-build memo for {@link readFactoryModule}, keyed by the
266
268
  * resolved absolute path of a module that DEFINES a composite. A composite
@@ -322,6 +324,8 @@ export function createFoldSession(
322
324
  * named as a specifier rather than only as a lexicon.
323
325
  */
324
326
  lexiconPackages: readonly string[] = [],
327
+ /** chant#2455 — `ι = executing`. Mutually exclusive with `sandbox`. */
328
+ executing = false,
325
329
  ): FoldSession {
326
330
  return {
327
331
  intrinsics,
@@ -332,6 +336,7 @@ export function createFoldSession(
332
336
  buildParams,
333
337
  lexiconPackages: new Set([...lexicons.map(lexiconPackageName), ...lexiconPackages]),
334
338
  sandbox,
339
+ executing,
335
340
  factoryModules: new Map(),
336
341
  };
337
342
  }
@@ -1275,6 +1280,8 @@ interface ResolveCtx {
1275
1280
  lexiconPackages: ReadonlySet<string>;
1276
1281
  /** chant #1093 — see {@link FoldSession.sandbox}. */
1277
1282
  sandbox: boolean;
1283
+ /** chant#2455 — see {@link FoldSession.executing}. */
1284
+ executing: boolean;
1278
1285
  /**
1279
1286
  * chant #1023 — the whole build session, for the two things composite-factory
1280
1287
  * interpretation needs that a per-file context cannot carry: the
@@ -1458,24 +1465,57 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
1458
1465
  if (!(err instanceof Error)) throw err;
1459
1466
  foldFailure = err;
1460
1467
  }
1461
- if (!binding) throw foldFailure;
1462
- // chant#2441 — `F-Div-Depth` is a refusal, not a failure to try hard
1463
- // enough. The fallback below imports the callee and invokes it, which is
1464
- // right when its body merely did not fold; here the folder has DECLINED,
1465
- // and invoking anyway produces exactly the envelope the specification says
1466
- // must not appear — a value the file's own declarators never produced.
1468
+ // chant#2453 — a DECLARED function is judged by its body, and that is the
1469
+ // whole answer. There is no invocation arm here any more.
1467
1470
  //
1468
- // Every other `F-Div` row is a fallback and `divergence.md` says so
1469
- // outright, which is what makes the direction safe. This one is not, so it
1470
- // propagates and the file runs, which is what the reference does.
1471
- if (foldFailure instanceof FoldError && foldFailure.refusedAtDepth) throw foldFailure;
1472
- try {
1473
- return await resolveImportedCall(node, calleeName, binding, ctx);
1474
- } catch (err) {
1471
+ // There used to be: when the body did not fold, an imported callee was
1472
+ // imported and invoked instead. chant#2441 narrowed that to stop it
1473
+ // rescuing a depth refusal. The external corpus then showed what the rest
1474
+ // of it does, in a project nobody here maintains
1475
+ // (jhgaylor/infisical-chant, INTENTIUS/typescript-as-data#129):
1476
+ //
1477
+ // export const namingParams = namingParamsFromEnv();
1478
+ //
1479
+ // whose body reads `process.env`. `F-Eval-Ident` step 4 rejects `process`,
1480
+ // so the body does not fold — and chant imported the module and ran it,
1481
+ // folding the file to whatever the FOLDING PROCESS's environment held. The
1482
+ // file reported `fold`, which reads as "determined statically". Two people
1483
+ // folding the same source got different output and nothing said so.
1484
+ //
1485
+ // That is wrong in chant's own terms before it is wrong in J1's: `--fold`
1486
+ // is the value you would get by running, WITHOUT running, and this ran. So
1487
+ // the rejection propagates and the file runs, carrying the reason
1488
+ // `F-Eval-CallLocal` gives.
1489
+ //
1490
+ // `F-Call` step 6's invocation is for a binding that is neither a declared
1491
+ // function nor an interpretable composite, and it still happens — below,
1492
+ // in the arm this one is not. A composite factory is not a
1493
+ // `FoldableFunction`, so `resolveImportedCall` still interprets it and
1494
+ // still invokes it when interpretation declines.
1495
+ //
1496
+ // chant#2455 — unless the caller asked for `ι = executing` (spec 1.8),
1497
+ // which is this behaviour as a MODE rather than as a silent default. The
1498
+ // value is then what a run would compute in this process, environment and
1499
+ // all, which is exactly why it cannot be implicit.
1500
+ if (ctx.executing && binding) {
1501
+ try {
1502
+ return await resolveImportedCall(node, calleeName, binding, ctx);
1503
+ } catch (err) {
1504
+ throw cheapError(
1505
+ `${foldFailure.message}; invoking it instead failed: ${err instanceof Error ? err.message : String(err)}`,
1506
+ );
1507
+ }
1508
+ }
1509
+ // Name the mode that would have folded it, so the reason is actionable
1510
+ // rather than only correct. Not under `sandbox`, where `executing` is not
1511
+ // an option the caller could take.
1512
+ if (binding && !ctx.sandbox) {
1475
1513
  throw cheapError(
1476
- `${foldFailure.message}; invoking it instead failed: ${err instanceof Error ? err.message : String(err)}`,
1514
+ `${foldFailure.message} (the default mode does not invoke a declared project function; ` +
1515
+ "`executing` would run it and fold what it returns)",
1477
1516
  );
1478
1517
  }
1518
+ throw foldFailure;
1479
1519
  }
1480
1520
 
1481
1521
  if (!binding) {
@@ -2356,6 +2396,7 @@ async function interpretCompositeFactory(
2356
2396
  resolvePathCache: ctx.resolvePathCache,
2357
2397
  lexiconPackages: ctx.lexiconPackages,
2358
2398
  sandbox: ctx.sandbox,
2399
+ executing: ctx.executing,
2359
2400
  session: ctx.session,
2360
2401
  interpretDepth: ctx.interpretDepth + 1,
2361
2402
  // chant #2161 — set unconditionally, and NOT inherited from `ctx`: a nested
@@ -3668,6 +3709,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
3668
3709
  resolvePathCache: session.resolvePathCache,
3669
3710
  lexiconPackages: session.lexiconPackages,
3670
3711
  sandbox: session.sandbox,
3712
+ executing: session.executing,
3671
3713
  session,
3672
3714
  interpretDepth: 0,
3673
3715
  // chant#2423 — filled by the pre-build below, read by
@@ -4102,6 +4144,25 @@ export interface FoldProjectOptions {
4102
4144
  readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
4103
4145
  /** chant #1093: this build asked for the sandbox, so fold may not reach outside the trusted allowlist. */
4104
4146
  readonly sandbox?: boolean;
4147
+ /**
4148
+ * chant#2455 — opt in to `ι = executing`, the third isolation mode
4149
+ * (typescript-as-data spec `1.8`).
4150
+ *
4151
+ * Under it, a declarator call to a declared project function whose body
4152
+ * cannot fold continues at `F-Call` step 6 and is invoked, so the fold's
4153
+ * value is what a run would compute **in the folding process's
4154
+ * environment**. That is the behaviour chant had before #2453, where it was
4155
+ * the silent default and leaked the folding shell's `process.env` into the
4156
+ * output of a file reported as `fold`.
4157
+ *
4158
+ * It is here as a mode rather than gone, because the value is real when the
4159
+ * caller means it. What it cannot be is implicit: `open` is the default and
4160
+ * is strict, so a build that wants a run's answer has to say so.
4161
+ *
4162
+ * Mutually exclusive with {@link sandbox}, which refuses to import project
4163
+ * code at all. Asking for both is a contradiction and refuses.
4164
+ */
4165
+ readonly executing?: boolean;
4105
4166
  /**
4106
4167
  * chant#2438 — package specifiers to follow a bare import into, verbatim,
4107
4168
  * alongside whatever `lexicons` names.
@@ -4158,12 +4219,24 @@ export async function foldProject(
4158
4219
  intrinsics: readonly IntrinsicDef[] = [],
4159
4220
  options: FoldProjectOptions = {},
4160
4221
  ): Promise<Map<string, FoldProjectVerdict>> {
4222
+ // chant#2455 — `sandbox` refuses to import project code at all and
4223
+ // `executing` exists to invoke it. Asking for both is not a preference
4224
+ // between two readings, it is a contradiction, and silently picking one would
4225
+ // make the fold's meaning depend on which.
4226
+ if (options.sandbox === true && options.executing === true) {
4227
+ throw new Error(
4228
+ "foldProject: `sandbox` and `executing` are mutually exclusive — the first refuses to import " +
4229
+ "project code and the second exists to invoke it. Pass one.",
4230
+ );
4231
+ }
4232
+
4161
4233
  const session = createFoldSession(
4162
4234
  intrinsics,
4163
4235
  options.buildParams,
4164
4236
  options.lexicons ?? [],
4165
4237
  options.sandbox ?? false,
4166
4238
  options.lexiconPackages ?? [],
4239
+ options.executing ?? false,
4167
4240
  );
4168
4241
  const attempts = new Map<string, FoldFileResult>();
4169
4242
  for (const file of files) attempts.set(file, await tryFoldFile(file, intrinsics, session));
@@ -0,0 +1,118 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { foldProject, foldExecutionCounts, resetFoldExecutionCounts } from "../index";
6
+
7
+ /**
8
+ * chant#2453 — a declared function is judged by its body, never invoked.
9
+ *
10
+ * `resolveCallExpression` used to catch a fold failure on an imported callee
11
+ * and fall back to importing and invoking it. chant#2441 stopped that rescuing
12
+ * a depth refusal. The external corpus then showed what the rest of it did, in
13
+ * a project nobody here maintains (jhgaylor/infisical-chant, via
14
+ * INTENTIUS/typescript-as-data#129):
15
+ *
16
+ * export const namingParams = namingParamsFromEnv();
17
+ *
18
+ * whose body reads `process.env`. `F-Eval-Ident` step 4 rejects `process`, so
19
+ * the body does not fold — and chant imported the module and ran it, folding
20
+ * the file to whatever the FOLDING PROCESS's environment held.
21
+ *
22
+ * The reason this is a correctness bug and not only a conformance divergence:
23
+ * the file reported `fold`, which reads as "determined statically". Two people
24
+ * folding the same source got different output, and nothing said so. `--fold`
25
+ * is the value you would get by running, without running. This ran.
26
+ */
27
+ describe("a declared function is folded or refused, never invoked (chant#2453)", () => {
28
+ let root: string;
29
+
30
+ beforeEach(() => {
31
+ root = mkdtempSync(join(tmpdir(), "chant-no-invoke-"));
32
+ resetFoldExecutionCounts();
33
+ });
34
+
35
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
36
+
37
+ const write = (name: string, source: string): string => {
38
+ const p = join(root, name);
39
+ writeFileSync(p, source);
40
+ return p;
41
+ };
42
+
43
+ test("an ambient read inside an imported function refuses, rather than folding the shell's environment in", async () => {
44
+ const params = write(
45
+ "params.ts",
46
+ 'export function namingParamsFromEnv() {\n return { prefix: process.env.CHANT_TEST_PREFIX ?? "fallback" };\n}\n',
47
+ );
48
+ const app = write(
49
+ "app.ts",
50
+ 'import { namingParamsFromEnv } from "./params";\nexport const namingParams = namingParamsFromEnv();\n',
51
+ );
52
+
53
+ process.env.CHANT_TEST_PREFIX = "leaked-from-the-test-runner";
54
+ try {
55
+ const verdict = (await foldProject([app, params], [], {})).get(app)!;
56
+
57
+ // Before the fix this was `fold` with prefix "leaked-from-the-test-runner".
58
+ expect(verdict.verdict).toBe("run");
59
+ expect(verdict.reason).toContain("namingParamsFromEnv");
60
+
61
+ // The counter is the direct evidence: nothing of the project was run.
62
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
63
+ } finally {
64
+ delete process.env.CHANT_TEST_PREFIX;
65
+ }
66
+ });
67
+
68
+ test("the folded output never depends on the environment, which is the property at stake", async () => {
69
+ const params = write(
70
+ "params.ts",
71
+ 'export function fromEnv() {\n return { v: process.env.CHANT_TEST_SWING ?? "d" };\n}\n',
72
+ );
73
+ const app = write("app.ts", 'import { fromEnv } from "./params";\nexport const c = fromEnv();\n');
74
+
75
+ // Fold the same source twice under different environments. Before the fix
76
+ // these disagreed, which is the part no reader of a `fold` verdict could
77
+ // have known.
78
+ process.env.CHANT_TEST_SWING = "one";
79
+ const first = (await foldProject([app, params], [], {})).get(app)!;
80
+ process.env.CHANT_TEST_SWING = "two";
81
+ const second = (await foldProject([app, params], [], {})).get(app)!;
82
+ delete process.env.CHANT_TEST_SWING;
83
+
84
+ expect(first.verdict).toBe(second.verdict);
85
+ expect(first.verdict).toBe("run");
86
+ });
87
+
88
+ test("a function whose body does fold still folds, so this narrows nothing it should not", async () => {
89
+ const helper = write(
90
+ "helper.ts",
91
+ 'export function joined() {\n const parts = ["a", "b"];\n return { joined: parts.join("-") };\n}\n',
92
+ );
93
+ const app = write("app.ts", 'import { joined } from "./helper";\nexport const v = joined();\n');
94
+
95
+ const verdict = (await foldProject([app, helper], [], {})).get(app)!;
96
+
97
+ expect(verdict.verdict).toBe("fold");
98
+ expect(JSON.parse(JSON.stringify(Object.fromEntries(verdict.exports!)))).toEqual({
99
+ v: { joined: "a-b" },
100
+ });
101
+ // Folded by interpretation, not by running it.
102
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
103
+ });
104
+
105
+ test("a same-file callee behaves the same as an imported one", async () => {
106
+ // Before the fix these two differed for no reason a reader could defend: a
107
+ // same-file callee had nothing to import, so its rejection was the verdict,
108
+ // while the identical function one file over got invoked instead.
109
+ const app = write(
110
+ "app.ts",
111
+ 'function fromEnv() {\n return { v: process.env.CHANT_TEST_SAME ?? "d" };\n}\nexport const c = fromEnv();\n',
112
+ );
113
+
114
+ const verdict = (await foldProject([app], [], {})).get(app)!;
115
+
116
+ expect(verdict.verdict).toBe("run");
117
+ });
118
+ });
package/src/graph-ir.ts CHANGED
@@ -725,7 +725,7 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
725
725
  for (const observation of observations) {
726
726
  for (const edge of observation.edges ?? []) {
727
727
  if (!observedIds.has(edge.from) || !observedIds.has(edge.to)) continue;
728
- const key = `${edge.from}${edge.to}${edge.viaAttr ?? ""}${edge.toAttr ?? ""}`;
728
+ const key = `${edge.from}\u0000${edge.to}\u0000${edge.viaAttr ?? ""}\u0000${edge.toAttr ?? ""}`;
729
729
  if (seen.has(key)) continue;
730
730
  seen.add(key);
731
731
  edges.push(edge);
Binary file
@@ -42,6 +42,7 @@
42
42
  * `"resolution"` is the default reading).
43
43
  */
44
44
  import { sortedJsonReplacer } from "../utils";
45
+ import { currentGateOrigin, type GateOrigin } from "./gate-origin";
45
46
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
46
47
 
47
48
  const DIR = "_gates";
@@ -138,6 +139,27 @@ export interface GateResolutionRecord {
138
139
  * such a record proves is that somebody approved *something*.
139
140
  */
140
141
  planDigest?: string;
142
+ /**
143
+ * The channel this resolution was authored on (chant#2384) — set by the
144
+ * writer, never by the caller. `chant approve` records `"cli"`, the
145
+ * `op-approve` MCP tool records `"mcp"`, an ACP-driven approve records
146
+ * `"acp"`.
147
+ *
148
+ * Absent on every resolution written before chant#2384, and absent is not
149
+ * `"cli"`: an old record simply does not say. `sameOriginRefusal`
150
+ * (./gate-origin.ts) refuses only on a positive match, so an unlabelled
151
+ * record is never refused on this ground.
152
+ */
153
+ origin?: GateOrigin;
154
+ /**
155
+ * This resolution was recorded from the same channel that reached the gate,
156
+ * deliberately (chant#2384's `--allow-same-origin`).
157
+ *
158
+ * On the record rather than only in the console, because the point of the
159
+ * refusal is that someone chose to bypass it. A reader auditing the ledger
160
+ * later should see which approvals had a second party and which did not.
161
+ */
162
+ sameOriginOverride?: boolean;
141
163
  }
142
164
 
143
165
  export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">;
@@ -183,6 +205,13 @@ export interface PendingGateRecord {
183
205
  * step with no `plan`), which is the shape every gate had before #2300.
184
206
  */
185
207
  planDigest?: string;
208
+ /**
209
+ * The channel the run that reached this gate was driven from (chant#2384).
210
+ *
211
+ * This is the half a resolution is compared against: a gate reached over MCP
212
+ * and resolved over MCP has one author, not two.
213
+ */
214
+ origin?: GateOrigin;
186
215
  }
187
216
 
188
217
  export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
@@ -233,7 +262,16 @@ export async function appendPendingGate(
233
262
  input: PendingGateInput,
234
263
  opts?: { cwd?: string },
235
264
  ): Promise<{ commit: string; record: PendingGateRecord }> {
236
- const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
265
+ // chant#2384 — stamped here rather than at each caller, because every pending
266
+ // fact goes through this function and a channel is a property of the process
267
+ // rather than of the call. An explicit `origin` on the input still wins, so a
268
+ // caller that knows better can say so.
269
+ const record: PendingGateRecord = {
270
+ version: 1,
271
+ kind: "pending",
272
+ origin: currentGateOrigin(),
273
+ ...input,
274
+ };
237
275
  const commit = await appendGateLine(record, "Pending gate record", opts);
238
276
  return { commit, record };
239
277
  }
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect, afterEach } from "vitest";
2
+ import {
3
+ currentGateOrigin,
4
+ setGateOrigin,
5
+ resetGateOrigin,
6
+ isModelAuthored,
7
+ sameOriginRefusal,
8
+ UNATTESTED_APPROVER,
9
+ } from "./gate-origin";
10
+
11
+ /**
12
+ * chant#2384 — the gate's two halves must have different authors.
13
+ *
14
+ * The gate as a durable, plan-bound fact is the strongest thing chant says
15
+ * about agent-driven change: a run reaching an unapproved gate records a
16
+ * pending fact and exits 3, and since #2300 a resolution counts only for the
17
+ * plan it names. All of that rests on the run and the approval being authored
18
+ * by different parties.
19
+ *
20
+ * At a shell they are, and the ledger's existing stance is right there: anyone
21
+ * who can run `chant approve` can also run `chant run`, the same trust boundary
22
+ * a local commit has. On MCP and ACP it stops being right, because the person's
23
+ * only act was launching the server — `op-run` returns the gate it stopped on
24
+ * and `op-approve` resolves it, both authored by the same model in the same
25
+ * session, and #2300's plan binding does not close it because the digest comes
26
+ * off the pending fact that same caller produced one tool call earlier.
27
+ */
28
+ describe("gate origin (chant#2384)", () => {
29
+ afterEach(() => resetGateOrigin());
30
+
31
+ test("the process serves one channel, and it is the CLI unless an entry point says otherwise", () => {
32
+ expect(currentGateOrigin()).toBe("cli");
33
+ setGateOrigin("mcp");
34
+ expect(currentGateOrigin()).toBe("mcp");
35
+ resetGateOrigin();
36
+ expect(currentGateOrigin()).toBe("cli");
37
+ });
38
+
39
+ test("only MCP and ACP are model-authored", () => {
40
+ expect(isModelAuthored("mcp")).toBe(true);
41
+ expect(isModelAuthored("acp")).toBe(true);
42
+ // The distinction the whole rule rests on: at a shell a person typed each
43
+ // command, so the two halves already have different authors.
44
+ expect(isModelAuthored("cli")).toBe(false);
45
+ expect(isModelAuthored(undefined)).toBe(false);
46
+ });
47
+
48
+ describe("the same-origin rule", () => {
49
+ test("refuses a gate reached and resolved on the same model-authored channel", () => {
50
+ expect(sameOriginRefusal("mcp", "mcp")).toContain("the same caller wrote both halves");
51
+ expect(sameOriginRefusal("acp", "acp")).toContain("the same caller wrote both halves");
52
+ });
53
+
54
+ test("allows run-then-approve at a shell, which is the intended workflow", () => {
55
+ // Both halves are `cli` and that is fine. This is the case the issue is
56
+ // explicit about keeping: `chant approve` typed at a shell followed by
57
+ // `chant run` still walks through.
58
+ expect(sameOriginRefusal("cli", "cli")).toBeUndefined();
59
+ });
60
+
61
+ test("allows a model's run approved by a person, which is the separation the gate is for", () => {
62
+ expect(sameOriginRefusal("mcp", "cli")).toBeUndefined();
63
+ expect(sameOriginRefusal("acp", "cli")).toBeUndefined();
64
+ });
65
+
66
+ test("allows a person's run approved over MCP, since a person still authored one half", () => {
67
+ expect(sameOriginRefusal("cli", "mcp")).toBeUndefined();
68
+ });
69
+
70
+ test("refuses across the two model channels only when they are the same one", () => {
71
+ // Distinct model channels are two sessions, not one caller writing both
72
+ // halves — so this is permitted, and deliberately so.
73
+ expect(sameOriginRefusal("mcp", "acp")).toBeUndefined();
74
+ expect(sameOriginRefusal("acp", "mcp")).toBeUndefined();
75
+ });
76
+
77
+ test("an unlabelled pending fact is never refused, so old ledgers keep working", () => {
78
+ // Every record written before this change has no origin. Absent is not
79
+ // "cli" and not a wildcard: the rule fires on a positive match only, so a
80
+ // pre-#2384 pending fact cannot start refusing approvals retroactively.
81
+ expect(sameOriginRefusal(undefined, "mcp")).toBeUndefined();
82
+ expect(sameOriginRefusal(undefined, "acp")).toBeUndefined();
83
+ expect(sameOriginRefusal(undefined, "cli")).toBeUndefined();
84
+ });
85
+ });
86
+
87
+ test("the unattested approver is a fixed marker, not a name anyone chose", () => {
88
+ // `op-approve` took a free-text `approver` on a channel that cannot verify
89
+ // one, so the model named itself whatever it liked and the ledger recorded
90
+ // it indistinguishably from a name a person gave.
91
+ expect(UNATTESTED_APPROVER).toBe("unattested");
92
+ });
93
+ });