@stigmer/runner 3.1.7 → 3.1.9
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 +1 -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/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 +1 -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__/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/tool-row.ts +14 -1
- package/src/shared/workspace/platform-dir.ts +25 -0
|
@@ -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
|
|
@@ -17,7 +17,7 @@ import { tmpdir } from "node:os";
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
20
|
-
import { create } from "@bufbuild/protobuf";
|
|
20
|
+
import { create, type JsonObject } from "@bufbuild/protobuf";
|
|
21
21
|
import {
|
|
22
22
|
AgentMessageSchema,
|
|
23
23
|
ToolCallSchema,
|
|
@@ -238,6 +238,133 @@ describe("runTurnBoundary", () => {
|
|
|
238
238
|
expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
// ── Issue #205: unattributed hook blocks and the kind split ────────────────
|
|
242
|
+
|
|
243
|
+
/** A FAILED tool call carrying Cursor's generic hook-block error text. */
|
|
244
|
+
function hookBlockedCall(id: string, name: string, args: JsonObject) {
|
|
245
|
+
return create(ToolCallSchema, {
|
|
246
|
+
id,
|
|
247
|
+
name,
|
|
248
|
+
status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
249
|
+
error: "Command blocked by a hook.",
|
|
250
|
+
args,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Append one entry to this turn's denial ledger. */
|
|
255
|
+
async function appendLedgerEntry(entry: Record<string, unknown>): Promise<void> {
|
|
256
|
+
await writeFile(denialLedgerPath(hitlDir), JSON.stringify(entry) + "\n", { flag: "a" });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
it("reports a foreign hook block on a non-pausing turn (the #205 silent-complete shape)", async () => {
|
|
260
|
+
const status = newStatus();
|
|
261
|
+
const baseline = await captureBaselineToLedger({
|
|
262
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// A foreign .cursor/hooks.json hook denied the write: Cursor stamped its
|
|
266
|
+
// generic error, our ledger stayed empty, the tree is untouched. Before
|
|
267
|
+
// this fix the boundary reported a clean non-waiting turn and the run
|
|
268
|
+
// completed silently with the work undone.
|
|
269
|
+
const blocked = hookBlockedCall("tc-foreign", "edit", { path: "notes.md" });
|
|
270
|
+
status.messages.push(
|
|
271
|
+
create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [blocked] }),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const result = await runTurnBoundary(
|
|
275
|
+
boundaryOpts(status, baseline, { foreignGatingHooks: ["./team-policy.sh"] }),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(result.waiting).toBe(false);
|
|
279
|
+
expect(result.unattributedHookBlocks).toEqual([
|
|
280
|
+
{ toolCallId: "tc-foreign", toolName: "edit", error: "Command blocked by a hook." },
|
|
281
|
+
]);
|
|
282
|
+
// The row itself is untouched — no gate was manufactured for a block no
|
|
283
|
+
// approval can lift; the caller fails the execution instead.
|
|
284
|
+
expect(blocked.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("attributes a secret hard-block (kind:'secret'): no pause, no false foreign-hook report", async () => {
|
|
288
|
+
const status = newStatus();
|
|
289
|
+
const baseline = await captureBaselineToLedger({
|
|
290
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const secretRow = hookBlockedCall("tc-secret", "edit", { path: ".env" });
|
|
294
|
+
status.messages.push(
|
|
295
|
+
create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [secretRow] }),
|
|
296
|
+
);
|
|
297
|
+
await writeFile(
|
|
298
|
+
denialLedgerPath(hitlDir),
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret",
|
|
301
|
+
}) + "\n",
|
|
302
|
+
"utf-8",
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
306
|
+
|
|
307
|
+
// Not a pause (the agent was told to move on), not a foreign block (our
|
|
308
|
+
// own kinded entry attributes it), and never a WAITING_APPROVAL overlay.
|
|
309
|
+
expect(result.waiting).toBe(false);
|
|
310
|
+
expect(result.deniedToolCallCount).toBe(0);
|
|
311
|
+
expect(result.unattributedHookBlocks).toEqual([]);
|
|
312
|
+
expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("mixed turn: pauses on our anchor while still reporting the foreign block", async () => {
|
|
316
|
+
const status = newStatus();
|
|
317
|
+
const baseline = await captureBaselineToLedger({
|
|
318
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const ourGated = create(ToolCallSchema, {
|
|
322
|
+
id: "tc-ours", name: "shell",
|
|
323
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
324
|
+
args: { command: "rm -rf build" },
|
|
325
|
+
});
|
|
326
|
+
const foreign = hookBlockedCall("tc-theirs", "shell", { command: "terraform apply" });
|
|
327
|
+
status.messages.push(
|
|
328
|
+
create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [ourGated, foreign] }),
|
|
329
|
+
);
|
|
330
|
+
await appendLedgerEntry({ toolName: "shell", token: toolCallIdentityToken(ourGated), kind: "approval" });
|
|
331
|
+
|
|
332
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
333
|
+
|
|
334
|
+
// The pause wins (a pausing turn is never silent); the foreign block is
|
|
335
|
+
// still surfaced so the caller can log it next to the approval.
|
|
336
|
+
expect(result.waiting).toBe(true);
|
|
337
|
+
expect(ourGated.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
338
|
+
expect(result.unattributedHookBlocks.map((b) => b.toolCallId)).toEqual(["tc-theirs"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("excludes a kinded (secret) deny from the flowed-edit stamp (full-ledger deniedTokens)", async () => {
|
|
342
|
+
const status = newStatus();
|
|
343
|
+
const baseline = await captureBaselineToLedger({
|
|
344
|
+
status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// One edit genuinely flowed (tree changed + streamed row); one secret-like
|
|
348
|
+
// write was hard-blocked (FAILED row + kind:"secret" entry, tree untouched).
|
|
349
|
+
await write("notes.md", "original notes\nplus a real edit\n");
|
|
350
|
+
status.messages.push(streamedEdit("tc-flowed", "notes.md", "original notes\nplus a real edit\n"));
|
|
351
|
+
const secretRow = hookBlockedCall("tc-secret", "edit", { path: ".env" });
|
|
352
|
+
status.messages.push(
|
|
353
|
+
create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [secretRow] }),
|
|
354
|
+
);
|
|
355
|
+
await appendLedgerEntry({ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" });
|
|
356
|
+
|
|
357
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
358
|
+
|
|
359
|
+
// The flowed edit is captured + stamped; the secret-blocked row is NOT
|
|
360
|
+
// stamped into a change set that does not contain its file — every ledger
|
|
361
|
+
// kind means "this action did not execute", so the stamp uses the FULL set.
|
|
362
|
+
expect(result.waiting).toBe(true);
|
|
363
|
+
expect(result.capturedChangeCount).toBe(1);
|
|
364
|
+
expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
|
|
365
|
+
expect(secretRow.fileChangeSetId).toBe("");
|
|
366
|
+
});
|
|
367
|
+
|
|
241
368
|
it("waits for the denial-stop cancel to settle before reading the ledger", async () => {
|
|
242
369
|
const status = newStatus();
|
|
243
370
|
const baseline = await captureBaselineToLedger({
|
|
@@ -216,6 +216,65 @@ describe("buildMergedConfig", () => {
|
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
// ── Issue #205: foreign gating hook reporting ────────────────────────────────
|
|
220
|
+
// The merge PRESERVES the user's own hooks (correct — never clobber their
|
|
221
|
+
// config), but Cursor runs every configured hook and a deny from any of them
|
|
222
|
+
// blocks the runner's tools without writing our denial ledger. buildMergedConfig
|
|
223
|
+
// therefore reports the foreign commands left on the GATING events so the
|
|
224
|
+
// runner can log the exposure and name the culprit when the turn boundary
|
|
225
|
+
// detects an unattributed hook block.
|
|
226
|
+
describe("buildMergedConfig — foreign gating hook reporting (issue #205)", () => {
|
|
227
|
+
const bothEvents = [
|
|
228
|
+
{ event: "preToolUse", scriptPath: "/abs/hitl/stigmer-approval.sh" },
|
|
229
|
+
{ event: "beforeMCPExecution", scriptPath: "/abs/hitl/stigmer-approval.sh" },
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
it("reports no foreign hooks when no hooks.json exists", () => {
|
|
233
|
+
expect(buildMergedConfig(null, bothEvents).foreignGatingHooks).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("reports user commands on the gating events, ignoring non-gating events", () => {
|
|
237
|
+
const original = JSON.stringify({
|
|
238
|
+
version: 1,
|
|
239
|
+
hooks: {
|
|
240
|
+
preToolUse: [{ command: "./gate-writes.sh", failClosed: true }],
|
|
241
|
+
beforeMCPExecution: [{ command: "./gate-mcp.sh" }],
|
|
242
|
+
// postToolUse cannot deny a tool — it must not be reported.
|
|
243
|
+
postToolUse: [{ command: "./audit.sh" }],
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const { foreignGatingHooks } = buildMergedConfig(original, bothEvents);
|
|
247
|
+
expect(foreignGatingHooks).toEqual(["./gate-writes.sh", "./gate-mcp.sh"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("excludes Stigmer's own entries (current and legacy) from the report", () => {
|
|
251
|
+
const original = JSON.stringify({
|
|
252
|
+
version: 1,
|
|
253
|
+
hooks: {
|
|
254
|
+
preToolUse: [
|
|
255
|
+
{ command: "/home/u/.stigmer/sessions/ses-1/hitl/stigmer-approval.sh" },
|
|
256
|
+
{ command: ".cursor/hooks/stigmer-approval.sh" }, // pre-#173 legacy
|
|
257
|
+
{ command: "./user.sh" },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const { foreignGatingHooks } = buildMergedConfig(original, bothEvents);
|
|
262
|
+
expect(foreignGatingHooks).toEqual(["./user.sh"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("ignores malformed entries without a string command", () => {
|
|
266
|
+
const original = JSON.stringify({
|
|
267
|
+
version: 1,
|
|
268
|
+
hooks: { preToolUse: [{ command: 42 }, { timeout: 5 }, null, "bare-string"] },
|
|
269
|
+
});
|
|
270
|
+
expect(buildMergedConfig(original, bothEvents).foreignGatingHooks).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("reports nothing for an unparseable hooks.json (replaced for the turn, so it cannot run)", () => {
|
|
274
|
+
expect(buildMergedConfig("{ not json", bothEvents).foreignGatingHooks).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
219
278
|
describe("installHitlGate / removeHitlGate", () => {
|
|
220
279
|
const approvalState = buildApprovalState(new Map(), false, new Set());
|
|
221
280
|
|
|
@@ -290,6 +349,42 @@ describe("installHitlGate / removeHitlGate", () => {
|
|
|
290
349
|
expect(existsSync(join(workspaceRoot, ".cursor", "hooks"))).toBe(false);
|
|
291
350
|
});
|
|
292
351
|
|
|
352
|
+
it("surfaces the preserved foreign gating hooks on the install handle", async () => {
|
|
353
|
+
const { workspaceRoot, hitlDir } = dirs();
|
|
354
|
+
const cursorDir = join(workspaceRoot, ".cursor");
|
|
355
|
+
mkdirSync(cursorDir, { recursive: true });
|
|
356
|
+
writeFileSync(
|
|
357
|
+
join(cursorDir, "hooks.json"),
|
|
358
|
+
JSON.stringify({
|
|
359
|
+
version: 1,
|
|
360
|
+
hooks: {
|
|
361
|
+
preToolUse: [{ command: "./gate-writes.sh", failClosed: true }],
|
|
362
|
+
postToolUse: [{ command: "./audit.sh" }],
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
"utf-8",
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const handle = await installHitlGate({
|
|
369
|
+
workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
|
|
370
|
+
});
|
|
371
|
+
// The user's gating hook is preserved in the merged config AND reported on
|
|
372
|
+
// the handle (both registered events read the same preToolUse array here,
|
|
373
|
+
// so the report carries the one foreign command once per gating event it
|
|
374
|
+
// could fire on — preToolUse only, since beforeMCPExecution was absent).
|
|
375
|
+
expect(handle.foreignGatingHooks).toEqual(["./gate-writes.sh"]);
|
|
376
|
+
await removeHitlGate(handle);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("reports no foreign hooks for a pristine workspace", async () => {
|
|
380
|
+
const { workspaceRoot, hitlDir } = dirs();
|
|
381
|
+
const handle = await installHitlGate({
|
|
382
|
+
workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
|
|
383
|
+
});
|
|
384
|
+
expect(handle.foreignGatingHooks).toEqual([]);
|
|
385
|
+
await removeHitlGate(handle);
|
|
386
|
+
});
|
|
387
|
+
|
|
293
388
|
it("restores a pre-existing user hooks.json byte-for-byte after teardown", async () => {
|
|
294
389
|
const { workspaceRoot, hitlDir } = dirs();
|
|
295
390
|
const cursorDir = join(workspaceRoot, ".cursor");
|
|
@@ -56,14 +56,24 @@
|
|
|
56
56
|
*
|
|
57
57
|
* Denial ledger (hook → runner):
|
|
58
58
|
* The state file is the runner's INPUT to the hook. Its symmetric OUTPUT is the
|
|
59
|
-
* denial ledger (denials.jsonl):
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
59
|
+
* denial ledger (denials.jsonl): EVERY deny the hook issues appends the call's
|
|
60
|
+
* identity token to this file, tagged with a `kind` (see {@link DenialKind}) —
|
|
61
|
+
* so the ledger is the complete, authoritative record of what THIS hook blocked
|
|
62
|
+
* this turn (the cursor analog of the native harness's LangGraph interrupts).
|
|
63
|
+
* The kinds split by consumer semantics:
|
|
64
|
+
*
|
|
65
|
+
* - Only APPROVAL-kind entries pause the run (the first-denial stop and
|
|
66
|
+
* reconcileDeniedToolCalls filter to them via {@link approvalDenials}); a
|
|
67
|
+
* secret hard-block or a fail-closed deny must never surface an approval the
|
|
68
|
+
* user cannot meaningfully grant.
|
|
69
|
+
* - The FULL ledger (all kinds) means "these actions did NOT execute": it feeds
|
|
70
|
+
* capture stamping and DD-28 provenance, and it is the ATTRIBUTION set for
|
|
71
|
+
* issue #205 — a hook-blocked tool call with NO ledger entry of any kind was
|
|
72
|
+
* blocked by a FOREIGN hook (or a failed ledger append), an invariant
|
|
73
|
+
* violation the turn boundary surfaces instead of completing silently.
|
|
74
|
+
*
|
|
75
|
+
* The token uses the SAME identity space as approvedGrantTokens, so a denial
|
|
76
|
+
* token approved this turn becomes a grant token next turn.
|
|
67
77
|
*/
|
|
68
78
|
|
|
69
79
|
import { watch } from "node:fs";
|
|
@@ -433,12 +443,40 @@ export async function writeApprovalStateFile(
|
|
|
433
443
|
|
|
434
444
|
const DENIAL_LEDGER_FILE = "denials.jsonl";
|
|
435
445
|
|
|
446
|
+
/**
|
|
447
|
+
* The attribution taxonomy of a hook denial (mirrored byte-for-byte by the
|
|
448
|
+
* generated bash script — see hook-script.ts record_denial):
|
|
449
|
+
*
|
|
450
|
+
* - `approval` — the normal gate: the runner surfaces it as a
|
|
451
|
+
* WAITING_APPROVAL pause. The ONLY kind that pauses.
|
|
452
|
+
* - `secret` — DD-26 secret hard-block: intentional, non-pausing (the
|
|
453
|
+
* agent was told to move on), recorded content-free.
|
|
454
|
+
* - `capture-error` — CAS staging failed; the write stayed on the deny-gate.
|
|
455
|
+
* Content-free (the staging error means secret
|
|
456
|
+
* classification may never have run).
|
|
457
|
+
* - `fail-closed` — the approval state file was missing, so everything gated
|
|
458
|
+
* denied. A turn-level "the gate itself was broken" fact.
|
|
459
|
+
*
|
|
460
|
+
* An unknown kind string is preserved as-is: it is treated as non-pausing (an
|
|
461
|
+
* unknown deny must never manufacture an approval) but still attributes the
|
|
462
|
+
* blocked call to our own hook.
|
|
463
|
+
*/
|
|
464
|
+
export type DenialKind = "approval" | "secret" | "capture-error" | "fail-closed";
|
|
465
|
+
|
|
466
|
+
/** The one kind that pauses the run for user approval. */
|
|
467
|
+
export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
|
|
468
|
+
|
|
436
469
|
/**
|
|
437
470
|
* One denial recorded by the preToolUse hook. `token` is the call's identity in
|
|
438
471
|
* the same space as grantToken() (base64 of `toolName \n salientArg`), used to
|
|
439
472
|
* correlate the denial back to the streamed tool call. `toolName` is carried raw
|
|
440
473
|
* for human-readable debugging of the ledger file.
|
|
441
474
|
*
|
|
475
|
+
* `kind` is the attribution taxonomy entry (see {@link DenialKind}). Optional
|
|
476
|
+
* for tolerance: an entry written without one (the pre-kind ledger format, or a
|
|
477
|
+
* hand-built test fixture) is an approval-kind denial — see
|
|
478
|
+
* {@link denialKindOf}.
|
|
479
|
+
*
|
|
442
480
|
* `input` is the authoritative pre-execution tool arguments the hook captured
|
|
443
481
|
* from the Cursor `tool_input` payload (decoded from the ledger's base64 form).
|
|
444
482
|
* It is the cursor analog of the native harness reading the AI-message tool-call
|
|
@@ -446,15 +484,33 @@ const DENIAL_LEDGER_FILE = "denials.jsonl";
|
|
|
446
484
|
* proposed change is in hand before the tool runs. The runner overlays it onto
|
|
447
485
|
* the gated tool call so the approval card can show the proposed content/diff
|
|
448
486
|
* before the user approves. Absent when the hook ran its grep fallback (the Node
|
|
449
|
-
* binary was unavailable)
|
|
450
|
-
*
|
|
487
|
+
* binary was unavailable) — the gate then degrades to stream-recovered args —
|
|
488
|
+
* and ALWAYS absent for non-approval kinds (DD-26: only an approval-kind entry
|
|
489
|
+
* may carry proposed content).
|
|
451
490
|
*/
|
|
452
491
|
export interface DeniedLedgerEntry {
|
|
453
492
|
toolName: string;
|
|
454
493
|
token: string;
|
|
494
|
+
kind?: string;
|
|
455
495
|
input?: Record<string, unknown>;
|
|
456
496
|
}
|
|
457
497
|
|
|
498
|
+
/** The effective kind of a ledger entry (absent → approval, the pre-kind format). */
|
|
499
|
+
export function denialKindOf(entry: DeniedLedgerEntry): string {
|
|
500
|
+
return entry.kind || APPROVAL_DENIAL_KIND;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The entries that pause the run for user approval — the ONLY kind
|
|
505
|
+
* reconcileDeniedToolCalls may turn into WAITING_APPROVAL gates and the only
|
|
506
|
+
* kind the first-denial stop may cancel the run for. Every other consumer
|
|
507
|
+
* (capture stamping, DD-28 provenance, foreign-hook attribution) wants the FULL
|
|
508
|
+
* ledger: all kinds mean "this action did not execute".
|
|
509
|
+
*/
|
|
510
|
+
export function approvalDenials(entries: readonly DeniedLedgerEntry[]): DeniedLedgerEntry[] {
|
|
511
|
+
return entries.filter((e) => denialKindOf(e) === APPROVAL_DENIAL_KIND);
|
|
512
|
+
}
|
|
513
|
+
|
|
458
514
|
/**
|
|
459
515
|
* Absolute path of the per-turn denial ledger the hook appends to, inside the
|
|
460
516
|
* session's runner-owned HITL directory (never the user's workspace).
|
|
@@ -540,11 +596,19 @@ export async function readDenialLedger(
|
|
|
540
596
|
const trimmed = line.trim();
|
|
541
597
|
if (!trimmed) continue;
|
|
542
598
|
try {
|
|
543
|
-
const obj = JSON.parse(trimmed) as {
|
|
599
|
+
const obj = JSON.parse(trimmed) as {
|
|
600
|
+
toolName?: unknown;
|
|
601
|
+
token?: unknown;
|
|
602
|
+
kind?: unknown;
|
|
603
|
+
input?: unknown;
|
|
604
|
+
};
|
|
544
605
|
if (typeof obj.token === "string" && obj.token) {
|
|
545
606
|
entries.push({
|
|
546
607
|
toolName: typeof obj.toolName === "string" ? obj.toolName : "",
|
|
547
608
|
token: obj.token,
|
|
609
|
+
// A garbled/missing kind degrades to undefined → approval (the
|
|
610
|
+
// pre-kind format); see denialKindOf.
|
|
611
|
+
kind: typeof obj.kind === "string" && obj.kind ? obj.kind : undefined,
|
|
548
612
|
input: decodeLedgerInput(obj.input),
|
|
549
613
|
});
|
|
550
614
|
}
|