@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.
- package/dist/cli/handlers/operator.d.ts +16 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +24 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-origin.d.ts +77 -0
- package/dist/lifecycle/gate-origin.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/audit/core.ts +1 -1
- package/src/cli/handlers/operator.ts +48 -1
- package/src/cli/main.ts +4 -0
- package/src/cli/mcp/op-approve-origin.test.ts +70 -0
- package/src/cli/mcp/op-tools.ts +18 -4
- package/src/cli/mcp/server.ts +11 -0
- package/src/cli/registry.ts +6 -0
- package/src/discovery/fold-executing-mode.test.ts +115 -0
- package/src/discovery/fold-import.ts +87 -14
- package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
- package/src/graph-ir.ts +1 -1
- package/src/graph-layout.ts +0 -0
- package/src/lifecycle/gate-ledger.ts +39 -1
- package/src/lifecycle/gate-origin.test.ts +93 -0
- package/src/lifecycle/gate-origin.ts +113 -0
- package/src/meta/source-is-text.test.ts +72 -0
|
@@ -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
|
+
});
|