@intentius/chant 0.72.2 → 0.72.4
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/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- 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 +10 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp-config.d.ts +47 -0
- package/dist/cli/mcp-config.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +12 -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/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.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/dist/observation.d.ts +0 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/converge.d.ts +16 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +1 -1
- package/src/cli/commands/doctor.ts +18 -6
- package/src/cli/commands/init.ts +9 -68
- package/src/cli/commands/update.ts +9 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/handlers/operator.ts +48 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/docs-parity.test.ts +133 -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 +16 -1
- package/src/cli/mcp-config.test.ts +168 -0
- package/src/cli/mcp-config.ts +76 -0
- package/src/cli/registry.ts +12 -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/discovery/sandbox/fork-diagnostic.test.ts +117 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- 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
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- package/src/op/activities/converge.ts +31 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { runFallbackFilesSandboxed } from "./run";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* chant#2461 — a child that ends without a usable result says which way it did.
|
|
10
|
+
*
|
|
11
|
+
* The parent had one sentence for three unrelated situations: `child exited
|
|
12
|
+
* before reporting results (code N, signal S)`. The one seen in the wild is the
|
|
13
|
+
* hardest to read — exit code 0, empty stderr, no message — because it reads as
|
|
14
|
+
* if the child was cut off, and it was not. It ran to completion and sent
|
|
15
|
+
* nothing.
|
|
16
|
+
*
|
|
17
|
+
* These tests pin the two mechanisms that produce that signature, against real
|
|
18
|
+
* forked children rather than a mock, because the whole question is what Node
|
|
19
|
+
* actually does:
|
|
20
|
+
*
|
|
21
|
+
* - an `await` that never settles, which drains the loop and exits 0
|
|
22
|
+
* - a payload the parent's `isResponse` refuses, which used to be dropped in
|
|
23
|
+
* silence and then reported as though nothing had been sent
|
|
24
|
+
*
|
|
25
|
+
* A third mechanism was proposed in the issue and is NOT covered, because it
|
|
26
|
+
* was measured and does not happen: `process.send` racing the child's reap so
|
|
27
|
+
* that `exit` is dispatched before an already-queued `message`. With the parent
|
|
28
|
+
* blocked so both were certainly pending, `message` won 60 times out of 60. The
|
|
29
|
+
* live IPC channel keeps the child alive until the payload flushes, and the
|
|
30
|
+
* driver has no `process.exit()` to cut that short. The last test here records
|
|
31
|
+
* that, so the disproof is not lost with the transcript.
|
|
32
|
+
*/
|
|
33
|
+
function runChild(source: string): Promise<{ code: number | null; message: unknown; stderr: string }> {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-fork-diag-"));
|
|
35
|
+
const file = join(dir, "child.mjs");
|
|
36
|
+
writeFileSync(file, source);
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = fork(file, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
|
|
39
|
+
let stderr = "";
|
|
40
|
+
let message: unknown;
|
|
41
|
+
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
42
|
+
child.on("message", (m) => { message = m; });
|
|
43
|
+
child.on("exit", (code) => {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
resolve({ code, message, stderr });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("why a sandboxed child ends without a result (chant#2461)", () => {
|
|
51
|
+
test("an await that never settles exits 0, silently, having sent nothing", async () => {
|
|
52
|
+
// The observed signature, reproduced. `main().catch(...)` catches a
|
|
53
|
+
// REJECTION; a promise that never settles is not one, so the driver's own
|
|
54
|
+
// fatal-payload path never runs either.
|
|
55
|
+
const r = await runChild(
|
|
56
|
+
'async function main() { await new Promise(() => {}); process.send({ ok: true }); }\n' +
|
|
57
|
+
"main().catch((e) => process.send({ fatal: String(e) }));\n",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(r.code).toBe(0);
|
|
61
|
+
expect(r.message).toBeUndefined();
|
|
62
|
+
expect(r.stderr.trim()).toBe("");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a child that sends and falls off the end always gets its message through", async () => {
|
|
66
|
+
// The disproof, kept as a test. If this ever fails, the race the issue
|
|
67
|
+
// proposed is real after all and the diagnostic's wording needs revisiting.
|
|
68
|
+
const results = await Promise.all(
|
|
69
|
+
Array.from({ length: 12 }, () => runChild("process.send({ ok: true });\n")),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
for (const r of results) {
|
|
73
|
+
expect(r.code).toBe(0);
|
|
74
|
+
expect(r.message).toEqual({ ok: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a rejection still reaches the parent, so the fatal path is not what broke", async () => {
|
|
79
|
+
// Establishes the contrast: the driver's catch works. It is specifically an
|
|
80
|
+
// unsettled promise that escapes it.
|
|
81
|
+
const r = await runChild(
|
|
82
|
+
'async function main() { throw new Error("boom"); }\n' +
|
|
83
|
+
"main().catch((e) => process.send({ fatal: String(e) }));\n",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(r.message).toEqual({ fatal: "Error: boom" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a project file with an unsettled top-level await reproduces it end to end", async () => {
|
|
90
|
+
// The run path's real mechanism, not a stand-in. `main()` does
|
|
91
|
+
// `await import(<project file>)` per run-fallback file, module scope is
|
|
92
|
+
// arbitrary project source, and a top level that awaits something which
|
|
93
|
+
// never settles makes that import never complete. Nothing keeps the loop
|
|
94
|
+
// alive, so Node exits 0 having sent nothing.
|
|
95
|
+
//
|
|
96
|
+
// This is what the diagnostic's wording is checked against. Before
|
|
97
|
+
// chant#2461 it said only "child exited before reporting results", which
|
|
98
|
+
// gave a reader nothing to look at.
|
|
99
|
+
const root = mkdtempSync(join(tmpdir(), "chant-tla-"));
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
102
|
+
writeFileSync(
|
|
103
|
+
join(root, "src", "hangs.ts"),
|
|
104
|
+
"await new Promise<void>(() => {});\nexport const never = { reached: true };\n",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const result = await runFallbackFilesSandboxed([join(root, "src", "hangs.ts")], root);
|
|
108
|
+
|
|
109
|
+
const message = result.errors.map((e) => e.message).join("\n");
|
|
110
|
+
expect(message).toContain("child exited before reporting results (code 0, signal null)");
|
|
111
|
+
expect(message).toContain("drained its loop without sending");
|
|
112
|
+
expect(message).toContain("top-level");
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(root, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}, 120_000);
|
|
117
|
+
});
|
|
@@ -125,6 +125,86 @@ function lineBuffered(emit: (line: string) => void) {
|
|
|
125
125
|
* resolve with the first IPC message that satisfies `isResponse` (or reject
|
|
126
126
|
* on crash / timeout / fork error).
|
|
127
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* Why a child ended without a usable result, in terms a reader can act on.
|
|
130
|
+
*
|
|
131
|
+
* chant#2461 — this used to be one sentence, `child exited before reporting
|
|
132
|
+
* results (code N, signal S)`, for three unrelated situations. The one that
|
|
133
|
+
* was observed in the wild is the hardest to read: exit code 0, empty stderr,
|
|
134
|
+
* no message. That reads like the child was cut off, and it was not — it ran
|
|
135
|
+
* to completion and sent nothing.
|
|
136
|
+
*
|
|
137
|
+
* Two mechanisms produce it, and the driver decides which is possible.
|
|
138
|
+
*
|
|
139
|
+
* The first is an `await` that never settles. `main().catch(...)` catches a
|
|
140
|
+
* REJECTION, and a promise that never settles is not one, so if nothing keeps
|
|
141
|
+
* the loop alive Node drains it and exits 0 having sent nothing and written
|
|
142
|
+
* nothing. Nothing else in the process reports that.
|
|
143
|
+
*
|
|
144
|
+
* On the RUN path that is not hypothetical, and it is reproducible: the driver
|
|
145
|
+
* does `await import(<project file>)` for each run-fallback file, module scope
|
|
146
|
+
* is arbitrary project source, and a file whose top level awaits something that
|
|
147
|
+
* never settles makes that import never complete. A fixture doing exactly that
|
|
148
|
+
* yields this error, which is how the wording here was checked rather than
|
|
149
|
+
* guessed.
|
|
150
|
+
*
|
|
151
|
+
* The second is the payload being lost between `process.send` and exit.
|
|
152
|
+
*
|
|
153
|
+
* For the CONFIG driver the first is impossible, which is worth stating because
|
|
154
|
+
* it was the working hypothesis until the bundle was read. Its `await
|
|
155
|
+
* import(configPath)` bundles to `await Promise.resolve().then(() =>
|
|
156
|
+
* (init_chant_config(), chant_config_exports))` — one microtask over
|
|
157
|
+
* synchronous code — and the bundle has no runtime imports, no dynamic
|
|
158
|
+
* `import(`, and exactly one `process.send`. There is nothing there to hang on.
|
|
159
|
+
* So a config child that exits 0 with nothing sent DID send, and the payload
|
|
160
|
+
* did not arrive.
|
|
161
|
+
*
|
|
162
|
+
* The exit/message race originally proposed in chant#2461 is a third thing and
|
|
163
|
+
* is not it: with the parent blocked so a queued payload and the reap were both
|
|
164
|
+
* pending, `message` was dispatched first 60 times out of 60, because the live
|
|
165
|
+
* IPC channel keeps the child alive until the payload flushes.
|
|
166
|
+
*/
|
|
167
|
+
function describeSilentExit(
|
|
168
|
+
label: string,
|
|
169
|
+
code: number | null,
|
|
170
|
+
signal: NodeJS.Signals | null,
|
|
171
|
+
stderrBuf: string,
|
|
172
|
+
unrecognised: readonly unknown[],
|
|
173
|
+
): string {
|
|
174
|
+
const stderr = stderrBuf.trim();
|
|
175
|
+
const head = `${label}: child exited before reporting results (code ${code}, signal ${signal})`;
|
|
176
|
+
|
|
177
|
+
if (unrecognised.length > 0) {
|
|
178
|
+
// It DID send. The parent refused the shape, which is a bug in one of them
|
|
179
|
+
// and not the child dying early.
|
|
180
|
+
const shapes = unrecognised
|
|
181
|
+
.map((m) => (m && typeof m === "object" ? `{${Object.keys(m as object).join(", ")}}` : typeof m))
|
|
182
|
+
.join(", ");
|
|
183
|
+
return (
|
|
184
|
+
`${head}. It sent ${unrecognised.length} message(s) the parent did not recognise (${shapes}), ` +
|
|
185
|
+
`so the payload shape and the parent's check disagree` +
|
|
186
|
+
(stderr ? `: ${stderr}` : "")
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (stderr) return `${head}: ${stderr}`;
|
|
191
|
+
if (code !== 0 || signal !== null) return head;
|
|
192
|
+
|
|
193
|
+
// Exit 0, nothing on stderr, nothing sent. The child finished normally and
|
|
194
|
+
// the parent has nothing. Two mechanisms produce exactly this, and which one
|
|
195
|
+
// it is depends on the driver — see this function's doc.
|
|
196
|
+
return (
|
|
197
|
+
`${head}. It exited cleanly with nothing on stderr and sent no message, so the child drained ` +
|
|
198
|
+
`its loop without sending: something it awaited never settled. On the run path the usual ` +
|
|
199
|
+
`cause is a project file with a top-level \`await\` that does not settle — module scope is ` +
|
|
200
|
+
`arbitrary project source, and an import of such a file never completes. A promise that ` +
|
|
201
|
+
`never settles is not a rejection, so the driver's own \`main().catch\` does not see it ` +
|
|
202
|
+
`either, which is why nothing is written anywhere. The config driver bundles to one ` +
|
|
203
|
+
`microtask over synchronous code with no runtime I/O, so on THAT path nothing can hang and ` +
|
|
204
|
+
`the payload was lost between \`process.send\` and exit instead (chant#2461).`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
128
208
|
export function forkSandboxed<T>(
|
|
129
209
|
options: SandboxForkOptions,
|
|
130
210
|
isResponse: (value: unknown) => value is T,
|
|
@@ -144,6 +224,8 @@ export function forkSandboxed<T>(
|
|
|
144
224
|
|
|
145
225
|
let settled = false;
|
|
146
226
|
let stderrBuf = "";
|
|
227
|
+
/** Messages the child sent that `isResponse` refused — see the `message` handler. */
|
|
228
|
+
const unrecognised: unknown[] = [];
|
|
147
229
|
|
|
148
230
|
const timeout = setTimeout(() => {
|
|
149
231
|
if (settled) return;
|
|
@@ -185,7 +267,15 @@ export function forkSandboxed<T>(
|
|
|
185
267
|
child.stderr?.on("end", () => stderrForwarder.flush());
|
|
186
268
|
|
|
187
269
|
child.on("message", (msg: unknown) => {
|
|
188
|
-
if (settled
|
|
270
|
+
if (settled) return;
|
|
271
|
+
if (!isResponse(msg)) {
|
|
272
|
+
// chant#2461 — remember it rather than dropping it. A child that sent
|
|
273
|
+
// something the parent does not recognise is a different failure from
|
|
274
|
+
// a child that sent nothing, and both used to arrive as "exited before
|
|
275
|
+
// reporting results" with no way to tell them apart.
|
|
276
|
+
unrecognised.push(msg);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
189
279
|
settled = true;
|
|
190
280
|
clearTimeout(timeout);
|
|
191
281
|
// chant #1131 — the child's entire job is to send this one message, so
|
|
@@ -212,11 +302,7 @@ export function forkSandboxed<T>(
|
|
|
212
302
|
if (settled) return;
|
|
213
303
|
settled = true;
|
|
214
304
|
clearTimeout(timeout);
|
|
215
|
-
reject(
|
|
216
|
-
new Error(
|
|
217
|
-
`${label}: child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
|
|
218
|
-
),
|
|
219
|
-
);
|
|
305
|
+
reject(new Error(describeSilentExit(label, code, signal, stderrBuf, unrecognised)));
|
|
220
306
|
});
|
|
221
307
|
});
|
|
222
308
|
}
|
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}
|
|
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);
|
package/src/graph-layout.ts
CHANGED
|
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
|
-
|
|
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
|
+
}
|