@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
|
@@ -45,7 +45,14 @@ import {
|
|
|
45
45
|
resolveApprovalProvenance,
|
|
46
46
|
toProtoPolicySource,
|
|
47
47
|
} from "../../shared/approval-policy.js";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
approvalDenials,
|
|
50
|
+
denialKindOf,
|
|
51
|
+
grantToken,
|
|
52
|
+
primaryToken,
|
|
53
|
+
toolIdentity,
|
|
54
|
+
type DeniedLedgerEntry,
|
|
55
|
+
} from "./approval-state.js";
|
|
49
56
|
import { utcTimestamp } from "../../shared/status.js";
|
|
50
57
|
import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
|
|
51
58
|
import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
|
|
@@ -316,6 +323,19 @@ function mapSubAgentStatus(cursorStatus: string): SubAgentStatus {
|
|
|
316
323
|
}
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Terminal for the MONOTONIC status guard in {@link mergeToolCallEvent}: once a
|
|
328
|
+
* row reaches one of these, a later event re-emit must not regress it.
|
|
329
|
+
*
|
|
330
|
+
* TOOL_CALL_INTERRUPTED is deliberately NOT here, even though it is terminal
|
|
331
|
+
* everywhere else (server, clients, provenance scoping — see the shared
|
|
332
|
+
* TERMINAL_TOOL_CALL_STATUSES in tool-row.ts). INTERRUPTED is server-authored
|
|
333
|
+
* when an execution terminalizes with the call in flight; if that FAILED
|
|
334
|
+
* execution is later RECOVERED, the harness checkpoint can re-execute the call
|
|
335
|
+
* under its original call id — and live execution evidence outranks the
|
|
336
|
+
* interruption marker, so the replayed event must advance the row to its true
|
|
337
|
+
* outcome (the enum's documented recovery supersede rule).
|
|
338
|
+
*/
|
|
319
339
|
function isTerminalToolStatus(status: ToolCallStatus): boolean {
|
|
320
340
|
return (
|
|
321
341
|
status === ToolCallStatus.TOOL_CALL_COMPLETED ||
|
|
@@ -1217,6 +1237,13 @@ export async function reconcileDeniedToolCalls(
|
|
|
1217
1237
|
mergedPolicies?: Map<string, MergedToolPolicy>,
|
|
1218
1238
|
workspaceBackend?: WorkspaceBackend,
|
|
1219
1239
|
): Promise<ToolCall[]> {
|
|
1240
|
+
// Defense-in-depth: only APPROVAL-kind denials may become approval gates.
|
|
1241
|
+
// The turn boundary already passes the filtered subset; re-filtering here
|
|
1242
|
+
// makes it structurally impossible for a secret/capture-error/fail-closed
|
|
1243
|
+
// entry to manufacture a pause the user cannot meaningfully grant, no matter
|
|
1244
|
+
// what a future caller passes (an absent kind is the pre-kind format and
|
|
1245
|
+
// counts as approval).
|
|
1246
|
+
ledger = approvalDenials(ledger);
|
|
1220
1247
|
if (ledger.length === 0) return [];
|
|
1221
1248
|
|
|
1222
1249
|
// The workspace the gated files live in; its rootDir normalizes paths for the
|
|
@@ -1793,6 +1820,102 @@ export function clearProvisionalPostDenialNarration(
|
|
|
1793
1820
|
return redacted;
|
|
1794
1821
|
}
|
|
1795
1822
|
|
|
1823
|
+
/**
|
|
1824
|
+
* Substrings of the error text Cursor stamps onto a tool call blocked by a
|
|
1825
|
+
* `preToolUse`/`beforeMCPExecution` hook (its generic replacement for the
|
|
1826
|
+
* hook's own agent_message — confirmed by the Phase 0 ground-truth capture in
|
|
1827
|
+
* cursor_hitl_test.go). The SDK has NO structured "denied by hook" signal, so
|
|
1828
|
+
* this marker family is the only stream-side trace of a hook block and is
|
|
1829
|
+
* single-sourced here for every consumer (the issue #205 attribution detector
|
|
1830
|
+
* below; tests). Matched case-insensitively against the FAILED call's error.
|
|
1831
|
+
*/
|
|
1832
|
+
export const HOOK_BLOCK_ERROR_MARKERS: readonly string[] = ["blocked by a hook"];
|
|
1833
|
+
|
|
1834
|
+
/** True when a tool call's error text reads as a hook block. */
|
|
1835
|
+
function isHookBlockError(errorText: string): boolean {
|
|
1836
|
+
if (!errorText) return false;
|
|
1837
|
+
const lowered = errorText.toLowerCase();
|
|
1838
|
+
return HOOK_BLOCK_ERROR_MARKERS.some((marker) => lowered.includes(marker));
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** One hook-blocked tool call that no ledger entry accounts for (issue #205). */
|
|
1842
|
+
export interface UnattributedHookBlock {
|
|
1843
|
+
toolCallId: string;
|
|
1844
|
+
toolName: string;
|
|
1845
|
+
/** The hook-block error text Cursor stamped on the call. */
|
|
1846
|
+
error: string;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Detect tool calls blocked by a hook that STIGMER'S OWN hook did not deny —
|
|
1851
|
+
* the issue #205 invariant check "a blocked tool must never silently complete".
|
|
1852
|
+
*
|
|
1853
|
+
* Cursor runs EVERY hook registered in the workspace's `.cursor/hooks.json`
|
|
1854
|
+
* and a deny from any of them blocks the tool. Our hook records every deny it
|
|
1855
|
+
* issues to the denial ledger (all kinds — see {@link DeniedLedgerEntry}), so a
|
|
1856
|
+
* FAILED tool call carrying Cursor's hook-block error text with NO matching
|
|
1857
|
+
* ledger entry was blocked by a FOREIGN hook (a user/team `preToolUse` policy
|
|
1858
|
+
* hook the merge deliberately preserves) — or, equally fatally, by our own
|
|
1859
|
+
* hook whose best-effort ledger append failed. Either way the runner cannot
|
|
1860
|
+
* pause for approval (an approval grants a token only OUR hook reads; the
|
|
1861
|
+
* foreign hook would deny the re-attempt forever), so the caller surfaces an
|
|
1862
|
+
* explicit failure instead of completing with the work silently undone.
|
|
1863
|
+
*
|
|
1864
|
+
* Attribution, in order:
|
|
1865
|
+
* 1. Any `fail-closed` ledger entry → the gate itself was broken this turn and
|
|
1866
|
+
* denied EVERYTHING it saw; per-call correlation is meaningless, so every
|
|
1867
|
+
* hook block is attributed to our own (broken) gate. Nothing is reported.
|
|
1868
|
+
* 2. Exact identity: the call's {@link toolCallIdentityToken} appears in the
|
|
1869
|
+
* ledger (any kind — approval gates were overlaid to WAITING_APPROVAL or
|
|
1870
|
+
* collapsed by the reconcile that runs first, so a still-FAILED row here is
|
|
1871
|
+
* typically a secret/capture-error deny, correctly attributed as ours).
|
|
1872
|
+
* 3. Normalized-path fallback: the same abs-vs-rel drift the reconcile's
|
|
1873
|
+
* second pass handles — a FILE call matches a ledger entry by (category,
|
|
1874
|
+
* workspace-normalized path) even when the raw tokens differ.
|
|
1875
|
+
*
|
|
1876
|
+
* Scoped to THIS turn's messages (from `turnStartMessageIndex`): seeded
|
|
1877
|
+
* prior-turn rows were already adjudicated and must never re-trigger.
|
|
1878
|
+
* Deliberately conservative: an ordinary tool failure (no hook-block text)
|
|
1879
|
+
* is never reported, and a foreign hook denying with fully custom text evades
|
|
1880
|
+
* the marker match (the documented residual — the install-time
|
|
1881
|
+
* foreignGatingHooks warning still fires for diagnosability).
|
|
1882
|
+
*/
|
|
1883
|
+
export function detectUnattributedHookBlocks(
|
|
1884
|
+
messages: readonly AgentMessage[],
|
|
1885
|
+
turnStartMessageIndex: number,
|
|
1886
|
+
ledger: readonly DeniedLedgerEntry[],
|
|
1887
|
+
workspaceRoot?: string,
|
|
1888
|
+
): UnattributedHookBlock[] {
|
|
1889
|
+
if (ledger.some((e) => denialKindOf(e) === "fail-closed")) return [];
|
|
1890
|
+
|
|
1891
|
+
const ledgerTokens = new Set(ledger.map((e) => e.token));
|
|
1892
|
+
const ledgerNormalizedSalients = new Set<string>();
|
|
1893
|
+
if (workspaceRoot) {
|
|
1894
|
+
for (const entry of ledger) {
|
|
1895
|
+
const decoded = decodeIdentityToken(entry.token);
|
|
1896
|
+
if (!decoded) continue;
|
|
1897
|
+
const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
|
|
1898
|
+
if (normalized) ledgerNormalizedSalients.add(normalized);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
const blocks: UnattributedHookBlock[] = [];
|
|
1903
|
+
for (const msg of messages.slice(Math.max(0, turnStartMessageIndex))) {
|
|
1904
|
+
for (const tc of msg.toolCalls) {
|
|
1905
|
+
if (tc.status !== ToolCallStatus.TOOL_CALL_FAILED) continue;
|
|
1906
|
+
if (!isHookBlockError(tc.error)) continue;
|
|
1907
|
+
if (ledgerTokens.has(toolCallIdentityToken(tc))) continue;
|
|
1908
|
+
if (workspaceRoot) {
|
|
1909
|
+
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
1910
|
+
const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
|
|
1911
|
+
if (normalized && ledgerNormalizedSalients.has(normalized)) continue;
|
|
1912
|
+
}
|
|
1913
|
+
blocks.push({ toolCallId: tc.id, toolName: tc.name, error: tc.error });
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return blocks;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1796
1919
|
/**
|
|
1797
1920
|
* Compute a streamed tool call's identity token in the same canonical space the
|
|
1798
1921
|
* preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* The Cursor harness's turn boundary — the single post-run pipeline that turns
|
|
3
3
|
* a finished agent run into the durable review surfaces:
|
|
4
4
|
*
|
|
5
|
-
* 1. read the denial ledger the preToolUse hook appended this turn
|
|
5
|
+
* 1. read the denial ledger the preToolUse hook appended this turn (ALL
|
|
6
|
+
* kinds — every entry means "this action did not execute"; only the
|
|
7
|
+
* approval-kind subset may pause, see approval-state.ts);
|
|
6
8
|
* 2. derive the approved-command provenance (DD-28 auto-keep facts);
|
|
7
9
|
* 3. capture the turn's net file change set to the file_review ledger
|
|
8
10
|
* (CANDIDATE_CAPTURED) and stamp the flowed edit rows;
|
|
9
|
-
* 4. reconcile denied tool calls to WAITING_APPROVAL gate rows
|
|
10
|
-
* model's provisional post-denial narration
|
|
11
|
+
* 4. reconcile denied (approval-kind) tool calls to WAITING_APPROVAL gate rows
|
|
12
|
+
* and redact the model's provisional post-denial narration;
|
|
13
|
+
* 5. detect UNATTRIBUTED hook blocks (issue #205) — a tool blocked by a hook
|
|
14
|
+
* with no ledger entry of any kind was denied by a FOREIGN hook the merge
|
|
15
|
+
* preserved, and the caller fails the run rather than completing silently.
|
|
11
16
|
*
|
|
12
17
|
* Extracted from the activity entry point (index.ts Phase 12) so it is directly
|
|
13
18
|
* unit-testable AND re-enterable: the poisoned-handle / transport-timeout
|
|
@@ -32,12 +37,20 @@ import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
|
|
|
32
37
|
import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
33
38
|
import type { ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
34
39
|
import type { MergedToolPolicy } from "../../shared/approval-policy.js";
|
|
35
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
approvalDenials,
|
|
42
|
+
denialKindOf,
|
|
43
|
+
primaryToken,
|
|
44
|
+
readDenialLedger,
|
|
45
|
+
type ApprovalGrant,
|
|
46
|
+
} from "./approval-state.js";
|
|
36
47
|
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
37
48
|
import { captureTurnToLedger } from "./capture-flow.js";
|
|
38
49
|
import {
|
|
39
50
|
clearProvisionalPostDenialNarration,
|
|
51
|
+
detectUnattributedHookBlocks,
|
|
40
52
|
reconcileDeniedToolCalls,
|
|
53
|
+
type UnattributedHookBlock,
|
|
41
54
|
} from "./message-translator.js";
|
|
42
55
|
|
|
43
56
|
// How long the boundary waits for the first-denial-stop's run.cancel() to
|
|
@@ -96,6 +109,13 @@ export interface TurnBoundaryOptions {
|
|
|
96
109
|
* normal completion path and the recovery retries, which have no early stop).
|
|
97
110
|
*/
|
|
98
111
|
readonly denialCancelSettled?: Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Foreign (non-Stigmer) hook commands the gate install preserved on the
|
|
114
|
+
* gating events (see HitlGateHandle.foreignGatingHooks). Used only for
|
|
115
|
+
* diagnostics: when an unattributed hook block is detected, these name the
|
|
116
|
+
* likely culprit in the logs and the caller's failure message.
|
|
117
|
+
*/
|
|
118
|
+
readonly foreignGatingHooks?: readonly string[];
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
export interface TurnBoundaryResult {
|
|
@@ -109,6 +129,15 @@ export interface TurnBoundaryResult {
|
|
|
109
129
|
readonly capturedChangeCount: number;
|
|
110
130
|
/** Denied tool calls reconciled to WAITING_APPROVAL gate rows this call. */
|
|
111
131
|
readonly deniedToolCallCount: number;
|
|
132
|
+
/**
|
|
133
|
+
* Hook-blocked tool calls this turn that NO denial-ledger entry accounts for
|
|
134
|
+
* (issue #205): a foreign `.cursor/hooks.json` hook — or our own hook with a
|
|
135
|
+
* failed ledger append — denied them, and Stigmer cannot approve on its
|
|
136
|
+
* behalf. When the turn is not otherwise pausing, the caller must surface an
|
|
137
|
+
* explicit EXECUTION_FAILED instead of completing with the work silently
|
|
138
|
+
* undone (a pausing turn is not silent — the caller logs and pauses as usual).
|
|
139
|
+
*/
|
|
140
|
+
readonly unattributedHookBlocks: readonly UnattributedHookBlock[];
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
/**
|
|
@@ -140,6 +169,7 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
140
169
|
artifactStorage,
|
|
141
170
|
mergedPolicies,
|
|
142
171
|
denialCancelSettled,
|
|
172
|
+
foreignGatingHooks,
|
|
143
173
|
} = opts;
|
|
144
174
|
|
|
145
175
|
// The timebox keeps a wedged cancel from hanging the pause; the reconcile
|
|
@@ -153,7 +183,13 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
153
183
|
}),
|
|
154
184
|
]);
|
|
155
185
|
}
|
|
186
|
+
// The FULL ledger (all kinds) vs its APPROVAL subset — the kind split:
|
|
187
|
+
// every ledger entry means "this action did NOT execute", so the full set
|
|
188
|
+
// feeds capture stamping, DD-28 provenance, and foreign-hook attribution;
|
|
189
|
+
// only approval-kind entries may become WAITING_APPROVAL gates (a secret
|
|
190
|
+
// hard-block or fail-closed deny is attributable but never pausable).
|
|
156
191
|
const deniedLedger = await readDenialLedger(hitlDir ?? "");
|
|
192
|
+
const approvalLedger = approvalDenials(deniedLedger);
|
|
157
193
|
|
|
158
194
|
// Capture mode: author the net change set to the file_review ledger as the
|
|
159
195
|
// CANDIDATE_CAPTURED event (projected server-side to a file_change_set
|
|
@@ -230,7 +266,7 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
230
266
|
const gateWorkspaceBackend = new LocalWorkspaceBackend(primaryWorkspaceDir);
|
|
231
267
|
const deniedToolCalls = await reconcileDeniedToolCalls(
|
|
232
268
|
status.messages,
|
|
233
|
-
|
|
269
|
+
approvalLedger,
|
|
234
270
|
mergedPolicies,
|
|
235
271
|
gateWorkspaceBackend,
|
|
236
272
|
);
|
|
@@ -266,9 +302,46 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
266
302
|
}
|
|
267
303
|
}
|
|
268
304
|
|
|
305
|
+
// Issue #205 invariant: a blocked tool must never silently complete. Match
|
|
306
|
+
// this turn's hook-blocked FAILED rows against the FULL ledger (all kinds) —
|
|
307
|
+
// anything left over was denied by a hook that is not ours (or by our hook
|
|
308
|
+
// with a failed ledger append). The reconcile above ran first, so our own
|
|
309
|
+
// approval gates are already WAITING_APPROVAL/collapsed and cannot appear
|
|
310
|
+
// here as false positives.
|
|
311
|
+
const unattributedHookBlocks = detectUnattributedHookBlocks(
|
|
312
|
+
status.messages,
|
|
313
|
+
turnStartMessageIndex,
|
|
314
|
+
deniedLedger,
|
|
315
|
+
primaryWorkspaceDir,
|
|
316
|
+
);
|
|
317
|
+
const waiting = deniedToolCalls.length > 0 || capturedChangeCount > 0;
|
|
318
|
+
if (unattributedHookBlocks.length > 0) {
|
|
319
|
+
const culprits = (foreignGatingHooks?.length ?? 0) > 0
|
|
320
|
+
? ` — likely foreign workspace hook(s): ${foreignGatingHooks!.join(", ")}`
|
|
321
|
+
: "";
|
|
322
|
+
console.warn(
|
|
323
|
+
`ExecuteCursor turn boundary: ${unattributedHookBlocks.length} tool call(s) blocked ` +
|
|
324
|
+
`by a hook with NO matching denial-ledger entry ` +
|
|
325
|
+
`[${unattributedHookBlocks.map((b) => b.toolName).join(", ")}]${culprits} ` +
|
|
326
|
+
`(execution=${executionId})${waiting ? " — turn pauses anyway; not failing" : ""}`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
// Diagnosability for the broken-gate shape: fail-closed entries mean the
|
|
330
|
+
// approval state file was missing and the gate denied everything it saw.
|
|
331
|
+
// Attribution treats those blocks as ours (never a foreign-hook failure),
|
|
332
|
+
// but the condition itself deserves a loud log.
|
|
333
|
+
if (deniedLedger.some((e) => denialKindOf(e) === "fail-closed")) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`ExecuteCursor turn boundary: fail-closed denial(s) in the ledger — the approval ` +
|
|
336
|
+
`state file was missing during this turn and gated tools were denied ` +
|
|
337
|
+
`(execution=${executionId})`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
269
341
|
return {
|
|
270
|
-
waiting
|
|
342
|
+
waiting,
|
|
271
343
|
capturedChangeCount,
|
|
272
344
|
deniedToolCallCount: deniedToolCalls.length,
|
|
345
|
+
unattributedHookBlocks,
|
|
273
346
|
};
|
|
274
347
|
}
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
type StallWatchdog,
|
|
34
34
|
} from "../../shared/stall-watchdog.js";
|
|
35
35
|
import { shouldPersistStreamingStatus } from "./persist-decision.js";
|
|
36
|
-
import { readDenialLedger } from "./approval-state.js";
|
|
36
|
+
import { approvalDenials, readDenialLedger } from "./approval-state.js";
|
|
37
37
|
import {
|
|
38
38
|
captureFileChangeProgress,
|
|
39
39
|
type ProgressCaptureState,
|
|
@@ -305,7 +305,11 @@ export async function consumeCursorTurnStream(
|
|
|
305
305
|
// anchor's own row is always present for the turn-boundary gate overlay.
|
|
306
306
|
if (!state.firstDenialDetected && hitlDir && (state.denialLedgerDirty || event.type === "tool_call")) {
|
|
307
307
|
state.denialLedgerDirty = false;
|
|
308
|
-
|
|
308
|
+
// APPROVAL-kind denials only: a secret hard-block or fail-closed deny
|
|
309
|
+
// also lands in the (kinded) ledger for attribution, but must never
|
|
310
|
+
// stop the run — the agent is told to continue past those, and there
|
|
311
|
+
// is no approval the user could meaningfully grant.
|
|
312
|
+
const denials = approvalDenials(await readDenialLedger(hitlDir));
|
|
309
313
|
if (denials.length > 0) {
|
|
310
314
|
state.firstDenialDetected = true;
|
|
311
315
|
console.log(
|
|
@@ -103,6 +103,19 @@ export interface HitlGateHandle {
|
|
|
103
103
|
* gate is inert between turns.
|
|
104
104
|
*/
|
|
105
105
|
gateDir: string;
|
|
106
|
+
/**
|
|
107
|
+
* Commands of FOREIGN (non-Stigmer) hooks registered on the gating events
|
|
108
|
+
* (preToolUse/beforeMCPExecution) in the workspace's pre-existing hooks.json.
|
|
109
|
+
* The merge deliberately PRESERVES them (they are the user's own config), but
|
|
110
|
+
* because Cursor runs every configured hook and a deny from ANY of them blocks
|
|
111
|
+
* the tool, a foreign hook can deny the runner's tools without writing our
|
|
112
|
+
* denial ledger — the issue #205 silent-failure trigger. Surfaced here so the
|
|
113
|
+
* runner can log the exposure at install and name the likely culprit when the
|
|
114
|
+
* turn boundary detects an unattributed hook block. Empty when the workspace
|
|
115
|
+
* had no hooks.json, only Stigmer entries, or an unparseable file (which is
|
|
116
|
+
* REPLACED for the turn, so its hooks cannot run against us).
|
|
117
|
+
*/
|
|
118
|
+
foreignGatingHooks: readonly string[];
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
/** Absolute path + restore target for a single workspace file the gate manages. */
|
|
@@ -334,12 +347,12 @@ async function installWorkspaceHook(
|
|
|
334
347
|
originalRaw = null;
|
|
335
348
|
}
|
|
336
349
|
|
|
337
|
-
const { merged, restoreTo } = buildMergedConfig(originalRaw, registrations);
|
|
350
|
+
const { merged, restoreTo, foreignGatingHooks } = buildMergedConfig(originalRaw, registrations);
|
|
338
351
|
|
|
339
352
|
await mkdir(cursorDir, { recursive: true });
|
|
340
353
|
await writeFile(hooksJsonPath, merged, "utf-8");
|
|
341
354
|
|
|
342
|
-
return { hooksJsonPath, restoreTo };
|
|
355
|
+
return { hooksJsonPath, restoreTo, foreignGatingHooks };
|
|
343
356
|
}
|
|
344
357
|
|
|
345
358
|
/**
|
|
@@ -406,21 +419,35 @@ const STANDALONE_CONFIG = (registrations: HookRegistration[]): string =>
|
|
|
406
419
|
/**
|
|
407
420
|
* Merge our registrations into a hooks object: for each event, drop any stale
|
|
408
421
|
* Stigmer entry, then append our fresh one. Returns the merged hooks object, the
|
|
409
|
-
* cleaned (Stigmer-free) hooks for restore,
|
|
422
|
+
* cleaned (Stigmer-free) hooks for restore, whether anything was stripped, and
|
|
423
|
+
* the FOREIGN commands preserved on the gating events (see
|
|
424
|
+
* {@link HitlGateHandle.foreignGatingHooks} for why they matter).
|
|
410
425
|
*/
|
|
411
426
|
function mergeHooks(
|
|
412
427
|
existingHooks: Record<string, unknown>,
|
|
413
428
|
registrations: HookRegistration[],
|
|
414
|
-
): {
|
|
429
|
+
): {
|
|
430
|
+
hooks: Record<string, unknown>;
|
|
431
|
+
cleaned: Record<string, unknown>;
|
|
432
|
+
strippedStale: boolean;
|
|
433
|
+
foreignGatingHooks: string[];
|
|
434
|
+
} {
|
|
415
435
|
const hooks: Record<string, unknown> = { ...existingHooks };
|
|
416
436
|
const cleaned: Record<string, unknown> = { ...existingHooks };
|
|
417
437
|
let strippedStale = false;
|
|
438
|
+
const foreignGatingHooks: string[] = [];
|
|
418
439
|
|
|
419
440
|
for (const { event, scriptPath } of registrations) {
|
|
420
441
|
const hadEvent = Array.isArray(existingHooks[event]);
|
|
421
442
|
const existing = hadEvent ? (existingHooks[event] as unknown[]) : [];
|
|
422
443
|
const userEntries = existing.filter((e) => !isStigmerHookEntry(e));
|
|
423
444
|
if (userEntries.length !== existing.length) strippedStale = true;
|
|
445
|
+
// The user entries surviving on a GATING event are exactly the hooks that
|
|
446
|
+
// can deny the runner's tools without touching our denial ledger.
|
|
447
|
+
for (const entry of userEntries) {
|
|
448
|
+
const command = (entry as { command?: unknown } | null)?.command;
|
|
449
|
+
if (typeof command === "string" && command) foreignGatingHooks.push(command);
|
|
450
|
+
}
|
|
424
451
|
|
|
425
452
|
hooks[event] = [...userEntries, buildHookEntry(scriptPath)];
|
|
426
453
|
// Restore target keeps the event key only if the user originally had it, so
|
|
@@ -428,12 +455,12 @@ function mergeHooks(
|
|
|
428
455
|
if (hadEvent) cleaned[event] = userEntries;
|
|
429
456
|
}
|
|
430
457
|
|
|
431
|
-
return { hooks, cleaned, strippedStale };
|
|
458
|
+
return { hooks, cleaned, strippedStale, foreignGatingHooks };
|
|
432
459
|
}
|
|
433
460
|
|
|
434
461
|
/**
|
|
435
|
-
* Compute the merged hooks.json to write for this turn
|
|
436
|
-
*
|
|
462
|
+
* Compute the merged hooks.json to write for this turn, the content to restore
|
|
463
|
+
* afterward, and the foreign gating hooks the merge preserved.
|
|
437
464
|
*
|
|
438
465
|
* - No existing file → write our standalone config; restore by deleting (null).
|
|
439
466
|
* - Existing, parseable file → append our entry to each registered event array,
|
|
@@ -448,19 +475,19 @@ function mergeHooks(
|
|
|
448
475
|
export function buildMergedConfig(
|
|
449
476
|
originalRaw: string | null,
|
|
450
477
|
registrations: HookRegistration[],
|
|
451
|
-
): { merged: string; restoreTo: string | null } {
|
|
478
|
+
): { merged: string; restoreTo: string | null; foreignGatingHooks: string[] } {
|
|
452
479
|
if (originalRaw === null) {
|
|
453
|
-
return { merged: STANDALONE_CONFIG(registrations), restoreTo: null };
|
|
480
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: null, foreignGatingHooks: [] };
|
|
454
481
|
}
|
|
455
482
|
|
|
456
483
|
let parsed: unknown;
|
|
457
484
|
try {
|
|
458
485
|
parsed = JSON.parse(originalRaw);
|
|
459
486
|
} catch {
|
|
460
|
-
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
|
|
487
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
|
|
461
488
|
}
|
|
462
489
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
463
|
-
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
|
|
490
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
|
|
464
491
|
}
|
|
465
492
|
|
|
466
493
|
const root = parsed as Record<string, unknown>;
|
|
@@ -470,13 +497,14 @@ export function buildMergedConfig(
|
|
|
470
497
|
: {};
|
|
471
498
|
const version = typeof root.version === "number" ? root.version : 1;
|
|
472
499
|
|
|
473
|
-
const { hooks: mergedHooks, cleaned, strippedStale } =
|
|
500
|
+
const { hooks: mergedHooks, cleaned, strippedStale, foreignGatingHooks } =
|
|
501
|
+
mergeHooks(hooks, registrations);
|
|
474
502
|
|
|
475
503
|
const merged = JSON.stringify({ ...root, version, hooks: mergedHooks }, null, 2);
|
|
476
504
|
|
|
477
505
|
// No stale Stigmer entry → restore the user's exact original bytes.
|
|
478
506
|
if (!strippedStale) {
|
|
479
|
-
return { merged, restoreTo: originalRaw };
|
|
507
|
+
return { merged, restoreTo: originalRaw, foreignGatingHooks };
|
|
480
508
|
}
|
|
481
509
|
|
|
482
510
|
// We stripped a stale Stigmer entry, so never restore the original bytes (that
|
|
@@ -495,5 +523,5 @@ export function buildMergedConfig(
|
|
|
495
523
|
? null
|
|
496
524
|
: JSON.stringify({ ...root, version, hooks: cleaned }, null, 2);
|
|
497
525
|
|
|
498
|
-
return { merged, restoreTo };
|
|
526
|
+
return { merged, restoreTo, foreignGatingHooks };
|
|
499
527
|
}
|
|
@@ -3,12 +3,15 @@ import { resolveEnvironment } from "../environment.js";
|
|
|
3
3
|
|
|
4
4
|
function createMockClient(execContext: any) {
|
|
5
5
|
return {
|
|
6
|
+
// No scoped token — the OSS/local shape (no runner credential to exchange).
|
|
7
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
6
8
|
getExecutionContextByExecutionId: vi.fn().mockResolvedValue(execContext),
|
|
7
9
|
} as any;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
function createNotFoundClient() {
|
|
11
13
|
return {
|
|
14
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
12
15
|
getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
|
|
13
16
|
Object.assign(new Error("not found"), { code: 5 }),
|
|
14
17
|
),
|
|
@@ -81,6 +84,7 @@ describe("resolveEnvironment", () => {
|
|
|
81
84
|
|
|
82
85
|
it("propagates non-NOT_FOUND errors", async () => {
|
|
83
86
|
const client = {
|
|
87
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
84
88
|
getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
|
|
85
89
|
new Error("connection refused"),
|
|
86
90
|
),
|
|
@@ -92,6 +96,7 @@ describe("resolveEnvironment", () => {
|
|
|
92
96
|
|
|
93
97
|
it("handles NOT_FOUND with lowercase code", async () => {
|
|
94
98
|
const client = {
|
|
99
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
95
100
|
getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
|
|
96
101
|
Object.assign(new Error("not found"), { code: "not_found" }),
|
|
97
102
|
),
|