@stigmer/runner 3.0.2 → 3.0.4

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.
Files changed (27) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/approval-policy.d.ts +55 -16
  3. package/dist/activities/execute-cursor/approval-policy.js +93 -31
  4. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  5. package/dist/activities/execute-cursor/approval-state.d.ts +54 -26
  6. package/dist/activities/execute-cursor/approval-state.js +41 -26
  7. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.d.ts +41 -14
  9. package/dist/activities/execute-cursor/hook-script.js +155 -63
  10. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  11. package/dist/activities/execute-cursor/message-translator.d.ts +23 -0
  12. package/dist/activities/execute-cursor/message-translator.js +100 -54
  13. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  14. package/dist/activities/execute-cursor/session-lifecycle.d.ts +9 -0
  15. package/dist/activities/execute-cursor/session-lifecycle.js +11 -3
  16. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +93 -37
  19. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +33 -18
  20. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +204 -0
  21. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +93 -0
  22. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +73 -2
  23. package/src/activities/execute-cursor/approval-policy.ts +113 -31
  24. package/src/activities/execute-cursor/approval-state.ts +74 -32
  25. package/src/activities/execute-cursor/hook-script.ts +157 -63
  26. package/src/activities/execute-cursor/message-translator.ts +114 -57
  27. package/src/activities/execute-cursor/session-lifecycle.ts +21 -3
@@ -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
 
@@ -16,66 +16,178 @@
16
16
  * so its ledger is the authoritative record of what was gated this turn
17
17
  * 5. Returns { "permission": "allow" } or { "permission": "deny" } on stdout
18
18
  *
19
- * The script is self-contained (no Node.js required) for portability. It uses
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).
19
+ * Identity extraction runs on the SAME Node.js binary as the runner (its
20
+ * absolute path process.execPath is baked into the script at generation
21
+ * time), because the identity token must be byte-identical to the one the
22
+ * runner computes from the parsed stream event. The original grep/cut
23
+ * extraction is kept only as a best-effort fallback if that binary cannot run:
24
+ * grep's `"command":"[^"]*"` truncates at the first JSON-escaped quote, so for
25
+ * a shell command like `printf '%s' "x" > file` the fallback token will NOT
26
+ * match the runner's — the call is still denied (the gate holds) but the
27
+ * denial cannot be overlaid onto the real streamed tool call and a grant for
28
+ * it will not match on reinvocation. All policy decisions are pre-computed by
29
+ * the runner into the state file (and into this generated script); the hook
30
+ * only performs mechanical field extraction and string lookups — the policy
31
+ * itself is authored once in TypeScript (approval-policy.ts /
32
+ * approval-state.ts).
33
+ *
34
+ * Cross-taxonomy identity (the crux):
35
+ * The preToolUse hook and the SDK event stream name the same operation
36
+ * differently — the hook receives PascalCase `tool_name` (`Write` for any file
37
+ * create/edit, `Shell`, `Delete`) while the stream emits lowercase `event.name`
38
+ * (`edit`, `shell`, `delete`). They also name the salient argument differently
39
+ * (`file_path` in the hook input vs `path` in the stream). So the hook and the
40
+ * runner cannot correlate on the raw name. Instead both reduce a tool call to a
41
+ * canonical identity — `base64(category \n salient)` — where `category` is the
42
+ * approval category (`write`/`delete`/`shell`, baked into the case statement
43
+ * below from approval-policy.ts) and `salient` is the resource VALUE (the file
44
+ * path or shell command), which is identical on both sides. The runner mirrors
45
+ * this exactly in approval-state.ts (toolIdentity + grantToken), so a denial
46
+ * recorded here correlates to the streamed tool call, and an approval grant
47
+ * matches the agent's re-attempt on reinvocation.
24
48
  *
25
49
  * Policy evaluation order (first match wins). The model is "gate the dangerous
26
50
  * set, allow the rest" — matching the native harness and avoiding denial of
27
51
  * auto-approved MCP tools (which are absent from mcpToolPolicies):
28
52
  * 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
53
+ * 2. Gated built-in (category non-empty):
54
+ * a. identity token in approvedGrantTokensallow (reinvocation grant)
55
+ * b. otherwise record denial, deny
56
+ * 3. MCP tool present in mcpToolPolicies (require-approval):
57
+ * a. name token in approvedGrantTokens → allow
58
+ * b. otherwise → record denial, deny
59
+ * 4. Everything else (read-only built-ins, auto-approved MCP, unknown) → allow
33
60
  */
34
61
 
35
- import { SALIENT_ARG_FIELDS } from "./approval-policy.js";
62
+ import { SALIENT_ARG_FIELDS, getBuiltInGatedCategories } from "./approval-policy.js";
36
63
 
37
64
  const APPROVAL_REQUIRED_AGENT_MESSAGE =
38
65
  "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.";
66
+ "execution. Do not attempt alternative approaches or workarounds (including " +
67
+ "shell commands). Stop and wait — the execution will resume after the user " +
68
+ "reviews and approves this tool call.";
69
+
70
+ /**
71
+ * Build the bash `case` arms that map an incoming hook `tool_name` to its
72
+ * canonical approval category. Generated from approval-policy.ts so the hook and
73
+ * the runner never disagree on which built-ins are gated or how they categorize.
74
+ */
75
+ function buildCategoryCaseArms(): string {
76
+ const byCategory = new Map<string, string[]>();
77
+ for (const [name, category] of getBuiltInGatedCategories()) {
78
+ const names = byCategory.get(category) ?? [];
79
+ names.push(name);
80
+ byCategory.set(category, names);
81
+ }
82
+ const arms: string[] = [];
83
+ for (const [category, names] of byCategory) {
84
+ const pattern = names.map((n) => `"${n}"`).join("|");
85
+ arms.push(` ${pattern}) CATEGORY="${category}" ;;`);
86
+ }
87
+ return arms.join("\n");
88
+ }
89
+
90
+ /**
91
+ * Build the inline Node.js identity extractor embedded in the hook script.
92
+ *
93
+ * Parses the hook's stdin JSON properly (the bash fallback's grep truncates
94
+ * string values at the first escaped quote) and emits four lines:
95
+ * tool_name, canonical category, identity token, and MCP name-token. The token
96
+ * encodings must stay byte-identical to grantToken() in approval-state.ts.
97
+ *
98
+ * Authored as a single-quoted bash string, so the JS must not contain single
99
+ * quotes. The category map and salient field list are baked from
100
+ * approval-policy.ts — the same source the runner uses — so the two sides can
101
+ * never disagree.
102
+ */
103
+ function buildNodeIdentityScript(): string {
104
+ const categoryMap: Record<string, string> = {};
105
+ for (const [name, category] of getBuiltInGatedCategories()) {
106
+ categoryMap[name] = category;
107
+ }
108
+ const categories = JSON.stringify(categoryMap);
109
+ const fields = JSON.stringify(SALIENT_ARG_FIELDS);
110
+ return [
111
+ `const t=JSON.parse(require("fs").readFileSync(0,"utf8"));`,
112
+ `const name=typeof t.tool_name==="string"?t.tool_name:"";`,
113
+ `const cat=(${categories})[name]||"";`,
114
+ `const a=(t.tool_input&&typeof t.tool_input==="object")?t.tool_input:{};`,
115
+ `let s="";`,
116
+ `for(const f of ${fields}){const v=a[f];if(typeof v==="string"&&v){s=v;break;}}`,
117
+ `const b=(x)=>Buffer.from(x,"utf8").toString("base64");`,
118
+ `process.stdout.write(name+"\\n"+cat+"\\n"+b(cat+"\\n"+s)+"\\n"+b(name+"\\n"));`,
119
+ ].join("");
120
+ }
41
121
 
42
122
  /**
43
123
  * Generates the bash hook script content.
44
124
  *
45
125
  * The script reads a JSON state file written by the cursor-runner before
46
126
  * each agent.send() call. The state file is the single source of truth
47
- * for all approval decisions.
127
+ * for the dynamic approval inputs (autoApproveAll, mcpToolPolicies,
128
+ * approvedGrantTokens). The static policy (which built-ins are gated and their
129
+ * categories, and which arg fields are salient) is baked into the script at
130
+ * generation time from approval-policy.ts.
48
131
  *
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
132
+ * The identity token encoding (`base64(key \n salient)`) must stay byte-identical
53
133
  * to grantToken() in approval-state.ts.
54
134
  */
55
135
  export function generateHookScript(stateFilePath: string, ledgerFilePath: string): string {
56
136
  const salientFields = SALIENT_ARG_FIELDS.join(" ");
137
+ const categoryCaseArms = buildCategoryCaseArms();
138
+ const nodeIdentityScript = buildNodeIdentityScript();
57
139
  return `#!/bin/bash
58
140
  # Stigmer HITL approval hook for Cursor preToolUse
59
141
  # Generated by cursor-runner — do not edit manually.
60
142
  #
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
143
+ # Reads tool call from stdin (JSON), checks approval state file, returns a
144
+ # permission decision on stdout (JSON). On a deny, appends the call's canonical
63
145
  # identity token to the denial ledger so the runner can mark the gated tool call
64
- # as WAITING_APPROVAL.
146
+ # as WAITING_APPROVAL. See hook-script.ts for the cross-taxonomy identity design.
65
147
 
66
148
  set -euo pipefail
67
149
 
68
150
  INPUT=$(cat)
69
151
 
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.
74
- TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
75
-
76
152
  STATE_FILE="${stateFilePath}"
77
153
  LEDGER_FILE="${ledgerFilePath}"
78
154
 
155
+ # --- Canonical identity: tool_name / category / identity token / MCP token ---
156
+ # Computed by the same Node.js binary that runs the cursor-runner (absolute path
157
+ # baked at generation time) so JSON string values — file paths and especially
158
+ # shell commands containing quotes, newlines, or unicode escapes — decode to the
159
+ # exact bytes the runner sees in the stream event. ELECTRON_RUN_AS_NODE makes
160
+ # the invocation safe when the runner is embedded in an Electron app (where
161
+ # process.execPath is the Electron binary).
162
+ NODE_BIN="${process.execPath}"
163
+ IDENTITY=$(printf '%s' "$INPUT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${nodeIdentityScript}' 2>/dev/null || true)
164
+ if [ -n "$IDENTITY" ]; then
165
+ TOOL_NAME=$(printf '%s\\n' "$IDENTITY" | sed -n 1p)
166
+ CATEGORY=$(printf '%s\\n' "$IDENTITY" | sed -n 2p)
167
+ TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 3p)
168
+ MCP_TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 4p)
169
+ else
170
+ # Fallback when the Node binary cannot run: grep/cut extraction. Best-effort
171
+ # only — '"field":"[^"]*"' truncates at the first JSON-escaped quote, so the
172
+ # token may not match the runner's for values containing escapes. Gating still
173
+ # holds (deny goes out); only denial correlation and grant precision degrade.
174
+ # Every extraction ends with '|| true': under 'set -e' a non-matching grep
175
+ # would otherwise abort the script and emit no decision.
176
+ TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
177
+ SALIENT=""
178
+ for field in ${salientFields}; do
179
+ v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
180
+ if [ -n "$v" ]; then SALIENT="$v"; break; fi
181
+ done
182
+ CATEGORY=""
183
+ case "$TOOL_NAME" in
184
+ ${categoryCaseArms}
185
+ *) CATEGORY="" ;;
186
+ esac
187
+ TOKEN=$(printf '%s\\n%s' "$CATEGORY" "$SALIENT" | base64 | tr -d '\\n')
188
+ MCP_TOKEN=$(printf '%s\\n' "$TOOL_NAME" | base64 | tr -d '\\n')
189
+ fi
190
+
79
191
  # --- Failsafe: missing state file → deny (fail-closed) ---
80
192
  if [ ! -f "$STATE_FILE" ]; then
81
193
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
@@ -90,66 +202,48 @@ if echo "$STATE" | grep -q '"autoApproveAll":true'; then
90
202
  exit 0
91
203
  fi
92
204
 
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
103
- SALIENT=""
104
- for field in ${salientFields}; do
105
- v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
106
- if [ -n "$v" ]; then SALIENT="$v"; break; fi
107
- 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
-
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
120
-
121
205
  # Append a denial record to the ledger. Best-effort: a ledger write failure must
122
206
  # never abort the decision (the deny still goes out on stdout). toolName is raw
123
207
  # for human-readable debugging; token drives correlation in the runner.
124
208
  record_denial() {
125
- echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$DENY_TOKEN"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
209
+ echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$1"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
126
210
  }
127
211
 
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
212
+ # --- 2. Gated built-in tools (category non-empty) ---
213
+ if [ -n "$CATEGORY" ]; then
214
+ # Reinvocation grant: this exact resource was approved earlier allow.
215
+ if echo "$STATE" | grep -qF "\\"$TOKEN\\""; then
216
+ echo '{"permission":"allow"}'
217
+ exit 0
218
+ fi
219
+ record_denial "$TOKEN"
132
220
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"Tool requires approval: '"$TOOL_NAME"'"}'
133
221
  exit 0
134
222
  fi
135
223
 
136
- # --- 4. MCP tools that require approval → deny ---
224
+ # --- 3. MCP tools that require approval → deny ---
137
225
  # mcpToolPolicies holds only require-approval tools (auto-approved MCP tools are
138
- # absent), so presence means "deny" unless an entry is explicitly false.
226
+ # absent), so presence means "deny" unless an entry is explicitly false. MCP tool
227
+ # names are consistent across the hook and the stream, so the identity token is
228
+ # name-only: base64("$TOOL_NAME\\n").
139
229
  if echo "$STATE" | grep -q "\\"mcpToolPolicies\\"" && [ -n "$TOOL_NAME" ]; then
140
230
  TOOL_POLICY=$(echo "$STATE" | grep -o "\\"$TOOL_NAME\\":{[^}]*}" | head -1 || true)
141
231
  if [ -n "$TOOL_POLICY" ] && ! echo "$TOOL_POLICY" | grep -q '"requiresApproval":false'; then
232
+ if echo "$STATE" | grep -qF "\\"$MCP_TOKEN\\""; then
233
+ echo '{"permission":"allow"}'
234
+ exit 0
235
+ fi
142
236
  MSG=$(echo "$TOOL_POLICY" | grep -o '"message":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
143
237
  if [ -z "$MSG" ]; then
144
238
  MSG="Tool requires approval: $TOOL_NAME"
145
239
  fi
146
- record_denial
240
+ record_denial "$MCP_TOKEN"
147
241
  echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
148
242
  exit 0
149
243
  fi
150
244
  fi
151
245
 
152
- # --- 5. Everything else → allow ---
246
+ # --- 4. Everything else → allow ---
153
247
  # Read-only built-ins, auto-approved MCP tools, and anything not explicitly
154
248
  # gated. Fail-open mirrors the native harness (gate the dangerous set, allow the
155
249
  # rest) and prevents denying auto-approved MCP tools the state cannot enumerate.