@intentius/chant 0.72.1 → 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.
Files changed (41) hide show
  1. package/dist/cli/handlers/operator.d.ts +16 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +6 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/discovery/fold-import.d.ts +24 -1
  9. package/dist/discovery/fold-import.d.ts.map +1 -1
  10. package/dist/fold/fold.d.ts +15 -1
  11. package/dist/fold/fold.d.ts.map +1 -1
  12. package/dist/fold/subset.d.ts +38 -1
  13. package/dist/fold/subset.d.ts.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/gate-origin.d.ts +77 -0
  19. package/dist/lifecycle/gate-origin.d.ts.map +1 -0
  20. package/package.json +1 -1
  21. package/src/audit/core.ts +1 -1
  22. package/src/cli/handlers/operator.ts +48 -1
  23. package/src/cli/main.ts +4 -0
  24. package/src/cli/mcp/op-approve-origin.test.ts +70 -0
  25. package/src/cli/mcp/op-tools.ts +18 -4
  26. package/src/cli/mcp/server.ts +11 -0
  27. package/src/cli/registry.ts +6 -0
  28. package/src/discovery/fold-counters-public.test.ts +67 -0
  29. package/src/discovery/fold-div-depth.test.ts +104 -0
  30. package/src/discovery/fold-executing-mode.test.ts +115 -0
  31. package/src/discovery/fold-import.ts +88 -5
  32. package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
  33. package/src/fold/fold.ts +23 -4
  34. package/src/fold/subset.ts +38 -1
  35. package/src/graph-ir.ts +1 -1
  36. package/src/graph-layout.ts +0 -0
  37. package/src/index.ts +10 -0
  38. package/src/lifecycle/gate-ledger.ts +39 -1
  39. package/src/lifecycle/gate-origin.test.ts +93 -0
  40. package/src/lifecycle/gate-origin.ts +113 -0
  41. package/src/meta/source-is-text.test.ts +72 -0
@@ -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/fold/fold.ts CHANGED
@@ -383,8 +383,22 @@ export class FoldError extends Error {
383
383
  readonly line: number;
384
384
  readonly column: number;
385
385
  readonly ruleId: SubsetRuleId;
386
+ /**
387
+ * This refusal is `F-Div-Depth` (chant#2441), not an ordinary fold failure.
388
+ *
389
+ * The distinction is load-bearing rather than informational. An ordinary
390
+ * failure to fold an imported call is allowed to fall back to importing and
391
+ * invoking the callee, which is `resolveCallExpression`'s pre-#1373 arm and
392
+ * is right for a helper that wraps a composite call. A depth refusal must
393
+ * NOT take that arm: the specification says the folder declines here, and
394
+ * invoking instead produces the envelope the fixture's own note warns about
395
+ * — a value the file's own declarators never produced. Unlike every other
396
+ * `F-Div` row this one is not a fallback, so papering over it is wrong
397
+ * output rather than a safe degradation.
398
+ */
399
+ readonly refusedAtDepth: boolean;
386
400
 
387
- constructor(message: string, line: number, column: number, ruleId: SubsetRuleId = "EVL001") {
401
+ constructor(message: string, line: number, column: number, ruleId: SubsetRuleId = "EVL001", refusedAtDepth = false) {
388
402
  const prevStackTraceLimit = Error.stackTraceLimit;
389
403
  Error.stackTraceLimit = 0;
390
404
  super(`${line}:${column} - ${message}`);
@@ -393,6 +407,7 @@ export class FoldError extends Error {
393
407
  this.line = line;
394
408
  this.column = column;
395
409
  this.ruleId = ruleId;
410
+ this.refusedAtDepth = refusedAtDepth;
396
411
  }
397
412
  }
398
413
 
@@ -690,7 +705,7 @@ const MAX_FUNCTION_CALL_DEPTH = 32;
690
705
 
691
706
  function insideFunctionBody(node: ts.Node, what: string): FoldError | undefined {
692
707
  if (functionBodyDepth === 0) return undefined;
693
- return foldError(node, `${what} inside a folded function body is not foldable`);
708
+ return foldError(node, `${what} inside a folded function body is not foldable`, "EVL001", true);
694
709
  }
695
710
 
696
711
  function fileLabel(file: string): string {
@@ -769,6 +784,10 @@ function callFoldableFunction(
769
784
  node,
770
785
  `${label} is not foldable: ${fileLabel(callee.file)}:${err.line}:${err.column} - ${reason}`,
771
786
  err.ruleId,
787
+ // chant#2441 — the refusal happened inside the callee's body and this
788
+ // frame is the CALL site, so without carrying the flag the caller sees
789
+ // an ordinary failure and falls back to invoking.
790
+ err.refusedAtDepth,
772
791
  );
773
792
  } finally {
774
793
  functionBodyDepth -= 1;
@@ -823,9 +842,9 @@ export function locate(node: ts.Node): { line: number; column: number } {
823
842
  return { line: line + 1, column: character + 1 };
824
843
  }
825
844
 
826
- function foldError(node: ts.Node, message: string, ruleId: SubsetRuleId = "EVL001"): FoldError {
845
+ function foldError(node: ts.Node, message: string, ruleId: SubsetRuleId = "EVL001", refusedAtDepth = false): FoldError {
827
846
  const { line, column } = locate(node);
828
- return new FoldError(message, line, column, ruleId);
847
+ return new FoldError(message, line, column, ruleId, refusedAtDepth);
829
848
  }
830
849
 
831
850
  /**
@@ -178,8 +178,45 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
178
178
  *
179
179
  * `spec/VERSION` in the specification repository carries the same string, and
180
180
  * the conformance adapter reads this one to fill its `specVersion` field.
181
+ *
182
+ * ## Why 1.6, and what each version since 1.0 asked for (chant#2445)
183
+ *
184
+ * Raising this is the last step of adopting a rule set, so each version was
185
+ * checked against what chant does rather than against whether the suite is
186
+ * green:
187
+ *
188
+ * - **1.1** added the two profiles. Its own changelog says nothing in `full`
189
+ * changed, so an implementation of 1.0 implements 1.1's `full` unchanged.
190
+ * - **1.2** added `S-LocalFunction`, which chant already folded in both forms,
191
+ * and `S-CallLocal`, which chant's classifier rejected until `chant-v0.72.0`
192
+ * (#2435). `S-ExportDefault` is `data-host`; the profile table records it in
193
+ * `full` as permitted, not required.
194
+ * - **1.3** dropped the number from `F-Eval-CallLocal` step 2, which chant
195
+ * satisfies by reporting the engine's stack overflow as a fallback under
196
+ * `F-Depth`, and clarified `F-Eval-Ident` step 3 with the reading both
197
+ * implementations already had.
198
+ * - **1.4** added the `F-Rule-*` family, extracted from chant's own
199
+ * post-synthesis engine. The conformance bridge answers all five fixtures
200
+ * with no disagreements.
201
+ * - **1.5** added `F-Val-Source`, and this is the one that looks like a gap and
202
+ * is not. The rule says that for every value of the domain there is source in
203
+ * the subset whose fold is that value: a property of the subset, not a demand
204
+ * that an implementation ship a generator. The round trip's fidelity half is
205
+ * named in the specification's README as an obligation on generators and
206
+ * explicitly not a rule. Inventory rows `L12.1` to `L12.4` record chant's
207
+ * generators against it. The conformance adapter's unanswered `generate`
208
+ * hook is a harness capability, not a rule chant fails.
209
+ * - **1.6** was written from chant's behaviour and no further: `F-Call` step 7
210
+ * binding whatever an invoked factory returns is what chant never checked
211
+ * (`L8.19`), and a declarator reaching a call through a const alias is what
212
+ * chant resolves while refusing a call nested elsewhere (`L2.18`).
213
+ *
214
+ * `scripts/check-docs-citations.ts` refuses to run when this constant and the
215
+ * pinned specification disagree, so `.github/workflows/docs-check.yml` pins the
216
+ * `spec-1.6` commit and the two move together. That coupling is deliberate: it
217
+ * is what stops the docs being gated against a rule set nobody writes against.
181
218
  */
182
- export const SPEC_VERSION = "1.0";
219
+ export const SPEC_VERSION = "1.6";
183
220
 
184
221
  /** The two EVL rule ids a shape violation can be attributed to. */
185
222
  export type SubsetRuleId = "EVL001" | "EVL003";
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
package/src/index.ts CHANGED
@@ -61,6 +61,16 @@ export {
61
61
  type FoldProjectVerdict,
62
62
  type TaintPlan,
63
63
  type TaintEdgeKind,
64
+ // F-Obs-Counters' three integers: what a build invoked, what it invoked from
65
+ // project code, and what it interpreted instead. Already exported from the
66
+ // module; on the public entry so a conformance harness can report them per
67
+ // build (chant#2446, INTENTIUS/typescript-as-data#121). The counters are
68
+ // process-wide and monotonic, so a per-build figure means calling
69
+ // `resetFoldExecutionCounts()` first — which is why both travel together and
70
+ // neither is useful alone.
71
+ foldExecutionCounts,
72
+ resetFoldExecutionCounts,
73
+ type FoldExecutionCounts,
64
74
  } from "./discovery/fold-import";
65
75
  export * from "./lint/parser";
66
76
  export * from "./lint/rule";
@@ -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
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Which channel a gate fact was authored on — chant#2384.
3
+ *
4
+ * The gate is the strongest thing chant says about agent-driven change: a run
5
+ * that reaches an unapproved gate records a pending fact and exits 3, and since
6
+ * #2300 a resolution counts only for the plan it names. That property rests on
7
+ * the two halves being authored by different parties.
8
+ *
9
+ * `gate-ledger.ts` already states the trust boundary, and is right about it:
10
+ *
11
+ * > this record is *not* itself an authorization check — anyone who can run
12
+ * > `chant approve` locally can write one, the same trust boundary a local
13
+ * > commit already has.
14
+ *
15
+ * That holds at a shell. A person who can run `chant approve` can also run
16
+ * `chant run`, and the ledger records what they did. It stops holding on MCP
17
+ * and ACP, because the person's only act was launching the server once; every
18
+ * call after that is authored by the model. `op-run` returns the gate it
19
+ * stopped on and `op-approve` resolves it, so the same caller writes both
20
+ * halves and the separation is gone.
21
+ *
22
+ * The ledger could not tell those situations apart, because both produce a
23
+ * `resolvedBy` string with no record of where it came from. This records the
24
+ * where.
25
+ *
26
+ * ## Why an ambient value rather than a parameter
27
+ *
28
+ * A channel is a property of the process, not of a call. A `chant mcp` server
29
+ * is launched once by a person and then serves a model for its lifetime; there
30
+ * is no call on it that is not model-authored. So the origin is established at
31
+ * the entry point and read by whoever writes a ledger line, rather than
32
+ * threaded through every runtime, executor and step that sits between them.
33
+ *
34
+ * This is provenance, not authentication. It records the door a fact came
35
+ * through. It does not prove who was behind it, and nothing here should be read
36
+ * as if it did — see {@link UNATTESTED_APPROVER}.
37
+ */
38
+
39
+ /** A channel a gate fact can be authored on. */
40
+ export type GateOrigin = "cli" | "mcp" | "acp";
41
+
42
+ /**
43
+ * Channels where the caller is a model rather than a person.
44
+ *
45
+ * The distinction this type exists to draw. On `cli` a human typed each
46
+ * command and the existing trust boundary is the right one. On `mcp` and `acp`
47
+ * a human launched a server and a model authored everything after, so a gate
48
+ * resolved from the same channel that produced it has no second party in it.
49
+ */
50
+ const MODEL_AUTHORED: ReadonlySet<GateOrigin> = new Set<GateOrigin>(["mcp", "acp"]);
51
+
52
+ /** Whether a channel's calls are authored by a model rather than by a person. */
53
+ export function isModelAuthored(origin: GateOrigin | undefined): boolean {
54
+ return origin !== undefined && MODEL_AUTHORED.has(origin);
55
+ }
56
+
57
+ /**
58
+ * What `resolvedBy` says for a resolution recorded on a channel that cannot
59
+ * attest to a name.
60
+ *
61
+ * `op-approve` used to take a free-text `approver` and write it down. Recording
62
+ * a name the model chose is worse than recording nothing, because it reads in
63
+ * the ledger exactly like a name a person gave. This is the honest value.
64
+ */
65
+ export const UNATTESTED_APPROVER = "unattested";
66
+
67
+ let ambient: GateOrigin = "cli";
68
+
69
+ /**
70
+ * Declare the channel this process serves. Called once at an entry point, not
71
+ * per call: `chant mcp` sets `"mcp"` before it serves anything, `chant acp`
72
+ * sets `"acp"`, and the CLI leaves the default.
73
+ */
74
+ export function setGateOrigin(origin: GateOrigin): void {
75
+ ambient = origin;
76
+ }
77
+
78
+ /** The channel this process serves. `"cli"` unless an entry point said otherwise. */
79
+ export function currentGateOrigin(): GateOrigin {
80
+ return ambient;
81
+ }
82
+
83
+ /** Restore the default. For tests, which must not leak a channel into each other. */
84
+ export function resetGateOrigin(): void {
85
+ ambient = "cli";
86
+ }
87
+
88
+ /**
89
+ * Why a resolution from this origin cannot answer a pending fact from the same
90
+ * one, or `undefined` when it can.
91
+ *
92
+ * The rule, and the two cases it deliberately leaves alone:
93
+ *
94
+ * - Same channel, model-authored. Refused. `op-run` produced the pending
95
+ * fact and `op-approve` would resolve it, both authored by the same model
96
+ * in the same session. Nothing about that is an approval.
97
+ * - Same channel, `cli`. Allowed. A person ran `chant run`, read the plan and
98
+ * ran `chant approve`. That is the intended workflow and the trust boundary
99
+ * the ledger already documents.
100
+ * - Different channels. Allowed, and the point: a model's run approved by a
101
+ * person at a shell is exactly the separation the gate is for.
102
+ */
103
+ export function sameOriginRefusal(
104
+ pendingOrigin: GateOrigin | undefined,
105
+ resolutionOrigin: GateOrigin,
106
+ ): string | undefined {
107
+ if (!isModelAuthored(resolutionOrigin)) return undefined;
108
+ if (pendingOrigin !== resolutionOrigin) return undefined;
109
+ return (
110
+ `the gate was reached over ${resolutionOrigin} and this resolution arrived over ${resolutionOrigin} too, ` +
111
+ "so the same caller wrote both halves"
112
+ );
113
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Every shipped source file is text a text tool can read.
8
+ *
9
+ * chant#2280 — `graph-ir.ts` carried three literal NUL bytes inside a template
10
+ * literal, used as separators in a composite key. The value was right and the
11
+ * code worked; what broke was everything that reads the file as text. ripgrep
12
+ * classifies a file containing NUL as binary, so it answers `binary file
13
+ * matches (found a NUL byte around offset 32631)` instead of the matching
14
+ * lines, and a repo-wide search does not list the file at all. 911 lines were
15
+ * invisible to every grep-driven search of this repository for as long as the
16
+ * bytes were there.
17
+ *
18
+ * That is worse than a silent bug, because it is a silent bug in the tool you
19
+ * would use to find bugs. Any audit, any "I searched the codebase", any
20
+ * refactor that greps for a symbol defined in that file quietly skipped it and
21
+ * reported success.
22
+ *
23
+ * The fix is not to stop using NUL as a separator — it is a good one, being the
24
+ * character that cannot appear in an identifier or an attribute name. It is to
25
+ * write it as an escape, four ASCII characters in the file and the same single
26
+ * code unit at runtime.
27
+ */
28
+ const SRC = fileURLToPath(new URL("../", import.meta.url));
29
+
30
+ /** Every TypeScript file under core's src, tests and fixtures included. */
31
+ function sourceFiles(dir: string): string[] {
32
+ return readdirSync(dir).flatMap((entry) => {
33
+ const path = join(dir, entry);
34
+ if (statSync(path).isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path);
35
+ return /\.(ts|mts|cts)$/.test(entry) ? [path] : [];
36
+ });
37
+ }
38
+
39
+ describe("source files are text, not binary (chant#2280)", () => {
40
+ test("no shipped source file contains a NUL byte", () => {
41
+ const offenders: string[] = [];
42
+ for (const file of sourceFiles(SRC)) {
43
+ const buf = readFileSync(file);
44
+ const at = buf.indexOf(0);
45
+ if (at !== -1) offenders.push(`${relative(SRC, file)} (first at byte ${at})`);
46
+ }
47
+
48
+ expect(
49
+ offenders,
50
+ "a NUL byte makes ripgrep treat the file as binary, so it reports a " +
51
+ "binary-file-matches line instead of the matches and a repo-wide search omits " +
52
+ "the file entirely. Write the character as a unicode escape instead — same " +
53
+ "value at runtime, plain ASCII in the file.",
54
+ ).toEqual([]);
55
+ });
56
+
57
+ test("the composite edge key still separates on NUL, which is the point of using it", () => {
58
+ // Guards the fix rather than the file: the escape must denote the same
59
+ // character the literal byte did, or the separator silently becomes
60
+ // something that CAN occur in an identifier and distinct edges collide.
61
+ const source = readFileSync(join(SRC, "graph-ir.ts"), "utf8");
62
+ const key = source.split("\n").find((l) => l.includes("const key = `${edge.from}"));
63
+ expect(key, "the composite edge key moved; check its separator is still NUL").toBeDefined();
64
+ expect(key).toContain("\\u0000");
65
+
66
+ // And the escape really is the NUL character, not a look-alike. Built with
67
+ // fromCharCode so this file stays plain ASCII and does not fail its own gate.
68
+ const nul = String.fromCharCode(0);
69
+ expect(`a${nul}b`.charCodeAt(1)).toBe(0);
70
+ expect(`a${nul}b`.split(nul)).toEqual(["a", "b"]);
71
+ });
72
+ });