@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,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
|
-
|
|
1462
|
-
//
|
|
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
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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}
|
|
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}
|
|
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
|
+
});
|