@stigmer/runner 3.0.2-dev.20260609093630 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/approval-policy.d.ts +55 -16
- package/dist/activities/execute-cursor/approval-policy.js +93 -31
- package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +54 -26
- package/dist/activities/execute-cursor/approval-state.js +41 -26
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +31 -12
- package/dist/activities/execute-cursor/hook-script.js +93 -52
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +23 -0
- package/dist/activities/execute-cursor/message-translator.js +100 -54
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +93 -37
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +33 -18
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +149 -0
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +93 -0
- package/src/activities/execute-cursor/approval-policy.ts +113 -31
- package/src/activities/execute-cursor/approval-state.ts +74 -32
- package/src/activities/execute-cursor/hook-script.ts +94 -52
- package/src/activities/execute-cursor/message-translator.ts +114 -57
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
|
|
20
20
|
import type { ToolApprovalOverride } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
21
|
+
import { ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
21
22
|
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
23
|
+
import { classifyTool } from "../../shared/tool-kind.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* A single tool's merged approval decision after evaluating all policy layers.
|
|
@@ -31,61 +33,141 @@ export interface MergedToolPolicy {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* Built-in
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Built-in Cursor tools the preToolUse hook gates, named as the hook receives
|
|
37
|
+
* them.
|
|
38
|
+
*
|
|
39
|
+
* Critical: the Cursor preToolUse hook and the SDK event stream use DIFFERENT
|
|
40
|
+
* tool taxonomies for the same operation. The hook's `tool_name` is PascalCase
|
|
41
|
+
* (`Write` for any file create/edit, `Shell`, `Delete`); the stream's
|
|
42
|
+
* `event.name` is lowercase (`edit`, `shell`, `delete`). This set is the HOOK
|
|
43
|
+
* taxonomy because it is consulted only to build the hook's gated set and its
|
|
44
|
+
* name->category mapping. Cross-layer correlation never compares these raw
|
|
45
|
+
* names — it uses {@link approvalCategory} (see below).
|
|
40
46
|
*/
|
|
41
|
-
const BUILT_IN_GATED = new
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const BUILT_IN_GATED: ReadonlySet<string> = new Set([
|
|
48
|
+
"Write",
|
|
49
|
+
"StrReplace",
|
|
50
|
+
"EditNotebook",
|
|
51
|
+
"Shell",
|
|
52
|
+
"Delete",
|
|
47
53
|
]);
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Canonical approval category for a gated tool, derived from EITHER taxonomy's
|
|
57
|
+
* name via the shared {@link classifyTool}.
|
|
58
|
+
*
|
|
59
|
+
* The hook (`Write`/`Shell`/`Delete`) and the stream (`edit`/`shell`/`delete`)
|
|
60
|
+
* name the same operation differently, so neither raw name is a stable
|
|
61
|
+
* cross-layer identity. The category collapses both onto one value so the denial
|
|
62
|
+
* ledger (recorded by the hook) correlates to the streamed tool call (read by
|
|
63
|
+
* the runner) and so an approval grant matches the agent's re-attempt on
|
|
64
|
+
* reinvocation regardless of which taxonomy named it. `FILE_WRITE` and
|
|
65
|
+
* `FILE_EDIT` both map to `write` because the Cursor hook reports every file
|
|
66
|
+
* mutation — create or edit — as `Write`.
|
|
67
|
+
*
|
|
68
|
+
* Returns undefined for non-gated tools (read-only built-ins, MCP tools, and
|
|
69
|
+
* anything `classifyTool` does not place in a mutating kind).
|
|
70
|
+
*/
|
|
71
|
+
export type ApprovalCategory = "write" | "delete" | "shell";
|
|
72
|
+
|
|
73
|
+
export function approvalCategory(toolName: string): ApprovalCategory | undefined {
|
|
74
|
+
switch (classifyTool(toolName)) {
|
|
75
|
+
case ToolKind.FILE_WRITE:
|
|
76
|
+
case ToolKind.FILE_EDIT:
|
|
77
|
+
return "write";
|
|
78
|
+
case ToolKind.FILE_DELETE:
|
|
79
|
+
return "delete";
|
|
80
|
+
case ToolKind.SHELL:
|
|
81
|
+
return "shell";
|
|
82
|
+
default:
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Human-readable approval-message template per canonical category. Keyed by
|
|
89
|
+
* category (not raw tool name) so a denial surfaced from either taxonomy renders
|
|
90
|
+
* the same message. Placeholders resolve against the tool args via
|
|
91
|
+
* {@link resolveApprovalMessage}; `{{args.path}}` and `{{args.command}}` are the
|
|
92
|
+
* stream-side field names (the runner builds the approval surface from the
|
|
93
|
+
* streamed tool call, whose args use `path`/`command`).
|
|
94
|
+
*/
|
|
95
|
+
const CATEGORY_APPROVAL_MESSAGE: Record<ApprovalCategory, string> = {
|
|
96
|
+
write: "Write file: {{args.path}}",
|
|
97
|
+
delete: "Delete: {{args.path}}",
|
|
98
|
+
shell: "Run command: {{args.command}}",
|
|
99
|
+
};
|
|
100
|
+
|
|
49
101
|
/**
|
|
50
102
|
* Top-level tool-argument fields, in priority order, that identify the specific
|
|
51
|
-
* resource a built-in tool acts on.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
103
|
+
* resource a built-in tool acts on. The list deliberately spans BOTH taxonomies'
|
|
104
|
+
* arg shapes: the hook input names a file `file_path` and the stream names it
|
|
105
|
+
* `path`; both name a shell command `command`. Extracting the same resource
|
|
106
|
+
* VALUE on both sides (the absolute path / the command string) is what lets the
|
|
107
|
+
* hook-recorded denial token equal the stream-computed token. Authored here once
|
|
108
|
+
* and injected into the generated preToolUse hook script so the runner and the
|
|
109
|
+
* hook never disagree on which field to match.
|
|
55
110
|
*/
|
|
56
|
-
export const SALIENT_ARG_FIELDS = ["
|
|
111
|
+
export const SALIENT_ARG_FIELDS = ["file_path", "path", "target_notebook", "command"] as const;
|
|
57
112
|
|
|
58
113
|
/**
|
|
59
114
|
* Check whether a built-in (non-MCP) Cursor tool requires user approval.
|
|
60
115
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
116
|
+
* Resolved via {@link approvalCategory} so it answers correctly for BOTH
|
|
117
|
+
* taxonomies — the hook's `Write`/`Shell`/`Delete` and the stream's
|
|
118
|
+
* `edit`/`shell`/`delete` all return true. Only mutating/destructive tools are
|
|
119
|
+
* gated; everything else (read-only built-ins, and — at the hook layer —
|
|
120
|
+
* auto-approved MCP tools) is allowed. This "gate the dangerous set, allow the
|
|
121
|
+
* rest" model mirrors the native harness's resolveToolApproval. It is
|
|
122
|
+
* deliberately fail-OPEN for unknown tools: the merged MCP policy map carries
|
|
123
|
+
* only the tools that REQUIRE approval, so a fail-closed default would wrongly
|
|
124
|
+
* deny every auto-approved MCP tool, which the hook cannot distinguish from an
|
|
125
|
+
* unknown built-in by name.
|
|
69
126
|
*/
|
|
70
127
|
export function builtInRequiresApproval(toolName: string): boolean {
|
|
71
|
-
return
|
|
128
|
+
return approvalCategory(toolName) !== undefined;
|
|
72
129
|
}
|
|
73
130
|
|
|
74
131
|
/**
|
|
75
132
|
* Returns the built-in tool names that require approval (the gated set the
|
|
76
133
|
* preToolUse hook denies unless auto-approved or granted on reinvocation).
|
|
134
|
+
*
|
|
135
|
+
* These are HOOK-taxonomy names (PascalCase), because the hook matches its own
|
|
136
|
+
* `tool_name`. See {@link approvalCategory} for the cross-layer identity.
|
|
77
137
|
*/
|
|
78
138
|
export function getBuiltInGatedList(): string[] {
|
|
79
|
-
return [...BUILT_IN_GATED
|
|
139
|
+
return [...BUILT_IN_GATED];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Returns the gated built-in tools as `(hookToolName, category)` pairs.
|
|
144
|
+
*
|
|
145
|
+
* Injected into the generated preToolUse hook so the bash script can map its
|
|
146
|
+
* incoming `tool_name` to the canonical category used for the denial/grant
|
|
147
|
+
* token — the same category the runner computes from the stream side via
|
|
148
|
+
* {@link approvalCategory}. Authoring it here keeps the mapping single-sourced;
|
|
149
|
+
* a gated built-in with no category would be a programming error, so it is
|
|
150
|
+
* filtered out (and would simply not be gated rather than crash the hook).
|
|
151
|
+
*/
|
|
152
|
+
export function getBuiltInGatedCategories(): Array<[string, ApprovalCategory]> {
|
|
153
|
+
const pairs: Array<[string, ApprovalCategory]> = [];
|
|
154
|
+
for (const name of BUILT_IN_GATED) {
|
|
155
|
+
const category = approvalCategory(name);
|
|
156
|
+
if (category) pairs.push([name, category]);
|
|
157
|
+
}
|
|
158
|
+
return pairs;
|
|
80
159
|
}
|
|
81
160
|
|
|
82
161
|
/**
|
|
83
|
-
* Approval-message template for a gated built-in tool, or
|
|
84
|
-
* tool is not
|
|
85
|
-
*
|
|
162
|
+
* Approval-message template for a gated built-in tool (either taxonomy), or
|
|
163
|
+
* undefined when the tool is not gated. Resolved via {@link approvalCategory}
|
|
164
|
+
* so stream-side names (`edit`/`shell`/`delete`) and hook-side names
|
|
165
|
+
* (`Write`/`Shell`/`Delete`) both map to the same template. Callers resolve the
|
|
166
|
+
* placeholders against the tool args via resolveApprovalMessage.
|
|
86
167
|
*/
|
|
87
168
|
export function getBuiltInApprovalMessage(toolName: string): string | undefined {
|
|
88
|
-
|
|
169
|
+
const category = approvalCategory(toolName);
|
|
170
|
+
return category ? CATEGORY_APPROVAL_MESSAGE[category] : undefined;
|
|
89
171
|
}
|
|
90
172
|
|
|
91
173
|
/**
|
|
@@ -8,27 +8,29 @@
|
|
|
8
8
|
* State file format (JSON):
|
|
9
9
|
* {
|
|
10
10
|
* "autoApproveAll": false,
|
|
11
|
-
* "builtInGatedList": ["Write", "StrReplace", "Shell", ...],
|
|
12
11
|
* "mcpToolPolicies": {
|
|
13
12
|
* "apply_cloud_resource": { "requiresApproval": true, "message": "..." }
|
|
14
13
|
* },
|
|
15
|
-
* "approvedGrants": [{ "toolName": "
|
|
16
|
-
* "approvedGrantTokens": ["
|
|
14
|
+
* "approvedGrants": [{ "toolName": "edit", "mcpServerSlug": "", "key": "write", "salient": "a.txt" }],
|
|
15
|
+
* "approvedGrantTokens": ["d3JpdGUKYS50eHQ="]
|
|
17
16
|
* }
|
|
18
17
|
*
|
|
19
|
-
* The hook gates
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* The hook gates the dangerous built-in set and the MCP tools that require
|
|
19
|
+
* approval (mcpToolPolicies, which by construction holds only require-approval
|
|
20
|
+
* entries); every other tool is allowed. The gated built-in set and its
|
|
21
|
+
* name->category mapping are baked into the generated hook script (from
|
|
22
|
+
* approval-policy.ts), not carried in the state file — only the dynamic inputs
|
|
23
|
+
* (autoApproveAll, mcpToolPolicies, approvedGrantTokens) live here. This mirrors
|
|
24
|
+
* the native harness and avoids denying auto-approved MCP tools, which are
|
|
25
|
+
* absent from the policy map and indistinguishable from unknown tools by name.
|
|
24
26
|
*
|
|
25
27
|
* Why grants instead of tool-call ids: a resumed Cursor agent re-issues the
|
|
26
28
|
* approved tool with a BRAND NEW call id, so matching on the original call id
|
|
27
|
-
* can never let the re-attempt through. Instead we grant by tool
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* only if its (
|
|
31
|
-
* tools and any newly proposed dangerous tool are re-gated.
|
|
29
|
+
* can never let the re-attempt through. Instead we grant by canonical tool
|
|
30
|
+
* identity — the approval category plus a "salient" resource value (the file
|
|
31
|
+
* path, the shell command; see {@link toolIdentity}). On reinvocation the hook
|
|
32
|
+
* allows a tool call only if its (category, salient) matches an approved grant;
|
|
33
|
+
* rejected/skipped tools and any newly proposed dangerous tool are re-gated.
|
|
32
34
|
*
|
|
33
35
|
* Tokens: the hook is a self-contained bash script, so it cannot parse an array
|
|
34
36
|
* of grant objects. `approvedGrantTokens` is the flat, base64-encoded form of
|
|
@@ -56,30 +58,66 @@ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agente
|
|
|
56
58
|
import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
57
59
|
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
58
60
|
import type { MergedToolPolicy } from "./approval-policy.js";
|
|
59
|
-
import {
|
|
61
|
+
import { extractArgKey, approvalCategory } from "./approval-policy.js";
|
|
60
62
|
|
|
61
63
|
export interface McpToolPolicyEntry {
|
|
62
64
|
requiresApproval: boolean;
|
|
63
65
|
message?: string;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The canonical, taxonomy-agnostic identity of a tool call.
|
|
70
|
+
*
|
|
71
|
+
* The Cursor preToolUse hook and the SDK stream name the same operation
|
|
72
|
+
* differently (hook `Write`/`Shell`/`Delete`; stream `edit`/`shell`/`delete`),
|
|
73
|
+
* so the raw tool name cannot be a cross-layer identity. Instead:
|
|
74
|
+
* - `key` is the {@link approvalCategory} (`write`/`delete`/`shell`) for gated
|
|
75
|
+
* built-ins, and the tool name for MCP tools (whose name is consistent across
|
|
76
|
+
* layers). It is the part that survives the name divergence.
|
|
77
|
+
* - `salient` is the resource the tool acts on (the absolute file path or the
|
|
78
|
+
* shell command) — identical on both sides because it is the argument VALUE,
|
|
79
|
+
* not the field name. Empty for MCP tools, matched by `key` alone.
|
|
80
|
+
*
|
|
81
|
+
* The denial ledger (hook) and the stream reconciliation (runner) both reduce a
|
|
82
|
+
* tool call to this identity, so they correlate exactly; an approval grant uses
|
|
83
|
+
* the same identity so the agent's re-attempt is allowed on reinvocation even
|
|
84
|
+
* though it carries a fresh tool-call id and a different-taxonomy name.
|
|
85
|
+
*/
|
|
86
|
+
export interface ToolIdentity {
|
|
87
|
+
key: string;
|
|
88
|
+
salient: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function toolIdentity(
|
|
92
|
+
toolName: string,
|
|
93
|
+
mcpServerSlug: string,
|
|
94
|
+
args: Record<string, unknown> | undefined,
|
|
95
|
+
): ToolIdentity {
|
|
96
|
+
if (mcpServerSlug) {
|
|
97
|
+
return { key: toolName, salient: "" };
|
|
98
|
+
}
|
|
99
|
+
const category = approvalCategory(toolName);
|
|
100
|
+
// A gated built-in keys on its category; an unknown/non-gated tool falls back
|
|
101
|
+
// to its own name (harmless — it is not gated, so it never enters the ledger).
|
|
102
|
+
return { key: category ?? toolName, salient: extractArgKey(args) };
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
/**
|
|
67
106
|
* The identity of an approved tool call, stable across agent resume.
|
|
68
107
|
*
|
|
69
|
-
* -
|
|
70
|
-
*
|
|
71
|
-
* -
|
|
72
|
-
* the grant then matches by name alone, since the user approved that tool.
|
|
108
|
+
* - `key`/`salient` are the canonical {@link ToolIdentity} the hook matches on.
|
|
109
|
+
* - `toolName`/`mcpServerSlug` are retained for readability, debugging, and the
|
|
110
|
+
* structured-vs-token cross-check (the two are always generated together).
|
|
73
111
|
*/
|
|
74
112
|
export interface ApprovalGrant {
|
|
75
113
|
toolName: string;
|
|
76
114
|
mcpServerSlug: string;
|
|
77
|
-
|
|
115
|
+
key: string;
|
|
116
|
+
salient: string;
|
|
78
117
|
}
|
|
79
118
|
|
|
80
119
|
export interface ApprovalStateFile {
|
|
81
120
|
autoApproveAll: boolean;
|
|
82
|
-
builtInGatedList: string[];
|
|
83
121
|
mcpToolPolicies: Record<string, McpToolPolicyEntry>;
|
|
84
122
|
approvedGrants: ApprovalGrant[];
|
|
85
123
|
approvedGrantTokens: string[];
|
|
@@ -87,17 +125,19 @@ export interface ApprovalStateFile {
|
|
|
87
125
|
|
|
88
126
|
/**
|
|
89
127
|
* Compute the flat token the bash hook matches on. The hook recomputes the same
|
|
90
|
-
* token from the incoming tool call (`base64(
|
|
91
|
-
* encoding here must stay byte-identical to the
|
|
128
|
+
* token from the incoming tool call (`base64(key \n salient)` — see
|
|
129
|
+
* {@link toolIdentity}), so the encoding here must stay byte-identical to the
|
|
130
|
+
* hook script in hook-script.ts.
|
|
92
131
|
*/
|
|
93
|
-
export function grantToken(
|
|
94
|
-
return Buffer.from(`${
|
|
132
|
+
export function grantToken(key: string, salient: string): string {
|
|
133
|
+
return Buffer.from(`${key}\n${salient}`, "utf-8").toString("base64");
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
/**
|
|
98
137
|
* Build approval grants from the pending approvals the user adjudicated and
|
|
99
|
-
* their decisions. Only APPROVE decisions produce grants.
|
|
100
|
-
*
|
|
138
|
+
* their decisions. Only APPROVE / APPROVE_ALL decisions produce grants. Each
|
|
139
|
+
* grant carries the canonical {@link ToolIdentity} (category + salient resource)
|
|
140
|
+
* so the hook allows the exact approved resource on the resumed turn.
|
|
101
141
|
*/
|
|
102
142
|
export function buildApprovalGrants(
|
|
103
143
|
pendingApprovals: PendingApproval[],
|
|
@@ -113,11 +153,12 @@ export function buildApprovalGrants(
|
|
|
113
153
|
const decision = decisions.get(pa.toolCallId);
|
|
114
154
|
if (decision !== ApprovalAction.APPROVE && decision !== ApprovalAction.APPROVE_ALL) continue;
|
|
115
155
|
|
|
116
|
-
const
|
|
156
|
+
const id = toolIdentity(pa.toolName, pa.mcpServerSlug, parseArgs(pa.argsPreview));
|
|
117
157
|
grants.push({
|
|
118
158
|
toolName: pa.toolName,
|
|
119
159
|
mcpServerSlug: pa.mcpServerSlug,
|
|
120
|
-
|
|
160
|
+
key: id.key,
|
|
161
|
+
salient: id.salient,
|
|
121
162
|
});
|
|
122
163
|
}
|
|
123
164
|
return grants;
|
|
@@ -137,11 +178,13 @@ function parseArgs(argsPreview: string): Record<string, unknown> | undefined {
|
|
|
137
178
|
* Build the approval state file content from merged policies and any approval
|
|
138
179
|
* grants from a previous HITL cycle.
|
|
139
180
|
*
|
|
140
|
-
* The state file
|
|
141
|
-
* - builtInGatedList: dangerous built-in tools the hook denies (unless granted)
|
|
181
|
+
* The state file carries the hook script's DYNAMIC inputs:
|
|
142
182
|
* - mcpToolPolicies: per-tool policy for MCP tools requiring approval
|
|
143
183
|
* - approvedGrants / approvedGrantTokens: tools approved in the current HITL
|
|
144
184
|
* cycle, allowed through on reinvocation
|
|
185
|
+
*
|
|
186
|
+
* The static gated built-in set and its category mapping are baked into the
|
|
187
|
+
* generated hook script (from approval-policy.ts), not carried here.
|
|
145
188
|
*/
|
|
146
189
|
export function buildApprovalState(
|
|
147
190
|
mergedPolicies: Map<string, MergedToolPolicy>,
|
|
@@ -160,10 +203,9 @@ export function buildApprovalState(
|
|
|
160
203
|
|
|
161
204
|
return {
|
|
162
205
|
autoApproveAll,
|
|
163
|
-
builtInGatedList: getBuiltInGatedList(),
|
|
164
206
|
mcpToolPolicies,
|
|
165
207
|
approvedGrants,
|
|
166
|
-
approvedGrantTokens: approvedGrants.map((g) => grantToken(g.
|
|
208
|
+
approvedGrantTokens: approvedGrants.map((g) => grantToken(g.key, g.salient)),
|
|
167
209
|
};
|
|
168
210
|
}
|
|
169
211
|
|
|
@@ -18,59 +18,100 @@
|
|
|
18
18
|
*
|
|
19
19
|
* The script is self-contained (no Node.js required) for portability. It uses
|
|
20
20
|
* bash + grep/cut for lightweight JSON field extraction. All policy decisions
|
|
21
|
-
* are pre-computed by the runner into the state file
|
|
22
|
-
* mechanical field extraction and string
|
|
23
|
-
* authored once in TypeScript (approval-policy.ts
|
|
21
|
+
* are pre-computed by the runner into the state file (and into this generated
|
|
22
|
+
* script); the hook only performs mechanical field extraction and string
|
|
23
|
+
* lookups — the policy itself is authored once in TypeScript (approval-policy.ts
|
|
24
|
+
* / approval-state.ts).
|
|
25
|
+
*
|
|
26
|
+
* Cross-taxonomy identity (the crux):
|
|
27
|
+
* The preToolUse hook and the SDK event stream name the same operation
|
|
28
|
+
* differently — the hook receives PascalCase `tool_name` (`Write` for any file
|
|
29
|
+
* create/edit, `Shell`, `Delete`) while the stream emits lowercase `event.name`
|
|
30
|
+
* (`edit`, `shell`, `delete`). They also name the salient argument differently
|
|
31
|
+
* (`file_path` in the hook input vs `path` in the stream). So the hook and the
|
|
32
|
+
* runner cannot correlate on the raw name. Instead both reduce a tool call to a
|
|
33
|
+
* canonical identity — `base64(category \n salient)` — where `category` is the
|
|
34
|
+
* approval category (`write`/`delete`/`shell`, baked into the case statement
|
|
35
|
+
* below from approval-policy.ts) and `salient` is the resource VALUE (the file
|
|
36
|
+
* path or shell command), which is identical on both sides. The runner mirrors
|
|
37
|
+
* this exactly in approval-state.ts (toolIdentity + grantToken), so a denial
|
|
38
|
+
* recorded here correlates to the streamed tool call, and an approval grant
|
|
39
|
+
* matches the agent's re-attempt on reinvocation.
|
|
24
40
|
*
|
|
25
41
|
* Policy evaluation order (first match wins). The model is "gate the dangerous
|
|
26
42
|
* set, allow the rest" — matching the native harness and avoiding denial of
|
|
27
43
|
* auto-approved MCP tools (which are absent from mcpToolPolicies):
|
|
28
44
|
* 1. autoApproveAll → allow
|
|
29
|
-
* 2.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
45
|
+
* 2. Gated built-in (category non-empty):
|
|
46
|
+
* a. identity token in approvedGrantTokens → allow (reinvocation grant)
|
|
47
|
+
* b. otherwise → record denial, deny
|
|
48
|
+
* 3. MCP tool present in mcpToolPolicies (require-approval):
|
|
49
|
+
* a. name token in approvedGrantTokens → allow
|
|
50
|
+
* b. otherwise → record denial, deny
|
|
51
|
+
* 4. Everything else (read-only built-ins, auto-approved MCP, unknown) → allow
|
|
33
52
|
*/
|
|
34
53
|
|
|
35
|
-
import { SALIENT_ARG_FIELDS } from "./approval-policy.js";
|
|
54
|
+
import { SALIENT_ARG_FIELDS, getBuiltInGatedCategories } from "./approval-policy.js";
|
|
36
55
|
|
|
37
56
|
const APPROVAL_REQUIRED_AGENT_MESSAGE =
|
|
38
57
|
"STIGMER_APPROVAL_REQUIRED: This tool call requires user approval before " +
|
|
39
|
-
"execution. Do not attempt alternative approaches or workarounds
|
|
40
|
-
"execution will resume after the user
|
|
58
|
+
"execution. Do not attempt alternative approaches or workarounds (including " +
|
|
59
|
+
"shell commands). Stop and wait — the execution will resume after the user " +
|
|
60
|
+
"reviews and approves this tool call.";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the bash `case` arms that map an incoming hook `tool_name` to its
|
|
64
|
+
* canonical approval category. Generated from approval-policy.ts so the hook and
|
|
65
|
+
* the runner never disagree on which built-ins are gated or how they categorize.
|
|
66
|
+
*/
|
|
67
|
+
function buildCategoryCaseArms(): string {
|
|
68
|
+
const byCategory = new Map<string, string[]>();
|
|
69
|
+
for (const [name, category] of getBuiltInGatedCategories()) {
|
|
70
|
+
const names = byCategory.get(category) ?? [];
|
|
71
|
+
names.push(name);
|
|
72
|
+
byCategory.set(category, names);
|
|
73
|
+
}
|
|
74
|
+
const arms: string[] = [];
|
|
75
|
+
for (const [category, names] of byCategory) {
|
|
76
|
+
const pattern = names.map((n) => `"${n}"`).join("|");
|
|
77
|
+
arms.push(` ${pattern}) CATEGORY="${category}" ;;`);
|
|
78
|
+
}
|
|
79
|
+
return arms.join("\n");
|
|
80
|
+
}
|
|
41
81
|
|
|
42
82
|
/**
|
|
43
83
|
* Generates the bash hook script content.
|
|
44
84
|
*
|
|
45
85
|
* The script reads a JSON state file written by the cursor-runner before
|
|
46
86
|
* each agent.send() call. The state file is the single source of truth
|
|
47
|
-
* for
|
|
87
|
+
* for the dynamic approval inputs (autoApproveAll, mcpToolPolicies,
|
|
88
|
+
* approvedGrantTokens). The static policy (which built-ins are gated and their
|
|
89
|
+
* categories, and which arg fields are salient) is baked into the script at
|
|
90
|
+
* generation time from approval-policy.ts.
|
|
48
91
|
*
|
|
49
|
-
*
|
|
50
|
-
* recomputed here from the incoming tool call. The salient-arg field list is
|
|
51
|
-
* injected from SALIENT_ARG_FIELDS so the runner and the hook never disagree on
|
|
52
|
-
* which argument identifies the resource. The encoding must stay byte-identical
|
|
92
|
+
* The identity token encoding (`base64(key \n salient)`) must stay byte-identical
|
|
53
93
|
* to grantToken() in approval-state.ts.
|
|
54
94
|
*/
|
|
55
95
|
export function generateHookScript(stateFilePath: string, ledgerFilePath: string): string {
|
|
56
96
|
const salientFields = SALIENT_ARG_FIELDS.join(" ");
|
|
97
|
+
const categoryCaseArms = buildCategoryCaseArms();
|
|
57
98
|
return `#!/bin/bash
|
|
58
99
|
# Stigmer HITL approval hook for Cursor preToolUse
|
|
59
100
|
# Generated by cursor-runner — do not edit manually.
|
|
60
101
|
#
|
|
61
|
-
# Reads tool call from stdin (JSON), checks approval state file,
|
|
62
|
-
#
|
|
102
|
+
# Reads tool call from stdin (JSON), checks approval state file, returns a
|
|
103
|
+
# permission decision on stdout (JSON). On a deny, appends the call's canonical
|
|
63
104
|
# identity token to the denial ledger so the runner can mark the gated tool call
|
|
64
|
-
# as WAITING_APPROVAL.
|
|
105
|
+
# as WAITING_APPROVAL. See hook-script.ts for the cross-taxonomy identity design.
|
|
65
106
|
|
|
66
107
|
set -euo pipefail
|
|
67
108
|
|
|
68
109
|
INPUT=$(cat)
|
|
69
110
|
|
|
70
|
-
# Extract tool_name from the hook input JSON.
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
111
|
+
# Extract tool_name from the hook input JSON. The hook receives PascalCase names
|
|
112
|
+
# (Write/Shell/Delete/Read/...). Every extraction ends with '|| true': under
|
|
113
|
+
# 'set -e' a non-matching grep would otherwise abort the script and emit no
|
|
114
|
+
# decision.
|
|
74
115
|
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
75
116
|
|
|
76
117
|
STATE_FILE="${stateFilePath}"
|
|
@@ -90,66 +131,67 @@ if echo "$STATE" | grep -q '"autoApproveAll":true'; then
|
|
|
90
131
|
exit 0
|
|
91
132
|
fi
|
|
92
133
|
|
|
93
|
-
# ---
|
|
94
|
-
#
|
|
95
|
-
# match it against approvedGrantTokens. Match by (name + salient arg); fall back
|
|
96
|
-
# to name-only for grants with no salient arg (MCP tools). Salient-arg field
|
|
97
|
-
# order is injected from SALIENT_ARG_FIELDS (single source of truth).
|
|
98
|
-
TOKEN_NAME=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
|
|
99
|
-
if echo "$STATE" | grep -q "\\"$TOKEN_NAME\\""; then
|
|
100
|
-
echo '{"permission":"allow"}'
|
|
101
|
-
exit 0
|
|
102
|
-
fi
|
|
134
|
+
# --- Salient resource value (file path / command), spanning both taxonomies'
|
|
135
|
+
# arg field names (file_path here, path on the stream side). First match wins. ---
|
|
103
136
|
SALIENT=""
|
|
104
137
|
for field in ${salientFields}; do
|
|
105
138
|
v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
|
|
106
139
|
if [ -n "$v" ]; then SALIENT="$v"; break; fi
|
|
107
140
|
done
|
|
108
|
-
if [ -n "$SALIENT" ]; then
|
|
109
|
-
TOKEN_SALIENT=$(printf '%s\\n%s' "$TOOL_NAME" "$SALIENT" | base64 | tr -d '\\n')
|
|
110
|
-
if echo "$STATE" | grep -q "\\"$TOKEN_SALIENT\\""; then
|
|
111
|
-
echo '{"permission":"allow"}'
|
|
112
|
-
exit 0
|
|
113
|
-
fi
|
|
114
|
-
fi
|
|
115
141
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
142
|
+
# --- Canonical approval category for this hook tool_name (baked from
|
|
143
|
+
# approval-policy.ts). Empty for non-gated tools. ---
|
|
144
|
+
CATEGORY=""
|
|
145
|
+
case "$TOOL_NAME" in
|
|
146
|
+
${categoryCaseArms}
|
|
147
|
+
*) CATEGORY="" ;;
|
|
148
|
+
esac
|
|
120
149
|
|
|
121
150
|
# Append a denial record to the ledger. Best-effort: a ledger write failure must
|
|
122
151
|
# never abort the decision (the deny still goes out on stdout). toolName is raw
|
|
123
152
|
# for human-readable debugging; token drives correlation in the runner.
|
|
124
153
|
record_denial() {
|
|
125
|
-
echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$
|
|
154
|
+
echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$1"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
|
|
126
155
|
}
|
|
127
156
|
|
|
128
|
-
# ---
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
# --- 2. Gated built-in tools (category non-empty) ---
|
|
158
|
+
if [ -n "$CATEGORY" ]; then
|
|
159
|
+
# Canonical identity token: base64("$CATEGORY\\n$SALIENT").
|
|
160
|
+
TOKEN=$(printf '%s\\n%s' "$CATEGORY" "$SALIENT" | base64 | tr -d '\\n')
|
|
161
|
+
# Reinvocation grant: this exact resource was approved earlier → allow.
|
|
162
|
+
if echo "$STATE" | grep -qF "\\"$TOKEN\\""; then
|
|
163
|
+
echo '{"permission":"allow"}'
|
|
164
|
+
exit 0
|
|
165
|
+
fi
|
|
166
|
+
record_denial "$TOKEN"
|
|
132
167
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
|
|
133
168
|
exit 0
|
|
134
169
|
fi
|
|
135
170
|
|
|
136
|
-
# ---
|
|
171
|
+
# --- 3. MCP tools that require approval → deny ---
|
|
137
172
|
# mcpToolPolicies holds only require-approval tools (auto-approved MCP tools are
|
|
138
|
-
# absent), so presence means "deny" unless an entry is explicitly false.
|
|
173
|
+
# absent), so presence means "deny" unless an entry is explicitly false. MCP tool
|
|
174
|
+
# names are consistent across the hook and the stream, so the identity token is
|
|
175
|
+
# name-only: base64("$TOOL_NAME\\n").
|
|
139
176
|
if echo "$STATE" | grep -q "\\"mcpToolPolicies\\"" && [ -n "$TOOL_NAME" ]; then
|
|
140
177
|
TOOL_POLICY=$(echo "$STATE" | grep -o "\\"$TOOL_NAME\\":{[^}]*}" | head -1 || true)
|
|
141
178
|
if [ -n "$TOOL_POLICY" ] && ! echo "$TOOL_POLICY" | grep -q '"requiresApproval":false'; then
|
|
179
|
+
MCP_TOKEN=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
|
|
180
|
+
if echo "$STATE" | grep -qF "\\"$MCP_TOKEN\\""; then
|
|
181
|
+
echo '{"permission":"allow"}'
|
|
182
|
+
exit 0
|
|
183
|
+
fi
|
|
142
184
|
MSG=$(echo "$TOOL_POLICY" | grep -o '"message":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
143
185
|
if [ -z "$MSG" ]; then
|
|
144
186
|
MSG="Tool requires approval: $TOOL_NAME"
|
|
145
187
|
fi
|
|
146
|
-
record_denial
|
|
188
|
+
record_denial "$MCP_TOKEN"
|
|
147
189
|
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
|
|
148
190
|
exit 0
|
|
149
191
|
fi
|
|
150
192
|
fi
|
|
151
193
|
|
|
152
|
-
# ---
|
|
194
|
+
# --- 4. Everything else → allow ---
|
|
153
195
|
# Read-only built-ins, auto-approved MCP tools, and anything not explicitly
|
|
154
196
|
# gated. Fail-open mirrors the native harness (gate the dangerous set, allow the
|
|
155
197
|
# rest) and prevents denying auto-approved MCP tools the state cannot enumerate.
|