@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
|
@@ -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
|
}
|
|
@@ -23,9 +23,16 @@ export async function resolveExecutionEnv(
|
|
|
23
23
|
client: StigmerClient,
|
|
24
24
|
executionId: string,
|
|
25
25
|
): Promise<EnvResult> {
|
|
26
|
+
// A desktop runner exchanges its bootstrap credential for a token scoped to
|
|
27
|
+
// this execution's session, so cloud's decrypt gate binds the read (#156).
|
|
28
|
+
// No-op for cloud sandbox and OSS runners.
|
|
29
|
+
const scopedToken = await client.acquireScopedRunnerToken({
|
|
30
|
+
agentExecutionId: executionId,
|
|
31
|
+
});
|
|
32
|
+
|
|
26
33
|
let execCtx;
|
|
27
34
|
try {
|
|
28
|
-
execCtx = await client.getExecutionContextByExecutionId(executionId);
|
|
35
|
+
execCtx = await client.getExecutionContextByExecutionId(executionId, scopedToken);
|
|
29
36
|
} catch (err) {
|
|
30
37
|
const code = (err as { code?: number })?.code;
|
|
31
38
|
// NOT_FOUND (5) is expected for executions without env vars
|
|
@@ -19,11 +19,24 @@
|
|
|
19
19
|
* by event — gated built-in tools (preToolUse) or MCP require-approval
|
|
20
20
|
* policies (beforeMCPExecution)
|
|
21
21
|
* 4. On a deny, appends the call's identity token to the denial ledger
|
|
22
|
-
* (
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* (denials.jsonl) — EVERY deny path records, each tagged with a `kind`
|
|
23
|
+
* (see below), so the ledger is the complete, authoritative record of what
|
|
24
|
+
* this hook blocked this turn. The runner marks approval-kind denials as
|
|
25
|
+
* WAITING_APPROVAL; the full ledger is its attribution set for
|
|
26
|
+
* distinguishing our denials from a FOREIGN hook's (issue #205): a
|
|
27
|
+
* hook-blocked tool with NO ledger entry of any kind was not blocked by us.
|
|
25
28
|
* 5. Returns { "permission": "allow" } or { "permission": "deny" } on stdout
|
|
26
29
|
*
|
|
30
|
+
* Denial kinds (the attribution taxonomy, mirrored in approval-state.ts):
|
|
31
|
+
* - "approval" — the normal gate: pauses the run for user approval.
|
|
32
|
+
* - "secret" — DD-26 secret hard-block: the agent continues, no pause.
|
|
33
|
+
* - "capture-error" — CAS staging failed, write kept on the deny-gate.
|
|
34
|
+
* - "fail-closed" — approval state file missing, everything gated denies.
|
|
35
|
+
* Only approval-kind records carry the captured tool_input: a secret write's
|
|
36
|
+
* content must never be persisted (DD-26), a capture-error's content is
|
|
37
|
+
* UNCLASSIFIED (the staging error means secret classification may never have
|
|
38
|
+
* run), and fail-closed has no state to classify against.
|
|
39
|
+
*
|
|
27
40
|
* Identity extraction runs on the SAME Node.js binary as the runner (its
|
|
28
41
|
* absolute path — process.execPath — is baked into the script at generation
|
|
29
42
|
* time), because the identity token must be byte-identical to the one the
|
|
@@ -436,9 +449,47 @@ ${categoryCaseArms}
|
|
|
436
449
|
INPUT_B64=""
|
|
437
450
|
CONTENT_TOKEN=""
|
|
438
451
|
fi
|
|
452
|
+
# The single identity a deny is recorded under: content-exact when the input
|
|
453
|
+
# carried edit content, else coarse — the same PRIMARY-token choice the runner
|
|
454
|
+
# grants on approval (approval-state.ts primaryToken).
|
|
455
|
+
if [ -n "$CONTENT_TOKEN" ]; then
|
|
456
|
+
PRIMARY_TOKEN="$CONTENT_TOKEN"
|
|
457
|
+
else
|
|
458
|
+
PRIMARY_TOKEN="$TOKEN"
|
|
459
|
+
fi
|
|
460
|
+
|
|
461
|
+
# Append a denial record to the ledger: $1 = identity token, $2 = kind (the
|
|
462
|
+
# attribution taxonomy — see the module doc in hook-script.ts). EVERY deny arm
|
|
463
|
+
# below records, so the ledger is the complete record of what THIS hook blocked
|
|
464
|
+
# and the runner can tell its own denials from a foreign hook's (issue #205).
|
|
465
|
+
# Best-effort: a ledger write failure must never abort the decision (the deny
|
|
466
|
+
# still goes out on stdout). ONLY approval-kind records carry the captured
|
|
467
|
+
# tool_input (DD-26: secret / unclassified content must never be persisted);
|
|
468
|
+
# input is base64(JSON(tool_input)) — the authoritative pre-execution args the
|
|
469
|
+
# runner overlays for the approval preview (empty on the grep fallback path).
|
|
470
|
+
# Written with printf (a builtin, so no ARG_MAX limit) because the input can be
|
|
471
|
+
# a large multi-MB file body. Defined BEFORE the first deny arm: bash resolves
|
|
472
|
+
# function calls at execution time, and under 'set -euo pipefail' with our
|
|
473
|
+
# failClosed hooks.json registration a call-before-define would abort with no
|
|
474
|
+
# decision and block EVERY tool.
|
|
475
|
+
record_denial() {
|
|
476
|
+
if [ "$2" = "approval" ]; then
|
|
477
|
+
printf '{"toolName":"%s","token":"%s","kind":"%s","input":"%s"}\\n' "$TOOL_NAME" "$1" "$2" "$INPUT_B64" >> "$LEDGER_FILE" 2>/dev/null || true
|
|
478
|
+
else
|
|
479
|
+
printf '{"toolName":"%s","token":"%s","kind":"%s"}\\n' "$TOOL_NAME" "$1" "$2" >> "$LEDGER_FILE" 2>/dev/null || true
|
|
480
|
+
fi
|
|
481
|
+
}
|
|
439
482
|
|
|
440
483
|
# --- Failsafe: missing state file → deny (fail-closed) ---
|
|
484
|
+
# Recorded as kind "fail-closed" so the runner attributes the blocked call to
|
|
485
|
+
# its own (broken) gate instead of misdiagnosing a foreign hook. The ledger
|
|
486
|
+
# path comes from the POINTER, not the state file, so it is recordable here.
|
|
441
487
|
if [ ! -f "$STATE_FILE" ]; then
|
|
488
|
+
if [ "$HOOK_EVENT" = "beforeMCPExecution" ]; then
|
|
489
|
+
record_denial "$MCP_TOKEN" "fail-closed"
|
|
490
|
+
else
|
|
491
|
+
record_denial "$PRIMARY_TOKEN" "fail-closed"
|
|
492
|
+
fi
|
|
442
493
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
|
|
443
494
|
exit 0
|
|
444
495
|
fi
|
|
@@ -494,12 +545,18 @@ if [ "$CAPTURE_IGNORED" = "true" ] && [ "$CATEGORY" = "write" ] && [ -n "$SALIEN
|
|
|
494
545
|
echo '{"permission":"allow"}'
|
|
495
546
|
exit 0
|
|
496
547
|
elif [ "$OBS_RESULT" = "secret" ]; then
|
|
548
|
+
# Kind "secret": attributable but deliberately non-pausing (the agent moves
|
|
549
|
+
# on) and content-free (DD-26 — the token is the only identity recorded).
|
|
550
|
+
record_denial "$PRIMARY_TOKEN" "secret"
|
|
497
551
|
echo '{"permission":"deny","agent_message":"${SECRET_BLOCKED_AGENT_MESSAGE}","user_message":"Blocked for security: this file matches a secret-like path and was not written or captured for review."}'
|
|
498
552
|
exit 0
|
|
499
553
|
else
|
|
500
554
|
# Node unavailable or a staging error: fail closed. A gitignored write we
|
|
501
555
|
# cannot stage cannot be captured for review, so keep gating it (today's
|
|
502
|
-
# behavior) rather than letting unreviewable bytes flow.
|
|
556
|
+
# behavior) rather than letting unreviewable bytes flow. Kind
|
|
557
|
+
# "capture-error" and content-free: the staging error means secret
|
|
558
|
+
# classification may never have run, so the content is UNCLASSIFIED.
|
|
559
|
+
record_denial "$PRIMARY_TOKEN" "capture-error"
|
|
503
560
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
|
|
504
561
|
exit 0
|
|
505
562
|
fi
|
|
@@ -511,17 +568,6 @@ if echo "$STATE" | grep -q '"autoApproveAll":true'; then
|
|
|
511
568
|
exit 0
|
|
512
569
|
fi
|
|
513
570
|
|
|
514
|
-
# Append a denial record to the ledger. Best-effort: a ledger write failure must
|
|
515
|
-
# never abort the decision (the deny still goes out on stdout). toolName is raw
|
|
516
|
-
# for human-readable debugging; token drives correlation in the runner; input is
|
|
517
|
-
# base64(JSON(tool_input)) — the authoritative pre-execution args the runner
|
|
518
|
-
# overlays for the approval preview (empty on the grep fallback path). Written
|
|
519
|
-
# with printf (a builtin, so no ARG_MAX limit) because the input can be a large
|
|
520
|
-
# multi-MB file body.
|
|
521
|
-
record_denial() {
|
|
522
|
-
printf '{"toolName":"%s","token":"%s","input":"%s"}\\n' "$TOOL_NAME" "$1" "$INPUT_B64" >> "$LEDGER_FILE" 2>/dev/null || true
|
|
523
|
-
}
|
|
524
|
-
|
|
525
571
|
# --- 2. MCP tools (beforeMCPExecution event) ---
|
|
526
572
|
# preToolUse does NOT enforce gating for MCP calls — beforeMCPExecution does — so
|
|
527
573
|
# MCP is gated here and ONLY here (never double-recorded). mcpToolPolicies holds
|
|
@@ -542,7 +588,7 @@ if [ "$HOOK_EVENT" = "beforeMCPExecution" ]; then
|
|
|
542
588
|
if [ -z "$MSG" ]; then
|
|
543
589
|
MSG="Tool requires approval: $TOOL_NAME"
|
|
544
590
|
fi
|
|
545
|
-
record_denial "$MCP_TOKEN"
|
|
591
|
+
record_denial "$MCP_TOKEN" "approval"
|
|
546
592
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
|
|
547
593
|
exit 0
|
|
548
594
|
fi
|
|
@@ -583,6 +629,9 @@ if [ -n "$CATEGORY" ]; then
|
|
|
583
629
|
if [ "$CATEGORY" = "write" ] && [ -n "$SALIENT" ]; then
|
|
584
630
|
SECRET_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${secretClassifyScript}' 2>/dev/null || echo ok)
|
|
585
631
|
if [ "$SECRET_RESULT" = "secret" ]; then
|
|
632
|
+
# Kind "secret": attributable but non-pausing and content-free (DD-26 —
|
|
633
|
+
# only the identity token is recorded, never the proposed content).
|
|
634
|
+
record_denial "$PRIMARY_TOKEN" "secret"
|
|
586
635
|
echo '{"permission":"deny","agent_message":"${SECRET_BLOCKED_AGENT_MESSAGE}","user_message":"Blocked for security: this file matches a secret-like path and was not written."}'
|
|
587
636
|
exit 0
|
|
588
637
|
fi
|
|
@@ -606,11 +655,7 @@ if [ -n "$CATEGORY" ]; then
|
|
|
606
655
|
fi
|
|
607
656
|
# Record the PRIMARY token (content-exact when available, else coarse) so the
|
|
608
657
|
# runner's denial correlation keys on the SAME identity it grants on approval.
|
|
609
|
-
|
|
610
|
-
record_denial "$CONTENT_TOKEN"
|
|
611
|
-
else
|
|
612
|
-
record_denial "$TOKEN"
|
|
613
|
-
fi
|
|
658
|
+
record_denial "$PRIMARY_TOKEN" "approval"
|
|
614
659
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
|
|
615
660
|
exit 0
|
|
616
661
|
fi
|
|
@@ -125,6 +125,7 @@ export function createCursorActivities(config: Config) {
|
|
|
125
125
|
endpoint: config.stigmerBackendEndpoint,
|
|
126
126
|
token: config.stigmerToken,
|
|
127
127
|
tokenRef: config.stigmerTokenRef,
|
|
128
|
+
runnerTokenRef: config.stigmerRunnerTokenRef,
|
|
128
129
|
});
|
|
129
130
|
|
|
130
131
|
return {
|
|
@@ -714,6 +715,19 @@ async function executeCursorInner(
|
|
|
714
715
|
await removeHitlGate(hitlGate);
|
|
715
716
|
await removeStigmerSymlink(primaryWorkspaceDir);
|
|
716
717
|
};
|
|
718
|
+
// Issue #205 diagnosability: the merge preserved the user's own hooks on
|
|
719
|
+
// the gating events, and Cursor runs every configured hook — so any of
|
|
720
|
+
// these can deny this turn's tools without writing our denial ledger. Log
|
|
721
|
+
// the exposure up front; the turn boundary uses the same list to name the
|
|
722
|
+
// likely culprit if it detects an unattributed hook block.
|
|
723
|
+
if (hitlGate.foreignGatingHooks.length > 0) {
|
|
724
|
+
console.warn(
|
|
725
|
+
`ExecuteCursor: workspace hooks.json carries ${hitlGate.foreignGatingHooks.length} ` +
|
|
726
|
+
`foreign gating hook(s) [${hitlGate.foreignGatingHooks.join(", ")}] — a deny from ` +
|
|
727
|
+
`any of them blocks the runner's tools outside Stigmer's approval flow ` +
|
|
728
|
+
`(execution=${executionId})`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
717
731
|
// Arm the denial watcher as soon as the gate exists. The per-turn ledger
|
|
718
732
|
// reset may flip the flag once before the run starts; the loop's read then
|
|
719
733
|
// sees an empty ledger and clears it — harmless by construction.
|
|
@@ -1198,6 +1212,7 @@ async function executeCursorInner(
|
|
|
1198
1212
|
artifactStorage,
|
|
1199
1213
|
mergedPolicies,
|
|
1200
1214
|
denialCancelSettled: denialSettled,
|
|
1215
|
+
foreignGatingHooks: hitlGate.foreignGatingHooks,
|
|
1201
1216
|
});
|
|
1202
1217
|
// Pauses for review exactly like the native harness: the boundary mutated
|
|
1203
1218
|
// the transcript in place; we flip the phase, persist, and RETURN to the
|
|
@@ -1212,6 +1227,41 @@ async function executeCursorInner(
|
|
|
1212
1227
|
return slimStatus(status);
|
|
1213
1228
|
};
|
|
1214
1229
|
|
|
1230
|
+
// Issue #205: a tool was blocked by a hook Stigmer does not own (the merge
|
|
1231
|
+
// preserves the user's own gating hooks, and Cursor runs every one), so no
|
|
1232
|
+
// approval can unblock it — an approval grants a token only OUR hook reads,
|
|
1233
|
+
// and the foreign hook would deny the re-attempt forever. Completing would
|
|
1234
|
+
// be the silent-failure shape the issue describes; instead fail with a
|
|
1235
|
+
// diagnosable reason naming the blocked tools and the likely culprit.
|
|
1236
|
+
// Shared by the primary turn and both recovery retries.
|
|
1237
|
+
const enterUnattributedHookBlockFailure = async (boundary: TurnBoundaryResult) => {
|
|
1238
|
+
const blockedTools = [...new Set(boundary.unattributedHookBlocks.map((b) => b.toolName))]
|
|
1239
|
+
.join(", ");
|
|
1240
|
+
const culprit = hitlGate.foreignGatingHooks.length > 0
|
|
1241
|
+
? ` The workspace's .cursor/hooks.json registers hook(s) outside Stigmer's control ` +
|
|
1242
|
+
`[${hitlGate.foreignGatingHooks.join(", ")}], which most likely denied it.`
|
|
1243
|
+
: "";
|
|
1244
|
+
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
1245
|
+
status.error =
|
|
1246
|
+
`A Cursor hook outside Stigmer's approval gate blocked tool(s): ${blockedTools}.` +
|
|
1247
|
+
culprit +
|
|
1248
|
+
` Stigmer cannot request approval on a foreign hook's behalf — remove or adjust ` +
|
|
1249
|
+
`the hook in .cursor/hooks.json and retry.`;
|
|
1250
|
+
status.completedAt = utcTimestamp();
|
|
1251
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1252
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1253
|
+
content: `Execution failed: ${status.error}`,
|
|
1254
|
+
timestamp: utcTimestamp(),
|
|
1255
|
+
}));
|
|
1256
|
+
await persist(status);
|
|
1257
|
+
try { resolution.agent.close(); } catch { /* best effort */ }
|
|
1258
|
+
console.error(
|
|
1259
|
+
`ExecuteCursor failed (unattributed hook block): execution=${executionId}, ` +
|
|
1260
|
+
`tools=[${blockedTools}], foreignHooks=[${hitlGate.foreignGatingHooks.join(", ")}]`,
|
|
1261
|
+
);
|
|
1262
|
+
return slimStatus(status);
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1215
1265
|
// Re-enter the turn boundary for a recovery retry: author the retry's net
|
|
1216
1266
|
// change set to the file_review ledger and overlay any denials as gates —
|
|
1217
1267
|
// without this a retry's file edits silently escape review (production case
|
|
@@ -1285,8 +1335,13 @@ async function executeCursorInner(
|
|
|
1285
1335
|
turnState.firstDenialDetected ? turnState.denialCancelSettled : undefined,
|
|
1286
1336
|
);
|
|
1287
1337
|
if (boundary.waiting) {
|
|
1338
|
+
// A pausing turn is never silent, so an unattributed block alongside our
|
|
1339
|
+
// own gate only warns (logged by the boundary) — the pause wins.
|
|
1288
1340
|
return enterApprovalPause(boundary);
|
|
1289
1341
|
}
|
|
1342
|
+
if (boundary.unattributedHookBlocks.length > 0) {
|
|
1343
|
+
return enterUnattributedHookBlockFailure(boundary);
|
|
1344
|
+
}
|
|
1290
1345
|
|
|
1291
1346
|
// Phase 13: Map final result
|
|
1292
1347
|
const result = await run.wait();
|
|
@@ -1407,6 +1462,9 @@ async function executeCursorInner(
|
|
|
1407
1462
|
);
|
|
1408
1463
|
return enterApprovalPause(retryBoundary);
|
|
1409
1464
|
}
|
|
1465
|
+
if (retryBoundary && retryBoundary.unattributedHookBlocks.length > 0) {
|
|
1466
|
+
return enterUnattributedHookBlockFailure(retryBoundary);
|
|
1467
|
+
}
|
|
1410
1468
|
|
|
1411
1469
|
if (retryResult.status === "finished") {
|
|
1412
1470
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
@@ -1488,6 +1546,9 @@ async function executeCursorInner(
|
|
|
1488
1546
|
);
|
|
1489
1547
|
return enterApprovalPause(retryBoundary);
|
|
1490
1548
|
}
|
|
1549
|
+
if (retryBoundary && retryBoundary.unattributedHookBlocks.length > 0) {
|
|
1550
|
+
return enterUnattributedHookBlockFailure(retryBoundary);
|
|
1551
|
+
}
|
|
1491
1552
|
|
|
1492
1553
|
if (retryResult.status === "finished") {
|
|
1493
1554
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|