@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.
- 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 +41 -14
- package/dist/activities/execute-cursor/hook-script.js +155 -63
- 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/dist/activities/execute-cursor/session-lifecycle.d.ts +9 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +11 -3
- package/dist/activities/execute-cursor/session-lifecycle.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 +204 -0
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +93 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +73 -2
- 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 +157 -63
- package/src/activities/execute-cursor/message-translator.ts +114 -57
- 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": "
|
|
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
|
|
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
53
|
+
* 2. Gated built-in (category non-empty):
|
|
54
|
+
* a. identity token in approvedGrantTokens → allow (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
|
|
40
|
-
"execution will resume after the user
|
|
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
|
|
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
|
-
*
|
|
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
|
-
#
|
|
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":"'"$
|
|
209
|
+
echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$1"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
|
|
126
210
|
}
|
|
127
211
|
|
|
128
|
-
# ---
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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.
|