@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.
@@ -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 (non-MCP) Cursor tools that mutate the workspace or execute
35
- * commands. These require approval when auto_approve_all is false, mirroring
36
- * the native harness's DANGEROUS_PLATFORM_TOOLS (write/edit/create/delete/
37
- * execute/shell). Each value is an approval-message template resolved against
38
- * the tool args (see resolveApprovalMessage); its placeholder names the same
39
- * field the grant matcher keys on (path/command/target_notebook).
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 Map<string, string>([
42
- ["Write", "Write file: {{args.path}}"],
43
- ["StrReplace", "Edit file: {{args.path}}"],
44
- ["EditNotebook", "Edit notebook: {{args.target_notebook}}"],
45
- ["Shell", "Run command: {{args.command}}"],
46
- ["Delete", "Delete: {{args.path}}"],
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. Used to render approval messages and to key
52
- * HITL approval grants (see approval-state.ts). Authored here once and injected
53
- * into the generated preToolUse hook script so the runner and the hook always
54
- * agree on which field to match.
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 = ["path", "command", "target_notebook"] as const;
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
- * Only the explicitly gated, mutating/destructive tools require approval;
62
- * everything else (read-only built-ins, and at the hook layer auto-approved
63
- * MCP tools) is allowed. This "gate the dangerous set, allow the rest" model
64
- * mirrors the native harness's resolveToolApproval, which also defaults
65
- * unlisted tools to no-approval. It is deliberately fail-OPEN for unknown
66
- * tools: the merged MCP policy map carries only the tools that REQUIRE
67
- * approval, so a fail-closed default would wrongly deny every auto-approved
68
- * MCP tool, which the hook cannot distinguish from an unknown built-in by name.
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 BUILT_IN_GATED.has(toolName);
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.keys()];
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 undefined when the
84
- * tool is not a known gated built-in. Callers resolve the placeholders against
85
- * the tool args via resolveApprovalMessage.
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
- return BUILT_IN_GATED.get(toolName);
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": "Write", "mcpServerSlug": "", "argKey": "a.txt" }],
16
- * "approvedGrantTokens": ["V3JpdGUKYS50eHQ="]
14
+ * "approvedGrants": [{ "toolName": "edit", "mcpServerSlug": "", "key": "write", "salient": "a.txt" }],
15
+ * "approvedGrantTokens": ["d3JpdGUKYS50eHQ="]
17
16
  * }
18
17
  *
19
- * The hook gates only the explicitly dangerous set (builtInGatedList) and the
20
- * MCP tools that require approval (mcpToolPolicies, which by construction holds
21
- * only require-approval entries); every other tool is allowed. This mirrors the
22
- * native harness and avoids denying auto-approved MCP tools, which are absent
23
- * from the policy map and indistinguishable from unknown tools by name.
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 identity —
28
- * tool name plus a "salient" argument (the file path for Write, the command for
29
- * Shell, …; see extractArgKey). On reinvocation the hook allows a tool call
30
- * only if its (name, salient-arg) matches an approved grant; rejected/skipped
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 { getBuiltInGatedList, extractArgKey } from "./approval-policy.js";
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
- * - argKey is the salient argument (path/command/…) for built-in tools; matched
70
- * exactly so only the approved resource is allowed through on the resumed turn.
71
- * - argKey is empty for MCP tools (and built-in tools with no salient field);
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
- argKey: string;
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(toolName \n salientArg)`), so the
91
- * encoding here must stay byte-identical to the hook script in hook-script.ts.
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(toolName: string, argKey: string): string {
94
- return Buffer.from(`${toolName}\n${argKey}`, "utf-8").toString("base64");
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. Built-in tools are
100
- * keyed by their salient argument; MCP tools are keyed by name only.
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 argKey = pa.mcpServerSlug ? "" : extractArgKey(parseArgs(pa.argsPreview));
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
- argKey,
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 drives the hook script's allow/deny decisions:
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.toolName, g.argKey)),
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; the hook only performs
22
- * mechanical field extraction and string lookups — the policy itself is
23
- * authored once in TypeScript (approval-policy.ts / approval-state.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. Matches an approved grant token → allow (reinvocation after approval)
30
- * 3. Tool name in builtInGatedListdeny
31
- * 4. Tool name in mcpToolPolicies (require-approval) → deny
32
- * 5. Everything else (read-only built-ins, auto-approved MCP, unknown) → allow
45
+ * 2. Gated built-in (category non-empty):
46
+ * a. identity token in approvedGrantTokensallow (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. The " +
40
- "execution will resume after the user reviews and approves this tool call.";
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 all approval decisions.
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
- * Approved grants are matched by a base64 token of `toolName \n salientArg`,
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
- # returns permission decision on stdout (JSON). On a deny, appends the call's
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
- # Cursor sends the actual tool name (e.g. "search_services" for MCP tools).
72
- # Every extraction ends with '|| true': under 'set -e' a non-matching grep would
73
- # otherwise abort the script and emit no decision.
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
- # --- 2. Approved grants (reinvocation after SubmitApproval) ---
94
- # Build the same base64 token the runner stored for an approved tool call and
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
- # Identity token recorded on a deny so the runner can correlate the gated call
117
- # back to its streamed tool call. Prefer the salient-arg token (identifies the
118
- # specific resource); fall back to name-only. Byte-identical to grantToken().
119
- if [ -n "$SALIENT" ]; then DENY_TOKEN="$TOKEN_SALIENT"; else DENY_TOKEN="$TOKEN_NAME"; fi
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":"'"$DENY_TOKEN"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
154
+ echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$1"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
126
155
  }
127
156
 
128
- # --- 3. Gated built-in tools (Write, StrReplace, Shell, ...) → deny ---
129
- GATED_LIST=$(echo "$STATE" | grep -o '"builtInGatedList":\\[[^]]*\\]' | head -1 || true)
130
- if [ -n "$GATED_LIST" ] && [ -n "$TOOL_NAME" ] && echo "$GATED_LIST" | grep -q "\\"$TOOL_NAME\\""; then
131
- record_denial
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
- # --- 4. MCP tools that require approval → deny ---
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
- # --- 5. Everything else → allow ---
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.