@stigmer/runner 3.1.6 → 3.1.8
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/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.js +24 -0
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
- package/dist/activities/execute-cursor/approval-state.js +37 -8
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/env-resolver.js +7 -1
- package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
- package/dist/activities/execute-cursor/hook-script.js +66 -21
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +59 -0
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
- package/dist/activities/execute-cursor/message-translator.js +107 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
- package/dist/activities/execute-cursor/turn-boundary.js +46 -8
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +6 -2
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
- package/dist/activities/execute-cursor/workspace-setup.js +22 -12
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/environment.js +7 -1
- package/dist/activities/execute-deep-agent/environment.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
- package/dist/activities/execute-deep-agent/hitl.js +42 -37
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +35 -26
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
- package/dist/activities/execute-deep-agent/setup.js +9 -2
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -1
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +69 -1
- package/dist/client/stigmer-client.js +108 -4
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +22 -0
- package/dist/client/token-claims.js +40 -0
- package/dist/client/token-claims.js.map +1 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +5 -1
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +9 -2
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +4 -2
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -2
- package/dist/runner.js +1 -1
- package/dist/shared/checkpointer/factory.d.ts +11 -7
- package/dist/shared/checkpointer/factory.js +21 -8
- package/dist/shared/checkpointer/factory.js.map +1 -1
- package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
- package/dist/shared/checkpointer/sqlite-saver.js +272 -0
- package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
- package/dist/shared/checkpointer/types.d.ts +8 -4
- package/dist/shared/mcp-oauth-detect.d.ts +53 -0
- package/dist/shared/mcp-oauth-detect.js +99 -0
- package/dist/shared/mcp-oauth-detect.js.map +1 -0
- package/dist/shared/tool-row.js +14 -1
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/platform-dir.d.ts +18 -0
- package/dist/shared/workspace/platform-dir.js +23 -0
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/package.json +3 -3
- package/src/__test-utils__/mock-client.ts +3 -0
- package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
- package/src/activities/discover-mcp-server.ts +26 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
- package/src/activities/execute-cursor/approval-state.ts +75 -11
- package/src/activities/execute-cursor/env-resolver.ts +8 -1
- package/src/activities/execute-cursor/hook-script.ts +66 -21
- package/src/activities/execute-cursor/index.ts +61 -0
- package/src/activities/execute-cursor/message-translator.ts +124 -1
- package/src/activities/execute-cursor/turn-boundary.ts +79 -6
- package/src/activities/execute-cursor/turn-stream.ts +6 -2
- package/src/activities/execute-cursor/workspace-setup.ts +42 -14
- package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
- package/src/activities/execute-deep-agent/environment.ts +8 -1
- package/src/activities/execute-deep-agent/hitl.ts +43 -45
- package/src/activities/execute-deep-agent/index.ts +36 -27
- package/src/activities/execute-deep-agent/setup.ts +15 -2
- package/src/activities/hydrate-workflow-execution.ts +9 -1
- package/src/client/__tests__/stigmer-client.test.ts +239 -2
- package/src/client/__tests__/token-claims.test.ts +45 -0
- package/src/client/stigmer-client.ts +152 -3
- package/src/client/token-claims.ts +42 -0
- package/src/config.ts +16 -3
- package/src/middleware/__tests__/approval-gate.test.ts +8 -1
- package/src/middleware/approval-gate.ts +9 -2
- package/src/runner-manager.ts +22 -8
- package/src/runner.ts +3 -3
- package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
- package/src/shared/__tests__/tool-row.test.ts +35 -0
- package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
- package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
- package/src/shared/checkpointer/factory.ts +25 -8
- package/src/shared/checkpointer/sqlite-saver.ts +384 -0
- package/src/shared/checkpointer/types.ts +8 -4
- package/src/shared/mcp-oauth-detect.ts +112 -0
- package/src/shared/tool-row.ts +14 -1
- package/src/shared/workspace/platform-dir.ts +25 -0
|
@@ -52,8 +52,17 @@ import {
|
|
|
52
52
|
buildApprovalGrants,
|
|
53
53
|
grantToken,
|
|
54
54
|
watchDenialLedger,
|
|
55
|
+
approvalDenials,
|
|
56
|
+
denialKindOf,
|
|
55
57
|
} from "../approval-state.js";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
reconcileDeniedToolCalls,
|
|
60
|
+
clearProvisionalPostDenialNarration,
|
|
61
|
+
collapseRedundantToolCallTwins,
|
|
62
|
+
toolCallIdentityToken,
|
|
63
|
+
detectUnattributedHookBlocks,
|
|
64
|
+
HOOK_BLOCK_ERROR_MARKERS,
|
|
65
|
+
} from "../message-translator.js";
|
|
57
66
|
import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
|
|
58
67
|
import type { WorkspaceBackend } from "../../../shared/workspace/types.js";
|
|
59
68
|
import { generateHookScript } from "../hook-script.js";
|
|
@@ -162,6 +171,54 @@ describe("denial ledger reset/read", () => {
|
|
|
162
171
|
expect(entries[1].input).toBeUndefined();
|
|
163
172
|
expect(entries[2].input).toBeUndefined();
|
|
164
173
|
});
|
|
174
|
+
|
|
175
|
+
it("parses the kind tag, defaulting an absent/garbled kind to approval", async () => {
|
|
176
|
+
const ws = makeWorkspace();
|
|
177
|
+
await resetDenialLedger(ws);
|
|
178
|
+
await writeFile(
|
|
179
|
+
denialLedgerPath(ws),
|
|
180
|
+
// 1) pre-kind format (no kind field), 2) explicit approval, 3) secret,
|
|
181
|
+
// 4) garbled kind (non-string), 5) unknown future kind.
|
|
182
|
+
`{"toolName":"Write","token":"${grantToken("write", "a.txt")}"}\n` +
|
|
183
|
+
`{"toolName":"Shell","token":"${grantToken("shell", "rm x")}","kind":"approval"}\n` +
|
|
184
|
+
`{"toolName":"Write","token":"${grantToken("write", ".env")}","kind":"secret"}\n` +
|
|
185
|
+
`{"toolName":"Write","token":"${grantToken("write", "b.txt")}","kind":42}\n` +
|
|
186
|
+
`{"toolName":"Write","token":"${grantToken("write", "c.txt")}","kind":"quarantine"}\n`,
|
|
187
|
+
"utf-8",
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const entries = await readDenialLedger(ws);
|
|
191
|
+
expect(entries.map((e) => denialKindOf(e))).toEqual([
|
|
192
|
+
"approval", // absent → the pre-kind format
|
|
193
|
+
"approval",
|
|
194
|
+
"secret",
|
|
195
|
+
"approval", // garbled (non-string) degrades to approval
|
|
196
|
+
"quarantine", // an unknown kind is preserved, not coerced
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("approvalDenials selects only the entries allowed to pause the run", async () => {
|
|
201
|
+
const ws = makeWorkspace();
|
|
202
|
+
await resetDenialLedger(ws);
|
|
203
|
+
await writeFile(
|
|
204
|
+
denialLedgerPath(ws),
|
|
205
|
+
`{"toolName":"Write","token":"${grantToken("write", "a.txt")}","kind":"approval"}\n` +
|
|
206
|
+
`{"toolName":"Write","token":"${grantToken("write", ".env")}","kind":"secret"}\n` +
|
|
207
|
+
`{"toolName":"Write","token":"${grantToken("write", "app.log")}","kind":"capture-error"}\n` +
|
|
208
|
+
`{"toolName":"Write","token":"${grantToken("write", "b.txt")}","kind":"fail-closed"}\n` +
|
|
209
|
+
// Pre-kind format counts as approval; an unknown kind must NOT pause.
|
|
210
|
+
`{"toolName":"Shell","token":"${grantToken("shell", "rm x")}"}\n` +
|
|
211
|
+
`{"toolName":"Write","token":"${grantToken("write", "c.txt")}","kind":"quarantine"}\n`,
|
|
212
|
+
"utf-8",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const entries = await readDenialLedger(ws);
|
|
216
|
+
expect(entries).toHaveLength(6);
|
|
217
|
+
expect(approvalDenials(entries).map((e) => e.token)).toEqual([
|
|
218
|
+
grantToken("write", "a.txt"),
|
|
219
|
+
grantToken("shell", "rm x"),
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
165
222
|
});
|
|
166
223
|
|
|
167
224
|
describe("reconcileDeniedToolCalls", () => {
|
|
@@ -1415,7 +1472,7 @@ describe("first-denial stop contract", () => {
|
|
|
1415
1472
|
| {
|
|
1416
1473
|
kind: "tool";
|
|
1417
1474
|
tool: ToolCall;
|
|
1418
|
-
denyToken?: { name: string; token: string };
|
|
1475
|
+
denyToken?: { name: string; token: string; kind?: string };
|
|
1419
1476
|
// When the hook's ledger append lands relative to the loop's read for
|
|
1420
1477
|
// THIS event. "before-read" is the common case (the hook adjudicates as
|
|
1421
1478
|
// the tool_call event surfaces); "after-read" reproduces the production
|
|
@@ -1426,9 +1483,11 @@ describe("first-denial stop contract", () => {
|
|
|
1426
1483
|
|
|
1427
1484
|
// Faithfully mirrors the index.ts loop rule: process each event, then read
|
|
1428
1485
|
// the denial ledger when the fs watcher flagged it dirty OR the event is a
|
|
1429
|
-
// tool_call (the backstop) — the instant
|
|
1430
|
-
// run and stop consuming the stream
|
|
1431
|
-
//
|
|
1486
|
+
// tool_call (the backstop) — the instant an APPROVAL-kind denial appears,
|
|
1487
|
+
// cancel the run and stop consuming the stream (non-approval kinds — a secret
|
|
1488
|
+
// hard-block, a capture error — are attributable but never pause). The
|
|
1489
|
+
// watcher is simulated by flipping the dirty flag as the append happens (the
|
|
1490
|
+
// real fs.watch notification).
|
|
1432
1491
|
async function runTurnWithFirstDenialStop(
|
|
1433
1492
|
hitlDir: string,
|
|
1434
1493
|
events: SimEvent[],
|
|
@@ -1438,12 +1497,13 @@ describe("first-denial stop contract", () => {
|
|
|
1438
1497
|
let cancelled = false;
|
|
1439
1498
|
let consumed = 0;
|
|
1440
1499
|
let ledgerDirty = false;
|
|
1441
|
-
let pendingAppend: { name: string; token: string } | undefined;
|
|
1500
|
+
let pendingAppend: { name: string; token: string; kind?: string } | undefined;
|
|
1442
1501
|
|
|
1443
|
-
const appendDenial = async (deny: { name: string; token: string }) => {
|
|
1502
|
+
const appendDenial = async (deny: { name: string; token: string; kind?: string }) => {
|
|
1503
|
+
const kindField = deny.kind ? `,"kind":"${deny.kind}"` : "";
|
|
1444
1504
|
await writeFile(
|
|
1445
1505
|
denialLedgerPath(hitlDir),
|
|
1446
|
-
`{"toolName":"${deny.name}","token":"${deny.token}"}\n`,
|
|
1506
|
+
`{"toolName":"${deny.name}","token":"${deny.token}"${kindField}}\n`,
|
|
1447
1507
|
{ flag: "a" },
|
|
1448
1508
|
);
|
|
1449
1509
|
ledgerDirty = true;
|
|
@@ -1470,7 +1530,7 @@ describe("first-denial stop contract", () => {
|
|
|
1470
1530
|
}
|
|
1471
1531
|
if (ledgerDirty || ev.kind === "tool") {
|
|
1472
1532
|
ledgerDirty = false;
|
|
1473
|
-
const denials = await readDenialLedger(hitlDir);
|
|
1533
|
+
const denials = approvalDenials(await readDenialLedger(hitlDir));
|
|
1474
1534
|
if (denials.length > 0) {
|
|
1475
1535
|
cancelled = true;
|
|
1476
1536
|
break;
|
|
@@ -1527,6 +1587,30 @@ describe("first-denial stop contract", () => {
|
|
|
1527
1587
|
expect(consumed).toBe(3); // the whole turn is consumed
|
|
1528
1588
|
});
|
|
1529
1589
|
|
|
1590
|
+
it("does NOT stop on a non-approval denial (secret hard-block: the agent continues)", async () => {
|
|
1591
|
+
// The kinded ledger now records a secret hard-block for attribution (issue
|
|
1592
|
+
// #205), and the append fires the fs watcher — but the model was told to
|
|
1593
|
+
// move on, so the run must NOT be cancelled and no approval may surface.
|
|
1594
|
+
const ws = makeWorkspace();
|
|
1595
|
+
const secretWrite = toolCall({
|
|
1596
|
+
id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1597
|
+
error: "blocked by a hook", args: { path: ".env" },
|
|
1598
|
+
});
|
|
1599
|
+
const read = toolCall({ id: "c2", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "a.txt" } });
|
|
1600
|
+
|
|
1601
|
+
const { cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
|
|
1602
|
+
{ kind: "tool", tool: secretWrite, denyToken: { name: "Write", token: grantToken("write", ".env"), kind: "secret" } },
|
|
1603
|
+
{ kind: "text", content: "That file is protected — moving on." },
|
|
1604
|
+
{ kind: "tool", tool: read },
|
|
1605
|
+
]);
|
|
1606
|
+
|
|
1607
|
+
expect(cancelled).toBe(false);
|
|
1608
|
+
expect(consumed).toBe(3); // the whole turn is consumed
|
|
1609
|
+
// And the boundary reconcile manufactures no gate from it either.
|
|
1610
|
+
const denied = await reconcileDeniedToolCalls([], await readDenialLedger(ws));
|
|
1611
|
+
expect(denied).toEqual([]);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1530
1614
|
it("stops on the NEXT event of any type when the denial lands after the tool_call read (the watcher rule)", async () => {
|
|
1531
1615
|
// The production race (aex_01kwj07f7g23c3wp9sn8496z5g): the hook's ledger
|
|
1532
1616
|
// append landed just after the gated tool's own tool_call-event read, so the
|
|
@@ -1594,8 +1678,173 @@ describe("watchDenialLedger", () => {
|
|
|
1594
1678
|
});
|
|
1595
1679
|
});
|
|
1596
1680
|
|
|
1681
|
+
// Issue #205: a FOREIGN `.cursor/hooks.json` hook (preserved by the merge) can
|
|
1682
|
+
// deny the runner's tools without writing our denial ledger; the SDK has no
|
|
1683
|
+
// structured "denied by hook" signal, so the only stream trace is Cursor's
|
|
1684
|
+
// generic hook-block error text on the FAILED call. The detector matches this
|
|
1685
|
+
// turn's hook-blocked FAILED rows against the FULL ledger (all kinds) and
|
|
1686
|
+
// reports the leftovers — which the activity surfaces as EXECUTION_FAILED
|
|
1687
|
+
// instead of the silent completion the issue describes. These pin the whole
|
|
1688
|
+
// attribution matrix.
|
|
1689
|
+
describe("detectUnattributedHookBlocks (issue #205)", () => {
|
|
1690
|
+
// Cursor's generic replacement text for a hook deny (Phase 0 ground truth).
|
|
1691
|
+
const HOOK_BLOCK = "Command blocked by a hook. Check the hooks configuration.";
|
|
1692
|
+
|
|
1693
|
+
const failedShell = (id: string, command: string, error: string = HOOK_BLOCK) =>
|
|
1694
|
+
toolCall({ id, name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED, error, args: { command } });
|
|
1695
|
+
const failedEdit = (id: string, path: string, error: string = HOOK_BLOCK) =>
|
|
1696
|
+
toolCall({ id, name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED, error, args: { path } });
|
|
1697
|
+
|
|
1698
|
+
it("detects a hook-blocked call when the ledger is EMPTY (the reproduce-first #205 shape)", () => {
|
|
1699
|
+
const edit = failedEdit("f1", "notes.md");
|
|
1700
|
+
const blocks = detectUnattributedHookBlocks([aiMessageWith([edit])], 0, []);
|
|
1701
|
+
expect(blocks).toEqual([{ toolCallId: "f1", toolName: "edit", error: HOOK_BLOCK }]);
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
it("matches the marker case-insensitively (single-sourced marker family)", () => {
|
|
1705
|
+
expect(HOOK_BLOCK_ERROR_MARKERS).toContain("blocked by a hook");
|
|
1706
|
+
const edit = failedEdit("f1", "notes.md", "Tool was Blocked By A Hook.");
|
|
1707
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, [])).toHaveLength(1);
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
it("attributes our own denial by exact token — not reported", () => {
|
|
1711
|
+
const shell = failedShell("s1", "rm -rf build");
|
|
1712
|
+
const ledger = [{ toolName: "Shell", token: toolCallIdentityToken(shell), kind: "approval" }];
|
|
1713
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([shell])], 0, ledger)).toEqual([]);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it("attributes a secret hard-block via its kind:'secret' entry — not reported", () => {
|
|
1717
|
+
// The agent continues past a secret block; its FAILED row must never be
|
|
1718
|
+
// misdiagnosed as a foreign hook (the false positive the kinded ledger
|
|
1719
|
+
// exists to prevent).
|
|
1720
|
+
const secretWrite = failedEdit("e1", ".env");
|
|
1721
|
+
const ledger = [{ toolName: "Write", token: toolCallIdentityToken(secretWrite), kind: "secret" }];
|
|
1722
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([secretWrite])], 0, ledger)).toEqual([]);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it("attributes a capture-error deny via its kind:'capture-error' entry — not reported", () => {
|
|
1726
|
+
const write = failedEdit("e1", "app.log");
|
|
1727
|
+
const ledger = [{ toolName: "Write", token: toolCallIdentityToken(write), kind: "capture-error" }];
|
|
1728
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([write])], 0, ledger)).toEqual([]);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
it("attributes across abs-vs-rel path drift via the workspace-normalized salient", () => {
|
|
1732
|
+
// The hook records the RAW absolute path Cursor handed it; the stream row
|
|
1733
|
+
// carries the relative one. Exact tokens differ; the normalized (category,
|
|
1734
|
+
// path) must still attribute — the same drift reconcile's second pass heals.
|
|
1735
|
+
const edit = failedEdit("e1", "notes.md");
|
|
1736
|
+
const ledger = [{ toolName: "Write", token: grantToken("write", `${ROOT}/notes.md`), kind: "secret" }];
|
|
1737
|
+
// Without a workspace root the raw tokens cannot be reconciled…
|
|
1738
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, ledger)).toHaveLength(1);
|
|
1739
|
+
// …with it, the normalized pass attributes the block to our own entry.
|
|
1740
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, ledger, ROOT)).toEqual([]);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it("treats a fail-closed turn as attributed — the broken gate denied everything it saw", () => {
|
|
1744
|
+
// With the state file missing, EVERY gated tool was denied by OUR OWN
|
|
1745
|
+
// (broken) hook; per-call correlation is meaningless and none of it is a
|
|
1746
|
+
// foreign hook. The boundary logs the broken-gate condition separately.
|
|
1747
|
+
const shell = failedShell("s1", "make build");
|
|
1748
|
+
const edit = failedEdit("e1", "other.txt");
|
|
1749
|
+
const ledger = [{ toolName: "Shell", token: grantToken("shell", "make build"), kind: "fail-closed" }];
|
|
1750
|
+
expect(
|
|
1751
|
+
detectUnattributedHookBlocks([aiMessageWith([shell, edit])], 0, ledger),
|
|
1752
|
+
).toEqual([]);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
it("scopes to THIS turn only — seeded prior-turn rows never re-trigger", () => {
|
|
1756
|
+
const priorTurnRow = failedShell("old-1", "yarn deploy");
|
|
1757
|
+
const thisTurnRow = failedShell("new-1", "yarn build");
|
|
1758
|
+
const messages = [aiMessageWith([priorTurnRow]), aiMessageWith([thisTurnRow])];
|
|
1759
|
+
const blocks = detectUnattributedHookBlocks(messages, 1, []);
|
|
1760
|
+
expect(blocks.map((b) => b.toolCallId)).toEqual(["new-1"]);
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
it("mixed turn: reports the foreign block even when our own anchor denial is present", () => {
|
|
1764
|
+
// The caller pauses on the anchor (a pausing turn is never silent) and only
|
|
1765
|
+
// logs this — but the detector itself must still see the foreign block.
|
|
1766
|
+
const ourEdit = failedEdit("ours", "gated.txt");
|
|
1767
|
+
const foreignShell = failedShell("theirs", "terraform apply");
|
|
1768
|
+
const ledger = [{ toolName: "Write", token: toolCallIdentityToken(ourEdit), kind: "approval" }];
|
|
1769
|
+
const blocks = detectUnattributedHookBlocks(
|
|
1770
|
+
[aiMessageWith([ourEdit, foreignShell])], 0, ledger,
|
|
1771
|
+
);
|
|
1772
|
+
expect(blocks.map((b) => b.toolCallId)).toEqual(["theirs"]);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it("ignores ordinary tool failures without hook-block text", () => {
|
|
1776
|
+
const failed = failedShell("s1", "make test", "exit code 2: tests failed");
|
|
1777
|
+
const noError = toolCall({ id: "s2", name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED, error: "", args: { command: "ls" } });
|
|
1778
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([failed, noError])], 0, [])).toEqual([]);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
it("ignores non-FAILED rows even when their output mentions the marker", () => {
|
|
1782
|
+
// Adversarial: the marker appearing in a tool's legitimate RESULT (e.g. the
|
|
1783
|
+
// agent grepped these very words) must never trigger — only a FAILED row's
|
|
1784
|
+
// error text reads as a hook block.
|
|
1785
|
+
const completed = toolCall({
|
|
1786
|
+
id: "c1", name: "shell", status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1787
|
+
result: 'found "blocked by a hook" in docs/hitl.md', args: { command: "grep -r hook docs" },
|
|
1788
|
+
});
|
|
1789
|
+
const waiting = toolCall({
|
|
1790
|
+
id: "w1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, args: { path: "gated.txt" },
|
|
1791
|
+
});
|
|
1792
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([completed, waiting])], 0, [])).toEqual([]);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("reports every distinct foreign block in the turn", () => {
|
|
1796
|
+
const one = failedEdit("f1", "a.txt");
|
|
1797
|
+
const two = failedShell("f2", "rm -rf build");
|
|
1798
|
+
const blocks = detectUnattributedHookBlocks([aiMessageWith([one]), aiMessageWith([two])], 0, []);
|
|
1799
|
+
expect(blocks.map((b) => b.toolCallId)).toEqual(["f1", "f2"]);
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
// The reconcile itself must never manufacture a pause from a non-approval
|
|
1804
|
+
// denial, regardless of caller discipline (defense-in-depth for the kind split).
|
|
1805
|
+
describe("reconcileDeniedToolCalls — non-approval kinds never gate", () => {
|
|
1806
|
+
it("returns no gates and leaves the transcript untouched for secret/capture-error/fail-closed entries", async () => {
|
|
1807
|
+
const secretRow = toolCall({
|
|
1808
|
+
id: "e1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1809
|
+
error: "blocked by a hook", args: { path: ".env" },
|
|
1810
|
+
});
|
|
1811
|
+
const messages = [aiMessageWith([secretRow])];
|
|
1812
|
+
const denied = await reconcileDeniedToolCalls(messages, [
|
|
1813
|
+
{ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" },
|
|
1814
|
+
{ toolName: "Write", token: grantToken("write", "app.log"), kind: "capture-error" },
|
|
1815
|
+
{ toolName: "Shell", token: grantToken("shell", "make"), kind: "fail-closed" },
|
|
1816
|
+
]);
|
|
1817
|
+
expect(denied).toEqual([]);
|
|
1818
|
+
// No overlay, no collapse, no synthesized placeholder appended.
|
|
1819
|
+
expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
1820
|
+
expect(messages).toHaveLength(1);
|
|
1821
|
+
expect(messages[0].toolCalls).toHaveLength(1);
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
it("gates the approval entry while ignoring a non-approval sibling in the same ledger", async () => {
|
|
1825
|
+
const gated = toolCall({
|
|
1826
|
+
id: "g1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1827
|
+
error: "blocked by a hook", args: { path: "gated.txt" },
|
|
1828
|
+
});
|
|
1829
|
+
const secretRow = toolCall({
|
|
1830
|
+
id: "e1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1831
|
+
error: "blocked by a hook", args: { path: ".env" },
|
|
1832
|
+
});
|
|
1833
|
+
const messages = [aiMessageWith([gated, secretRow])];
|
|
1834
|
+
const denied = await reconcileDeniedToolCalls(messages, [
|
|
1835
|
+
// Ledger order puts the secret FIRST: the anchor must still be the first
|
|
1836
|
+
// APPROVAL entry, never a non-approval kind.
|
|
1837
|
+
{ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" },
|
|
1838
|
+
{ toolName: "Write", token: toolCallIdentityToken(gated), kind: "approval" },
|
|
1839
|
+
]);
|
|
1840
|
+
expect(denied).toHaveLength(1);
|
|
1841
|
+
expect(gated.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1842
|
+
expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
1843
|
+
});
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1597
1846
|
describe("generateHookScript ledger wiring", () => {
|
|
1598
|
-
it("bakes the active-turn pointer, derives the ledger from it, and records
|
|
1847
|
+
it("bakes the active-turn pointer, derives the ledger from it, and records on EVERY deny arm", () => {
|
|
1599
1848
|
const script = generateHookScript("/gate/active.json");
|
|
1600
1849
|
|
|
1601
1850
|
// Stable script: it bakes the pointer path (not per-turn state/ledger), and
|
|
@@ -1604,9 +1853,18 @@ describe("generateHookScript ledger wiring", () => {
|
|
|
1604
1853
|
expect(script).not.toContain('LEDGER_FILE="/');
|
|
1605
1854
|
expect(script).toContain('>> "$LEDGER_FILE"');
|
|
1606
1855
|
expect(script).toContain("record_denial()");
|
|
1607
|
-
//
|
|
1608
|
-
//
|
|
1609
|
-
|
|
1610
|
-
expect(
|
|
1856
|
+
// Every deny arm records a kind-tagged entry (issue #205 attribution):
|
|
1857
|
+
// the approval gates (built-in + MCP), both secret hard-block arms, the
|
|
1858
|
+
// CAS staging-error fail-closed deny, and the missing-state-file failsafe.
|
|
1859
|
+
expect(script).toContain('record_denial "$PRIMARY_TOKEN" "approval"');
|
|
1860
|
+
expect(script).toContain('record_denial "$MCP_TOKEN" "approval"');
|
|
1861
|
+
expect(script.split('"secret"').length - 1).toBeGreaterThanOrEqual(2);
|
|
1862
|
+
expect(script).toContain('record_denial "$PRIMARY_TOKEN" "capture-error"');
|
|
1863
|
+
expect(script).toContain('record_denial "$PRIMARY_TOKEN" "fail-closed"');
|
|
1864
|
+
expect(script).toContain('record_denial "$MCP_TOKEN" "fail-closed"');
|
|
1865
|
+
// The hoisting guard: under `set -euo pipefail` with our failClosed
|
|
1866
|
+
// hooks.json registration, a call-before-define would emit no decision and
|
|
1867
|
+
// block EVERY tool — so the definition must precede the first call.
|
|
1868
|
+
expect(script.indexOf("record_denial()")).toBeLessThan(script.indexOf('record_denial "'));
|
|
1611
1869
|
});
|
|
1612
1870
|
});
|
|
@@ -58,6 +58,8 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
58
58
|
// Byte-identical to the runner's primaryToken: content-exact when the hook
|
|
59
59
|
// can compute an edit digest (the write), else the coarse grantToken.
|
|
60
60
|
expect(ledger[0].token).toBe(primaryToken(category, salient, contentDigest(args)));
|
|
61
|
+
// The normal gate is the APPROVAL kind — the only kind that pauses.
|
|
62
|
+
expect(ledger[0].kind).toBe("approval");
|
|
61
63
|
}
|
|
62
64
|
});
|
|
63
65
|
|
|
@@ -88,9 +90,17 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
88
90
|
// through the Cursor substrate adapter alongside the deep-agent substrate. See
|
|
89
91
|
// src/__tests__/approval-gateway-contract.test.ts.
|
|
90
92
|
|
|
91
|
-
it("fails closed (deny) when the state file is missing", () => {
|
|
93
|
+
it("fails closed (deny) when the state file is missing, recorded as kind fail-closed", () => {
|
|
92
94
|
const h = setup({ noStateFile: true });
|
|
93
95
|
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("deny");
|
|
96
|
+
// The broken-gate deny is ATTRIBUTABLE (issue #205): recorded under the
|
|
97
|
+
// primary token with kind "fail-closed" and — like every non-approval
|
|
98
|
+
// kind — content-free (no input field, DD-26).
|
|
99
|
+
const ledger = h.ledger();
|
|
100
|
+
expect(ledger).toHaveLength(1);
|
|
101
|
+
expect(ledger[0].kind).toBe("fail-closed");
|
|
102
|
+
expect(ledger[0].token).toBe(primaryToken("write", "/x/a.txt", contentDigest({ content: "x" })));
|
|
103
|
+
expect(ledger[0]).not.toHaveProperty("input");
|
|
94
104
|
});
|
|
95
105
|
|
|
96
106
|
// MCP gating runs ONLY on the beforeMCPExecution event (preToolUse does not
|
|
@@ -105,6 +115,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
105
115
|
expect(res.raw).toContain("Approve click?");
|
|
106
116
|
expect(h.ledger()).toHaveLength(1);
|
|
107
117
|
expect(h.ledger()[0].token).toBe(grantToken("click", ""));
|
|
118
|
+
expect(h.ledger()[0].kind).toBe("approval");
|
|
108
119
|
});
|
|
109
120
|
|
|
110
121
|
it("denial agent_message frames approval as automatic and never trains ask-in-prose", () => {
|
|
@@ -149,9 +160,16 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
149
160
|
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
150
161
|
});
|
|
151
162
|
|
|
152
|
-
it("fails closed (deny) when the state file is missing", () => {
|
|
163
|
+
it("fails closed (deny) when the state file is missing, recorded as kind fail-closed", () => {
|
|
153
164
|
const h = setup({ noStateFile: true });
|
|
154
165
|
expect(h.decide(hookMcp("click")).permission).toBe("deny");
|
|
166
|
+
// Attributable under the MCP name-token (the identity the stream row
|
|
167
|
+
// computes for an MCP call), content-free like every non-approval kind.
|
|
168
|
+
const ledger = h.ledger();
|
|
169
|
+
expect(ledger).toHaveLength(1);
|
|
170
|
+
expect(ledger[0].kind).toBe("fail-closed");
|
|
171
|
+
expect(ledger[0].token).toBe(grantToken("click", ""));
|
|
172
|
+
expect(ledger[0]).not.toHaveProperty("input");
|
|
155
173
|
});
|
|
156
174
|
|
|
157
175
|
it("does NOT gate the same MCP tool delivered on preToolUse (no double-gating)", () => {
|
|
@@ -422,11 +440,14 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
422
440
|
// Deny-gate secret hard-block (DD-26 #2): with no capture substrate for a write
|
|
423
441
|
// (capture off — the classic deny-gate — or captureIgnored off in a git-no-
|
|
424
442
|
// storage workspace) a secret-like WRITE must NOT surface its content for
|
|
425
|
-
// approval. The hook hard-blocks it with the security message and records
|
|
426
|
-
// ledger entry
|
|
427
|
-
//
|
|
443
|
+
// approval. The hook hard-blocks it with the security message and records a
|
|
444
|
+
// kind:"secret" ledger entry — ATTRIBUTABLE (issue #205: the runner must know
|
|
445
|
+
// this block was ours) but non-pausing (approvalDenials filters it out, so it
|
|
446
|
+
// never becomes an approvable WAITING row) and content-free (DD-26: only the
|
|
447
|
+
// identity token, never the proposed bytes). A non-secret write still
|
|
448
|
+
// deny-gates as kind:"approval", and a delete (content-less) stays gated.
|
|
428
449
|
describe("deny-gate secret hard-block (DD-26 #2)", () => {
|
|
429
|
-
it("hard-blocks a secret-like write
|
|
450
|
+
it("hard-blocks a secret-like write; records an attributable, content-free secret entry", () => {
|
|
430
451
|
const h = setup({}); // captureMode off — the classic deny-gate
|
|
431
452
|
const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
432
453
|
expect(dec.permission).toBe("deny");
|
|
@@ -434,7 +455,12 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
434
455
|
// SECRET_BLOCKED, not APPROVAL_REQUIRED: the model is told to move on.
|
|
435
456
|
expect(dec.raw.toLowerCase()).toContain("nothing was written");
|
|
436
457
|
expect(dec.raw).not.toContain("submitted to the user for approval");
|
|
437
|
-
|
|
458
|
+
const ledger = h.ledger();
|
|
459
|
+
expect(ledger).toHaveLength(1);
|
|
460
|
+
expect(ledger[0].kind).toBe("secret");
|
|
461
|
+
// DD-26 on the raw ledger bytes: no input field, no trace of the content.
|
|
462
|
+
expect(ledger[0]).not.toHaveProperty("input");
|
|
463
|
+
expect(JSON.stringify(ledger[0])).not.toContain("API_KEY");
|
|
438
464
|
});
|
|
439
465
|
|
|
440
466
|
it("hard-blocks a secret-like edit (path-fragment match)", () => {
|
|
@@ -442,7 +468,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
442
468
|
const dec = h.decide(hookEdit(".ssh/id_rsa"));
|
|
443
469
|
expect(dec.permission).toBe("deny");
|
|
444
470
|
expect(dec.raw).toContain("blocked for security");
|
|
445
|
-
expect(h.ledger()).toEqual([]);
|
|
471
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["secret"]);
|
|
446
472
|
});
|
|
447
473
|
|
|
448
474
|
it("still deny-gates a NON-secret write and records its content for approval", () => {
|
|
@@ -451,6 +477,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
451
477
|
expect(dec.permission).toBe("deny");
|
|
452
478
|
expect(dec.raw).toContain("submitted to the user for approval"); // APPROVAL_REQUIRED
|
|
453
479
|
expect(h.ledger().map((e) => e.toolName)).toContain("Write");
|
|
480
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["approval"]);
|
|
454
481
|
});
|
|
455
482
|
|
|
456
483
|
it("a write-category lease does NOT bypass the secret block", () => {
|
|
@@ -459,7 +486,8 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
459
486
|
const dec = h.decide(hookWrite(".env", "SECRET")); // secret is still hard-blocked
|
|
460
487
|
expect(dec.permission).toBe("deny");
|
|
461
488
|
expect(dec.raw).toContain("blocked for security");
|
|
462
|
-
expect(h.ledger()).toEqual([]);
|
|
489
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["secret"]);
|
|
490
|
+
expect(JSON.stringify(h.ledger())).not.toContain("SECRET");
|
|
463
491
|
});
|
|
464
492
|
|
|
465
493
|
it("hard-blocks a secret write in a git workspace with captureIgnored off (no storage)", () => {
|
|
@@ -467,7 +495,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
467
495
|
const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
468
496
|
expect(dec.permission).toBe("deny");
|
|
469
497
|
expect(dec.raw).toContain("blocked for security");
|
|
470
|
-
expect(h.ledger()).toEqual([]);
|
|
498
|
+
expect(h.ledger().map((e) => e.kind)).toEqual(["secret"]);
|
|
471
499
|
});
|
|
472
500
|
|
|
473
501
|
it("does NOT hard-block a secret DELETE (no content; stays deny-gated)", () => {
|
|
@@ -517,19 +545,43 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
517
545
|
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
|
|
518
546
|
});
|
|
519
547
|
|
|
520
|
-
it("hard-blocks a secret-like gitignored write
|
|
548
|
+
it("hard-blocks a secret-like gitignored write; records a non-pausing secret entry", async () => {
|
|
521
549
|
const h = setup({ captureMode: true, captureIgnored: true, gitignored: [".env"] });
|
|
522
550
|
const d = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
523
551
|
expect(d.permission).toBe("deny");
|
|
524
552
|
expect(d.raw).toContain("blocked for security");
|
|
525
|
-
// A secret is NOT approvable:
|
|
526
|
-
//
|
|
527
|
-
|
|
553
|
+
// A secret is NOT approvable: its kind:"secret" entry attributes the block
|
|
554
|
+
// to our own gate (issue #205) but is filtered out of the pause path — it
|
|
555
|
+
// surfaces as DIFF_UNREVIEWABLE instead, and carries no content (DD-26).
|
|
556
|
+
const ledger = h.ledger();
|
|
557
|
+
expect(ledger.map((e) => e.kind)).toEqual(["secret"]);
|
|
558
|
+
expect(ledger[0]).not.toHaveProperty("input");
|
|
559
|
+
expect(JSON.stringify(ledger[0])).not.toContain("API_KEY");
|
|
528
560
|
const obs = await h.observations();
|
|
529
561
|
expect(obs.captured).toEqual([]);
|
|
530
562
|
expect(obs.secretPaths).toEqual([".env"]);
|
|
531
563
|
});
|
|
532
564
|
|
|
565
|
+
it("a CAS staging error fails closed and records a content-free capture-error entry", async () => {
|
|
566
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
567
|
+
// Force the staging script's "error" result: the sidecar dir path is
|
|
568
|
+
// occupied by a regular FILE, so mkdir fails and the blob write throws.
|
|
569
|
+
// (The harness keeps state/ledger/sidecar in .cursor/hooks — its hitlDir
|
|
570
|
+
// analog; see setupCursorHookHarness.)
|
|
571
|
+
writeFileSync(join(h.root, ".cursor", "hooks", "cas-observations"), "not a dir", "utf-8");
|
|
572
|
+
const d = h.decide(hookWrite("app.log", "payload"));
|
|
573
|
+
expect(d.permission).toBe("deny");
|
|
574
|
+
// Denied with the approval message (fail-closed, today's deny-gate text)
|
|
575
|
+
// but recorded as kind "capture-error": attributable, NON-pausing (there
|
|
576
|
+
// is no approval that could make the write reviewable), and content-free
|
|
577
|
+
// (the staging error means secret classification may never have run).
|
|
578
|
+
expect(d.raw).toContain("submitted to the user for approval");
|
|
579
|
+
const ledger = h.ledger();
|
|
580
|
+
expect(ledger.map((e) => e.kind)).toEqual(["capture-error"]);
|
|
581
|
+
expect(ledger[0]).not.toHaveProperty("input");
|
|
582
|
+
expect(JSON.stringify(ledger[0])).not.toContain("payload");
|
|
583
|
+
});
|
|
584
|
+
|
|
533
585
|
it("captures under auto_approve_all too (capture is a turn property, not authorization)", async () => {
|
|
534
586
|
const h = setup({
|
|
535
587
|
captureMode: true,
|
|
@@ -594,12 +646,14 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
594
646
|
expect(obs.captured[0].before).toBeNull();
|
|
595
647
|
});
|
|
596
648
|
|
|
597
|
-
it("hard-blocks a secret-like write
|
|
649
|
+
it("hard-blocks a secret-like write; records a non-pausing, content-free secret entry", async () => {
|
|
598
650
|
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
599
651
|
const dcn = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
600
652
|
expect(dcn.permission).toBe("deny");
|
|
601
653
|
expect(dcn.raw).toContain("blocked for security");
|
|
602
|
-
|
|
654
|
+
const ledger = h.ledger();
|
|
655
|
+
expect(ledger.map((e) => e.kind)).toEqual(["secret"]);
|
|
656
|
+
expect(JSON.stringify(ledger[0])).not.toContain("API_KEY");
|
|
603
657
|
const obs = await h.observations();
|
|
604
658
|
expect(obs.captured).toEqual([]);
|
|
605
659
|
expect(obs.secretPaths).toEqual([".env"]);
|
|
@@ -657,5 +711,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
657
711
|
const ledger = readFileSync(ledgerPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
658
712
|
expect(ledger).toHaveLength(1);
|
|
659
713
|
expect(ledger[0].token).toBe(grantToken("write", "/x/a.txt"));
|
|
714
|
+
// record_denial is pure bash, so the kind tag survives the Node outage too.
|
|
715
|
+
expect(ledger[0].kind).toBe("approval");
|
|
660
716
|
});
|
|
661
717
|
});
|
|
@@ -867,6 +867,71 @@ describe("MessageAccumulator tool call status transitions", () => {
|
|
|
867
867
|
});
|
|
868
868
|
});
|
|
869
869
|
|
|
870
|
+
// The recovery supersede rule (issue #207): TOOL_CALL_INTERRUPTED is
|
|
871
|
+
// server-authored when an execution terminalizes with the call in flight. If
|
|
872
|
+
// that FAILED execution is recovered, the harness checkpoint can re-execute
|
|
873
|
+
// the call under its original call id — so INTERRUPTED is the one settled
|
|
874
|
+
// status the monotonic guard lets live execution evidence supersede in
|
|
875
|
+
// place. Every other terminal status stays immovable.
|
|
876
|
+
describe("recovery supersede of TOOL_CALL_INTERRUPTED", () => {
|
|
877
|
+
function seededInterruptedTranscript(callId: string): AgentMessage[] {
|
|
878
|
+
return [
|
|
879
|
+
create(AgentMessageSchema, {
|
|
880
|
+
type: MessageType.MESSAGE_AI,
|
|
881
|
+
content: "Running a command.",
|
|
882
|
+
toolCalls: [
|
|
883
|
+
create(ToolCallSchema, {
|
|
884
|
+
id: callId,
|
|
885
|
+
name: "Shell",
|
|
886
|
+
status: ToolCallStatus.TOOL_CALL_INTERRUPTED,
|
|
887
|
+
completedAt: "2026-07-08T00:00:00Z",
|
|
888
|
+
}),
|
|
889
|
+
],
|
|
890
|
+
}),
|
|
891
|
+
];
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
it("a replayed lifecycle event advances a seeded INTERRUPTED row to its true outcome", () => {
|
|
895
|
+
const messages = seededInterruptedTranscript("tc-replayed");
|
|
896
|
+
const acc = new MessageAccumulator(messages);
|
|
897
|
+
|
|
898
|
+
acc.processEvent(toolCallEvent("tc-replayed", "Shell", "running"));
|
|
899
|
+
expect(findToolCallById(messages, "tc-replayed")!.status).toBe(
|
|
900
|
+
ToolCallStatus.TOOL_CALL_RUNNING,
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
acc.processEvent(toolCallEvent("tc-replayed", "Shell", "completed", "run-1", { result: "OK" }));
|
|
904
|
+
|
|
905
|
+
expect(countToolCallsWithId(messages, "tc-replayed")).toBe(1);
|
|
906
|
+
const tc = findToolCallById(messages, "tc-replayed")!;
|
|
907
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
908
|
+
expect(tc.result).toBe("OK");
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("a genuinely terminal seeded row (COMPLETED) still refuses to regress", () => {
|
|
912
|
+
const messages: AgentMessage[] = [
|
|
913
|
+
create(AgentMessageSchema, {
|
|
914
|
+
type: MessageType.MESSAGE_AI,
|
|
915
|
+
toolCalls: [
|
|
916
|
+
create(ToolCallSchema, {
|
|
917
|
+
id: "tc-done",
|
|
918
|
+
name: "Shell",
|
|
919
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
920
|
+
result: "final",
|
|
921
|
+
}),
|
|
922
|
+
],
|
|
923
|
+
}),
|
|
924
|
+
];
|
|
925
|
+
const acc = new MessageAccumulator(messages);
|
|
926
|
+
|
|
927
|
+
acc.processEvent(toolCallEvent("tc-done", "Shell", "running"));
|
|
928
|
+
|
|
929
|
+
expect(findToolCallById(messages, "tc-done")!.status).toBe(
|
|
930
|
+
ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
931
|
+
);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
870
935
|
// Issue #179: the live thinking/tool-call trace was starved because the Cursor
|
|
871
936
|
// loop's persist cadence had no force-flush for tool-call lifecycle. The
|
|
872
937
|
// accumulator's isDirty signal is now the force-flush source: it MUST fire on
|