fullcourtdefense-cli 1.34.21 → 1.34.25

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 (91) hide show
  1. package/README.md +1 -0
  2. package/dist/actionIdentity.d.ts +1 -0
  3. package/dist/actionIdentity.js +2 -0
  4. package/dist/actionPolicyEngine.d.ts +5 -0
  5. package/dist/actionPolicyEngine.js +273 -30
  6. package/dist/agentTerminal.d.ts +1 -3
  7. package/dist/agentTerminal.js +14 -29
  8. package/dist/approvalPrompt.d.ts +7 -0
  9. package/dist/approvalPrompt.js +46 -0
  10. package/dist/askDialog.d.ts +87 -0
  11. package/dist/askDialog.js +209 -0
  12. package/dist/blockExplanation.js +18 -6
  13. package/dist/boundaryVerification.d.ts +20 -0
  14. package/dist/boundaryVerification.js +147 -0
  15. package/dist/commands/daemon.js +198 -70
  16. package/dist/commands/deterministicGuard.d.ts +2 -0
  17. package/dist/commands/deterministicGuard.js +161 -9
  18. package/dist/commands/hook.js +81 -29
  19. package/dist/commands/installClaudeHook.js +11 -5
  20. package/dist/commands/installCursorHook.js +5 -4
  21. package/dist/commands/mcpGateway.d.ts +2 -0
  22. package/dist/commands/mcpGateway.js +163 -56
  23. package/dist/commands/onboard.js +6 -5
  24. package/dist/commands/onboardingSummary.d.ts +5 -0
  25. package/dist/commands/onboardingSummary.js +11 -0
  26. package/dist/commands/protectedRun.d.ts +14 -0
  27. package/dist/commands/protectedRun.js +207 -0
  28. package/dist/containment-runtime/index.mjs +30118 -0
  29. package/dist/containment-runtime/licenses/_anthropic-ai_sandbox-runtime.txt +201 -0
  30. package/dist/containment-runtime/licenses/_pondwader_socks5-server.txt +20 -0
  31. package/dist/containment-runtime/licenses/commander.txt +22 -0
  32. package/dist/containment-runtime/licenses/node-forge.txt +331 -0
  33. package/dist/containment-runtime/licenses/zod.txt +21 -0
  34. package/dist/containment-runtime/runtime-version.json +1 -0
  35. package/dist/containment-runtime/vendor/java-proxy-agent/build.ts +95 -0
  36. package/dist/containment-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
  37. package/dist/containment-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
  38. package/dist/containment-runtime/vendor/seccomp/build.ts +69 -0
  39. package/dist/containment-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
  40. package/dist/containment-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
  41. package/dist/containment-runtime/vendor/srt-win/build.ts +21 -0
  42. package/dist/containment-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
  43. package/dist/containmentLease.d.ts +5 -0
  44. package/dist/containmentLease.js +72 -0
  45. package/dist/detectionUpdates.d.ts +23 -3
  46. package/dist/detectionUpdates.js +45 -14
  47. package/dist/detectorArtifact.d.ts +17 -0
  48. package/dist/detectorArtifact.js +35 -0
  49. package/dist/detectorBaseline.d.ts +2 -0
  50. package/dist/detectorBaseline.js +5 -0
  51. package/dist/detectorModule.d.ts +9 -0
  52. package/dist/detectorModule.js +84 -0
  53. package/dist/detectorRuntime.d.ts +21 -0
  54. package/dist/detectorRuntime.js +49 -0
  55. package/dist/detectorUpdates.d.ts +21 -0
  56. package/dist/detectorUpdates.js +86 -0
  57. package/dist/devConfirm.d.ts +97 -15
  58. package/dist/devConfirm.js +447 -117
  59. package/dist/distress.d.ts +2 -0
  60. package/dist/distress.js +2 -0
  61. package/dist/frictionRollout.js +103 -0
  62. package/dist/gatewayRuntime.d.ts +19 -0
  63. package/dist/gatewayRuntime.js +93 -0
  64. package/dist/hookSlim.js +17 -18
  65. package/dist/index.js +35 -0
  66. package/dist/localDetectionUpdates.d.ts +3 -1
  67. package/dist/localDetectionUpdates.js +8 -1
  68. package/dist/localSafetySnapshot.d.ts +9 -5
  69. package/dist/localSafetySnapshot.js +42 -15
  70. package/dist/mcpToolReview.d.ts +17 -0
  71. package/dist/mcpToolReview.js +127 -0
  72. package/dist/mcpToolTrust.d.ts +22 -0
  73. package/dist/mcpToolTrust.js +66 -0
  74. package/dist/nativeHook.d.ts +4 -0
  75. package/dist/nativeHook.js +31 -17
  76. package/dist/policyRefresh.d.ts +3 -0
  77. package/dist/policyRefresh.js +27 -0
  78. package/dist/protectionMaintenance.d.ts +10 -0
  79. package/dist/protectionMaintenance.js +64 -0
  80. package/dist/protectionProfile.d.ts +26 -0
  81. package/dist/protectionProfile.js +63 -0
  82. package/dist/runtimeConfig.d.ts +2 -0
  83. package/dist/runtimeConfig.js +65 -26
  84. package/dist/selfTest.d.ts +2 -0
  85. package/dist/selfTest.js +9 -1
  86. package/dist/sessionLimits.d.ts +2 -0
  87. package/dist/sessionLimits.js +24 -0
  88. package/dist/telemetry.d.ts +5 -0
  89. package/dist/telemetry.js +8 -0
  90. package/dist/version.json +1 -1
  91. package/package.json +10 -6
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CONFIRMED_ACTION_TTL_MS = exports.CONFIRM_PENDING_TTL_MS = exports.MIN_HUMAN_ANSWER_MS = void 0;
36
+ exports.GRANT_TTL_MS = exports.CONFIRMED_ACTION_TTL_MS = exports.CONFIRM_PENDING_TTL_MS = exports.DIALOG_SCRIPT_MARKER = exports.DIALOG_BUTTON_DENY = exports.DIALOG_BUTTON_ONCE = exports.DIALOG_BUTTON_ALWAYS = exports.MIN_HUMAN_ANSWER_MS = void 0;
37
37
  exports.looksLikeClientAutoAnswer = looksLikeClientAutoAnswer;
38
38
  exports.confirmNativeAction = confirmNativeAction;
39
39
  exports.nativeDialogAvailable = nativeDialogAvailable;
@@ -49,6 +49,13 @@ exports.raiseDeveloperConfirmation = raiseDeveloperConfirmation;
49
49
  exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
50
50
  exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
51
51
  exports.sweepExpiredConfirmations = sweepExpiredConfirmations;
52
+ exports.shellPrograms = shellPrograms;
53
+ exports.findDeveloperGrant = findDeveloperGrant;
54
+ exports.recordDeveloperGrant = recordDeveloperGrant;
55
+ exports.listDeveloperGrants = listDeveloperGrants;
56
+ exports.revokeDeveloperGrants = revokeDeveloperGrants;
57
+ exports.describeGrantScope = describeGrantScope;
58
+ exports.grantScopeFor = grantScopeFor;
52
59
  exports.isConfirmationRemembered = isConfirmationRemembered;
53
60
  /**
54
61
  * Developer self-confirmation ledger (F2 — approval.scope: developer).
@@ -64,7 +71,7 @@ exports.isConfirmationRemembered = isConfirmationRemembered;
64
71
  * tool + digest => the developer approved -> `allow`, approvedBy: developer
65
72
  * - no post-hook within the window => denied or ignored -> `block` on sweep
66
73
  *
67
- * `remember: session | 1h` stores the confirmation so the same tool + operation is not asked
74
+ * `remember: session | 1h` stores the confirmation so the same action + policy + session is not asked
68
75
  * again within that window. Everything here is minimized facts on disk (no raw command
69
76
  * bodies — a SHA-256 digest of the canonical args), per-user, best-effort: a ledger failure
70
77
  * must never change a verdict, only lose a record.
@@ -78,6 +85,7 @@ const crypto = __importStar(require("crypto"));
78
85
  const fs = __importStar(require("fs"));
79
86
  const os = __importStar(require("os"));
80
87
  const path = __importStar(require("path"));
88
+ const approvalPrompt_1 = require("./approvalPrompt");
81
89
  /**
82
90
  * Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
83
91
  * the dialog, read at least the tool name and press a button; MCP clients that
@@ -102,6 +110,12 @@ function looksLikeClientAutoAnswer(answer, latencyMs) {
102
110
  async function confirmNativeAction(reason, timeoutMs = 60_000) {
103
111
  return (await confirmNativeActionDetailed(reason, timeoutMs)).outcome === 'allow';
104
112
  }
113
+ /** Button labels — one vocabulary on every platform and in every test. */
114
+ exports.DIALOG_BUTTON_ALWAYS = 'Always allow';
115
+ exports.DIALOG_BUTTON_ONCE = 'Allow once';
116
+ exports.DIALOG_BUTTON_DENY = 'Deny';
117
+ /** Marker inside the Windows dialog script; tests recognize the spawn by it. */
118
+ exports.DIALOG_SCRIPT_MARKER = 'FullCourtDefense.Dialog';
105
119
  /** True when this process can put a dialog in front of a person on this machine. */
106
120
  function nativeDialogAvailable() {
107
121
  if (!isHumanPresent())
@@ -110,60 +124,121 @@ function nativeDialogAvailable() {
110
124
  return true;
111
125
  return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
112
126
  }
113
- async function confirmNativeActionDetailed(reason, timeoutMs = 60_000) {
127
+ /**
128
+ * Windows: a small owned window (System.Windows.Forms) — `WScript.Shell.Popup`
129
+ * cannot label its buttons, and "Yes / No / Cancel" is not an approval UI.
130
+ * TopMost keeps it above the IDE that triggered it; Enter/Escape both mean Deny.
131
+ * Exit codes: 0 = Allow once, 3 = Always allow, 2 = timed out, anything else = Deny.
132
+ */
133
+ function windowsDialogScript(message, timeoutSeconds, options) {
134
+ const encoded = Buffer.from(message, 'utf8').toString('base64');
135
+ return [
136
+ `# ${exports.DIALOG_SCRIPT_MARKER}`,
137
+ 'Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing',
138
+ '[System.Windows.Forms.Application]::EnableVisualStyles()',
139
+ `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`,
140
+ "$f=New-Object Windows.Forms.Form; $f.Text='FullCourtDefense'; $f.TopMost=$true; $f.StartPosition='CenterScreen'; $f.FormBorderStyle='FixedDialog'; $f.MaximizeBox=$false; $f.MinimizeBox=$false; $f.ShowInTaskbar=$true; $f.AutoSize=$true; $f.AutoSizeMode='GrowAndShrink'; $f.Padding=New-Object Windows.Forms.Padding(18,16,18,16); $f.Font=New-Object Drawing.Font('Segoe UI',9.75)",
141
+ "$t=New-Object Windows.Forms.TableLayoutPanel; $t.AutoSize=$true; $t.AutoSizeMode='GrowAndShrink'; $t.ColumnCount=1; $t.RowCount=2; $t.Dock='Fill'",
142
+ "$l=New-Object Windows.Forms.Label; $l.Text=$msg; $l.AutoSize=$true; $l.MaximumSize=New-Object Drawing.Size(600,0); $l.Margin=New-Object Windows.Forms.Padding(0,0,0,14); $t.Controls.Add($l,0,0)",
143
+ "$b=New-Object Windows.Forms.FlowLayoutPanel; $b.FlowDirection='RightToLeft'; $b.AutoSize=$true; $b.AutoSizeMode='GrowAndShrink'; $b.Dock='Fill'; $b.WrapContents=$false",
144
+ `$deny=New-Object Windows.Forms.Button; $deny.Text='${exports.DIALOG_BUTTON_DENY}'; $deny.AutoSize=$true; $deny.MinimumSize=New-Object Drawing.Size(96,30); $deny.DialogResult='Cancel'; $deny.TabIndex=0; $b.Controls.Add($deny)`,
145
+ `$once=New-Object Windows.Forms.Button; $once.Text='${exports.DIALOG_BUTTON_ONCE}'; $once.AutoSize=$true; $once.MinimumSize=New-Object Drawing.Size(96,30); $once.DialogResult='OK'; $once.TabIndex=1; $b.Controls.Add($once)`,
146
+ options.allowAlways
147
+ ? `$always=New-Object Windows.Forms.Button; $always.Text='${exports.DIALOG_BUTTON_ALWAYS}'; $always.AutoSize=$true; $always.MinimumSize=New-Object Drawing.Size(110,30); $always.DialogResult='Yes'; $always.TabIndex=2; $b.Controls.Add($always)`
148
+ : '',
149
+ "$t.Controls.Add($b,0,1); $f.Controls.Add($t); $f.CancelButton=$deny; $f.AcceptButton=$deny; $f.ActiveControl=$deny",
150
+ `$timer=New-Object Windows.Forms.Timer; $timer.Interval=${timeoutSeconds * 1000}; $timer.Add_Tick({ $f.DialogResult='Abort'; $f.Close() })`,
151
+ // The hook spawns PowerShell with a hidden startup show-state (no console flash);
152
+ // WinForms applies that state to the FIRST window, so the form would run
153
+ // invisibly. Bouncing WindowState issues a real ShowWindow(SW_RESTORE).
154
+ "$f.Add_Load({ $f.WindowState='Minimized'; $f.WindowState='Normal' })",
155
+ "$f.Add_Shown({ $f.Activate(); $deny.Select(); $deny.Focus(); $timer.Start() })",
156
+ "$r=$f.ShowDialog(); $timer.Stop()",
157
+ "if($r -eq 'OK'){exit 0} elseif($r -eq 'Yes'){exit 3} elseif($r -eq 'Abort'){exit 2} else {exit 1}",
158
+ ].filter(Boolean).join('\n');
159
+ }
160
+ async function confirmNativeActionDetailed(reason, timeoutMs = 60_000, options = {}) {
114
161
  if (!nativeDialogAvailable())
115
162
  return { outcome: 'unavailable', latencyMs: 0 };
116
163
  const started = Date.now();
117
164
  let executable;
118
165
  let args;
119
- const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
166
+ const message = (0, approvalPrompt_1.nativeApprovalMessage)(reason, { allowAlways: options.allowAlways, alwaysScopeLabel: options.alwaysScopeLabel });
120
167
  const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
121
168
  if (process.platform === 'win32') {
122
169
  executable = 'powershell.exe';
123
- const encoded = Buffer.from(message, 'utf8').toString('base64');
124
- // 292 = Yes/No + question icon + "No" default; +4096 (MB_SYSTEMMODAL) keeps the
125
- // popup above the IDE window that triggered it, so a fallback ask is never
126
- // hidden behind Codex/Cursor and silently timed out.
127
- const script = `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $w=New-Object -ComObject WScript.Shell; $answer=$w.Popup($msg,${timeoutSeconds},'FullCourtDefense',4388); if($answer -eq 6){exit 0}else{exit 1}`;
128
- args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
170
+ args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(windowsDialogScript(message, timeoutSeconds, options), 'utf16le').toString('base64')];
129
171
  }
130
172
  else if (process.platform === 'darwin') {
131
173
  executable = '/usr/bin/osascript';
132
- args = ['-e', 'on run argv', '-e', `display dialog (item 1 of argv) buttons {"Deny", "Allow once"} default button "Deny" giving up after ${timeoutSeconds}`, '-e', 'if gave up of result or button returned of result is not "Allow once" then error number -128', '-e', 'end run', message];
174
+ const buttons = options.allowAlways
175
+ ? `{"${exports.DIALOG_BUTTON_DENY}", "${exports.DIALOG_BUTTON_ONCE}", "${exports.DIALOG_BUTTON_ALWAYS}"}`
176
+ : `{"${exports.DIALOG_BUTTON_DENY}", "${exports.DIALOG_BUTTON_ONCE}"}`;
177
+ // The pressed label is printed on stdout; Deny is the cancel button (exit 1).
178
+ args = ['-e', 'on run argv', '-e', `set r to display dialog (item 1 of argv) with title "FullCourtDefense" buttons ${buttons} default button "${exports.DIALOG_BUTTON_DENY}" cancel button "${exports.DIALOG_BUTTON_DENY}" giving up after ${timeoutSeconds}`, '-e', 'if gave up of r then return "timeout"', '-e', 'return button returned of r', '-e', 'end run', message];
133
179
  }
134
180
  else {
135
181
  executable = 'zenity';
136
- args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, '--ok-label=Allow once', '--cancel-label=Deny', `--timeout=${timeoutSeconds}`];
182
+ args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, `--ok-label=${exports.DIALOG_BUTTON_ONCE}`, `--cancel-label=${exports.DIALOG_BUTTON_DENY}`, `--timeout=${timeoutSeconds}`];
183
+ // zenity prints the extra button's label on stdout and exits 1 when it is pressed.
184
+ if (options.allowAlways)
185
+ args.push(`--extra-button=${exports.DIALOG_BUTTON_ALWAYS}`);
137
186
  }
138
187
  return new Promise(resolve => {
139
188
  let finished = false;
140
189
  let child;
190
+ let stdout = '';
141
191
  const done = (outcome) => {
142
192
  if (finished)
143
193
  return;
144
194
  finished = true;
145
195
  clearTimeout(timer);
146
196
  process.removeListener('exit', cancelChild);
147
- resolve({ outcome, latencyMs: Date.now() - started });
197
+ // A third button that was never offered can never be "pressed".
198
+ resolve({ outcome: outcome === 'allow_always' && !options.allowAlways ? 'deny' : outcome, latencyMs: Date.now() - started });
148
199
  };
149
200
  const cancelChild = () => child?.kill();
150
201
  const timer = setTimeout(() => { child?.kill(); done('timeout'); }, timeoutMs + 1000);
151
202
  try {
152
- child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
203
+ child = (0, child_process_1.spawn)(executable, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
153
204
  }
154
205
  catch {
155
206
  done('unavailable');
156
207
  return;
157
208
  }
158
209
  process.once('exit', cancelChild);
159
- // Spawn failure (binary missing, no permission) = nothing was shown. A child
160
- // that ran and exited non-zero = the person (or the dialog's give-up) said no;
161
- // the dialog cannot tell Deny from close from give-up, so a "no" that arrives
162
- // at the deadline is reported as a timeout.
210
+ child.stdout?.on('data', (chunk) => { if (stdout.length < 256)
211
+ stdout += String(chunk); });
212
+ // Spawn failure (binary missing, no permission) = nothing was shown. Windows
213
+ // reports the button by exit code; osascript / zenity print the pressed label.
214
+ // A "no" that arrives at the deadline is reported as a timeout.
163
215
  child.on('error', () => done('unavailable'));
164
216
  child.on('exit', code => {
165
- if (code === 0)
166
- return done('allow');
217
+ const pressed = stdout.trim();
218
+ if (process.platform === 'win32') {
219
+ if (code === 0)
220
+ return done('allow');
221
+ if (code === 3)
222
+ return done('allow_always');
223
+ if (code === 2)
224
+ return done('timeout');
225
+ }
226
+ else if (process.platform === 'darwin') {
227
+ if (code === 0 && pressed === exports.DIALOG_BUTTON_ONCE)
228
+ return done('allow');
229
+ if (code === 0 && pressed === exports.DIALOG_BUTTON_ALWAYS)
230
+ return done('allow_always');
231
+ if (code === 0 && pressed === 'timeout')
232
+ return done('timeout');
233
+ }
234
+ else {
235
+ if (code === 0)
236
+ return done('allow');
237
+ if (code === 1 && pressed === exports.DIALOG_BUTTON_ALWAYS)
238
+ return done('allow_always');
239
+ if (code === 5)
240
+ return done('timeout');
241
+ }
167
242
  done(Date.now() - started >= timeoutMs - 500 ? 'timeout' : 'deny');
168
243
  });
169
244
  });
@@ -178,6 +253,9 @@ const MAX_REMEMBERED = 200;
178
253
  /** The hook's dialog answer only carries over to the gateway call it was given for. */
179
254
  exports.CONFIRMED_ACTION_TTL_MS = 2 * 60_000;
180
255
  const MAX_CONFIRMED_ACTIONS = 50;
256
+ /** "Always" is long, not forever: a standing grant lapses after 30 days unless renewed by use of the dialog. */
257
+ exports.GRANT_TTL_MS = 30 * 24 * 60 * 60_000;
258
+ const MAX_GRANTS = 100;
181
259
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
182
260
  function isCiEnvironment(env = process.env) {
183
261
  return env.GITHUB_ACTIONS === 'true'
@@ -206,58 +284,107 @@ function ledgerFile() {
206
284
  function readLedger() {
207
285
  try {
208
286
  const parsed = JSON.parse(fs.readFileSync(ledgerFile(), 'utf8'));
287
+ // Version 2 remembered entire rules and discarded MCP server identity.
288
+ if (parsed?.formatVersion !== 3)
289
+ return { pending: [], remembered: [], confirmedActions: [], grants: [] };
209
290
  return {
210
291
  pending: Array.isArray(parsed?.pending) ? parsed.pending.filter((p) => p && typeof p === 'object') : [],
211
292
  // Older ledgers could remember declines. Require fresh affirmative consent.
212
- remembered: parsed?.formatVersion === 2 && Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
293
+ remembered: Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
213
294
  confirmedActions: Array.isArray(parsed?.confirmedActions) ? parsed.confirmedActions.filter((c) => c && typeof c === 'object') : [],
295
+ // Grants are an additive field on the same format (older CLIs ignore them).
296
+ grants: Array.isArray(parsed?.grants) ? parsed.grants.filter((g) => g && typeof g === 'object' && typeof g.key === 'string' && g.scope && typeof g.scope === 'object') : [],
214
297
  };
215
298
  }
216
299
  catch {
217
- return { pending: [], remembered: [], confirmedActions: [] };
300
+ return { pending: [], remembered: [], confirmedActions: [], grants: [] };
218
301
  }
219
302
  }
220
303
  function writeLedger(ledger) {
304
+ const temp = `${ledgerFile()}.${process.pid}.${crypto.randomUUID()}.tmp`;
221
305
  try {
222
- fs.mkdirSync(path.dirname(ledgerFile()), { recursive: true });
223
- const temp = `${ledgerFile()}.${process.pid}.${crypto.randomUUID()}.tmp`;
224
- fs.writeFileSync(temp, JSON.stringify({ ...ledger, formatVersion: 2 }), { encoding: 'utf8', mode: 0o600 });
306
+ const fd = fs.openSync(temp, 'wx', 0o600);
307
+ try {
308
+ fs.writeFileSync(fd, JSON.stringify({ ...ledger, formatVersion: 3 }), 'utf8');
309
+ fs.fsyncSync(fd);
310
+ }
311
+ finally {
312
+ fs.closeSync(fd);
313
+ }
225
314
  fs.renameSync(temp, ledgerFile());
226
315
  }
227
- catch { /* best-effort — losing a record must never change a verdict */ }
316
+ finally {
317
+ try {
318
+ fs.unlinkSync(temp);
319
+ }
320
+ catch { /* renamed or not created */ }
321
+ }
322
+ }
323
+ /** Serialize every mutation: a stale writer must not resurrect a consumed approval.
324
+ * Contention, failed persistence or a leftover lock loses reuse, never grants access.
325
+ * No stale-lock stealing: deleting another process's lock introduces a replay race.
326
+ */
327
+ function mutateLedger(fallback, mutate) {
328
+ const lock = `${ledgerFile()}.lock`;
329
+ let fd;
330
+ try {
331
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
332
+ fd = fs.openSync(lock, 'wx', 0o600);
333
+ return mutate();
334
+ }
335
+ catch {
336
+ return fallback;
337
+ }
338
+ finally {
339
+ if (fd !== undefined) {
340
+ try {
341
+ fs.closeSync(fd);
342
+ }
343
+ catch { /* best effort */ }
344
+ try {
345
+ fs.unlinkSync(lock);
346
+ }
347
+ catch { /* fail closed on next reuse */ }
348
+ }
349
+ }
228
350
  }
229
351
  function sha256(value) {
230
352
  return crypto.createHash('sha256').update(value).digest('hex');
231
353
  }
232
- /**
233
- * Canonical digest of what the agent is about to do. Shell: the trimmed command. MCP/file: the
234
- * tool args with keys sorted, so the pre- and post-hook payloads (which the IDE serializes
235
- * independently) hash the same.
236
- */
354
+ /** Canonical JSON action, including cwd and every argument. Invalid inputs cannot reuse consent. */
237
355
  function actionDigest(toolArgs) {
238
- const command = toolArgs.command ?? toolArgs.cmd;
239
- if (typeof command === 'string' && command.trim())
240
- return sha256(`cmd:${command.trim()}`);
241
- const stable = (value) => {
356
+ let nodes = 0;
357
+ const stable = (value, depth = 0) => {
358
+ if (++nodes > 10000 || depth > 40)
359
+ throw new Error('Action too complex');
360
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
361
+ return value;
362
+ if (typeof value === 'number' && Number.isFinite(value))
363
+ return value;
242
364
  if (Array.isArray(value))
243
- return value.map(stable);
244
- if (value && typeof value === 'object') {
245
- return Object.keys(value).sort().reduce((acc, key) => {
246
- acc[key] = stable(value[key]);
247
- return acc;
248
- }, {});
365
+ return value.map(v => stable(v, depth + 1));
366
+ if (value && typeof value === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
367
+ const result = Object.create(null);
368
+ for (const key of Object.keys(value).sort()) {
369
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
370
+ if (!('value' in descriptor))
371
+ throw new Error('Action accessor');
372
+ if (descriptor.value !== undefined)
373
+ result[key] = stable(descriptor.value, depth + 1);
374
+ }
375
+ return result;
249
376
  }
250
- return value;
377
+ throw new Error('Non-JSON action');
251
378
  };
252
379
  try {
253
- return sha256(`args:${JSON.stringify(stable(toolArgs))}`);
380
+ return sha256(`args-v3:${JSON.stringify(stable(toolArgs))}`);
254
381
  }
255
382
  catch {
256
- return sha256('args:unserializable');
383
+ return sha256(`invalid-action:${crypto.randomUUID()}`);
257
384
  }
258
385
  }
259
386
  function pendingKey(event, toolName, argsDigest) {
260
- return sha256(`${event}|${toolName.toLowerCase()}|${argsDigest}`);
387
+ return sha256(JSON.stringify([event, toolName, argsDigest]));
261
388
  }
262
389
  /**
263
390
  * The tool name as the MCP server sees it. The IDE hook names an MCP call
@@ -275,25 +402,37 @@ function bareToolName(toolName) {
275
402
  }
276
403
  return name.toLowerCase();
277
404
  }
405
+ /** Preserve the server and case; display-name normalization is not authorization. */
406
+ function qualifiedToolIdentity(toolName) {
407
+ const match = /^mcp__([^]+?)__(.+)$/.exec(toolName);
408
+ if (match)
409
+ return JSON.stringify([match[1], match[2]]);
410
+ const slash = toolName.indexOf('/');
411
+ if (slash > 0)
412
+ return JSON.stringify([toolName.slice(0, slash), toolName.slice(slash + 1)]);
413
+ return JSON.stringify([null, toolName]);
414
+ }
278
415
  function confirmedActionKey(toolName, argsDigest) {
279
- return sha256(`confirmed|${bareToolName(toolName)}|${argsDigest}`);
416
+ return sha256(JSON.stringify(['confirmed-v3', qualifiedToolIdentity(toolName), argsDigest]));
280
417
  }
281
418
  /** The IDE hook's OS dialog got an explicit "Allow once" for this exact action. */
282
419
  function recordConfirmedAction(toolName, argsDigest, now = new Date()) {
283
420
  const entry = {
284
421
  key: confirmedActionKey(toolName, argsDigest),
285
- toolName: bareToolName(toolName),
422
+ toolName,
286
423
  argsDigest,
287
424
  confirmedAt: now.toISOString(),
288
425
  expiresAt: new Date(now.getTime() + exports.CONFIRMED_ACTION_TTL_MS).toISOString(),
289
426
  };
290
- const ledger = readLedger();
291
- ledger.confirmedActions = ledger.confirmedActions.filter(c => c.key !== entry.key && Date.parse(c.expiresAt) > now.getTime());
292
- ledger.confirmedActions.push(entry);
293
- if (ledger.confirmedActions.length > MAX_CONFIRMED_ACTIONS)
294
- ledger.confirmedActions = ledger.confirmedActions.slice(-MAX_CONFIRMED_ACTIONS);
295
- writeLedger(ledger);
296
- return entry;
427
+ return mutateLedger(entry, () => {
428
+ const ledger = readLedger();
429
+ ledger.confirmedActions = ledger.confirmedActions.filter(c => c.key !== entry.key && Date.parse(c.expiresAt) > now.getTime());
430
+ ledger.confirmedActions.push(entry);
431
+ if (ledger.confirmedActions.length > MAX_CONFIRMED_ACTIONS)
432
+ ledger.confirmedActions = ledger.confirmedActions.slice(-MAX_CONFIRMED_ACTIONS);
433
+ writeLedger(ledger);
434
+ return entry;
435
+ });
297
436
  }
298
437
  /**
299
438
  * Take (and remove) a still-valid hook-dialog approval for this exact action, so the
@@ -301,21 +440,24 @@ function recordConfirmedAction(toolName, argsDigest, now = new Date()) {
301
440
  * tool, different arguments, an expired entry, or the same entry twice.
302
441
  */
303
442
  function consumeConfirmedAction(toolName, argsDigest, now = new Date()) {
304
- const ledger = readLedger();
305
- const key = confirmedActionKey(toolName, argsDigest);
306
- const hit = ledger.confirmedActions.find(c => c.key === key);
307
- const live = ledger.confirmedActions.filter(c => Number.isFinite(Date.parse(c.expiresAt)) && Date.parse(c.expiresAt) > now.getTime());
308
- const valid = hit && live.includes(hit) ? hit : undefined;
309
- const next = live.filter(c => c !== valid);
310
- if (next.length !== ledger.confirmedActions.length) {
311
- ledger.confirmedActions = next;
312
- writeLedger(ledger);
313
- }
314
- return valid;
443
+ return mutateLedger(undefined, () => {
444
+ const ledger = readLedger();
445
+ const key = confirmedActionKey(toolName, argsDigest);
446
+ const hit = ledger.confirmedActions.find(c => c.key === key);
447
+ const live = ledger.confirmedActions.filter(c => Number.isFinite(Date.parse(c.expiresAt)) && Date.parse(c.expiresAt) > now.getTime());
448
+ const valid = hit && live.includes(hit) && Number.isFinite(Date.parse(hit.confirmedAt))
449
+ && Date.parse(hit.confirmedAt) <= now.getTime()
450
+ && Date.parse(hit.expiresAt) - Date.parse(hit.confirmedAt) <= exports.CONFIRMED_ACTION_TTL_MS ? hit : undefined;
451
+ const next = live.filter(c => c !== valid);
452
+ if (next.length !== ledger.confirmedActions.length) {
453
+ ledger.confirmedActions = next;
454
+ writeLedger(ledger);
455
+ }
456
+ return valid;
457
+ });
315
458
  }
316
459
  function rememberKey(scope) {
317
- const rule = scope.policyId ? `${scope.policyId}|${scope.matchedRule || ''}` : `op:${scope.operation.toLowerCase()}`;
318
- return sha256(`${scope.toolName.toLowerCase()}|${rule}`);
460
+ return sha256(JSON.stringify([scope.toolName, scope.operation, scope.argsDigest, scope.policyId, scope.matchedRule, scope.policyHash, scope.authorizationContext]));
319
461
  }
320
462
  /** Record that the IDE was asked to confirm this action. Returns the marker (for logging). */
321
463
  function raiseDeveloperConfirmation(input, now = new Date()) {
@@ -324,13 +466,15 @@ function raiseDeveloperConfirmation(input, now = new Date()) {
324
466
  key: pendingKey(input.event, input.toolName, input.argsDigest),
325
467
  raisedAt: now.toISOString(),
326
468
  };
327
- const ledger = readLedger();
328
- ledger.pending = ledger.pending.filter(p => p.key !== marker.key);
329
- ledger.pending.push(marker);
330
- if (ledger.pending.length > MAX_PENDING)
331
- ledger.pending = ledger.pending.slice(-MAX_PENDING);
332
- writeLedger(ledger);
333
- return marker;
469
+ return mutateLedger(marker, () => {
470
+ const ledger = readLedger();
471
+ ledger.pending = ledger.pending.filter(p => p.key !== marker.key);
472
+ ledger.pending.push(marker);
473
+ if (ledger.pending.length > MAX_PENDING)
474
+ ledger.pending = ledger.pending.slice(-MAX_PENDING);
475
+ writeLedger(ledger);
476
+ return marker;
477
+ });
334
478
  }
335
479
  /**
336
480
  * A post-execution hook fired for this action: the developer approved it in the IDE prompt.
@@ -339,39 +483,43 @@ function raiseDeveloperConfirmation(input, now = new Date()) {
339
483
  * the action was simply allowed and this post-hook is noise.
340
484
  */
341
485
  function settleDeveloperConfirmation(event, toolName, argsDigest, now = new Date()) {
342
- const ledger = readLedger();
343
- const key = pendingKey(event, toolName, argsDigest);
344
- const marker = ledger.pending.find(p => p.key === key);
345
- if (!marker)
346
- return undefined;
347
- ledger.pending = ledger.pending.filter(p => p.key !== key);
348
- if (marker.remember !== 'none' && Date.parse(marker.raisedAt) <= now.getTime() && now.getTime() - Date.parse(marker.raisedAt) < exports.CONFIRM_PENDING_TTL_MS) {
349
- const ttl = marker.remember === '1h' ? REMEMBER_1H_MS : REMEMBER_SESSION_MAX_MS;
350
- const remembered = {
351
- key: rememberKey(marker),
352
- toolName: marker.toolName,
353
- operation: marker.operation,
354
- policyId: marker.policyId,
355
- matchedRule: marker.matchedRule,
356
- remember: marker.remember,
357
- sessionId: marker.sessionId,
358
- confirmedAt: now.toISOString(),
359
- expiresAt: new Date(now.getTime() + ttl).toISOString(),
360
- };
361
- ledger.remembered = ledger.remembered.filter(r => r.key !== remembered.key);
362
- ledger.remembered.push(remembered);
363
- if (ledger.remembered.length > MAX_REMEMBERED)
364
- ledger.remembered = ledger.remembered.slice(-MAX_REMEMBERED);
365
- }
366
- writeLedger(ledger);
367
- return { marker, confirmLatencyMs: Math.max(0, now.getTime() - Date.parse(marker.raisedAt)) };
486
+ return mutateLedger(undefined, () => {
487
+ const ledger = readLedger();
488
+ const key = pendingKey(event, toolName, argsDigest);
489
+ const marker = ledger.pending.find(p => p.key === key);
490
+ if (!marker)
491
+ return undefined;
492
+ ledger.pending = ledger.pending.filter(p => p.key !== key);
493
+ if (marker.policyHash && marker.remember !== 'none' && Date.parse(marker.raisedAt) <= now.getTime() && now.getTime() - Date.parse(marker.raisedAt) < exports.CONFIRM_PENDING_TTL_MS) {
494
+ const ttl = marker.remember === '1h' ? REMEMBER_1H_MS : REMEMBER_SESSION_MAX_MS;
495
+ const remembered = {
496
+ key: rememberKey(marker),
497
+ toolName: marker.toolName,
498
+ operation: marker.operation,
499
+ policyId: marker.policyId,
500
+ matchedRule: marker.matchedRule,
501
+ remember: marker.remember,
502
+ sessionId: marker.sessionId,
503
+ confirmedAt: now.toISOString(),
504
+ expiresAt: new Date(now.getTime() + ttl).toISOString(),
505
+ };
506
+ ledger.remembered = ledger.remembered.filter(r => r.key !== remembered.key);
507
+ ledger.remembered.push(remembered);
508
+ if (ledger.remembered.length > MAX_REMEMBERED)
509
+ ledger.remembered = ledger.remembered.slice(-MAX_REMEMBERED);
510
+ }
511
+ writeLedger(ledger);
512
+ return { marker, confirmLatencyMs: Math.max(0, now.getTime() - Date.parse(marker.raisedAt)) };
513
+ });
368
514
  }
369
515
  /** Reject or cancel a pending request without creating approval memory. */
370
516
  function rejectDeveloperConfirmation(event, toolName, argsDigest) {
371
- const ledger = readLedger();
372
- const key = pendingKey(event, toolName, argsDigest);
373
- ledger.pending = ledger.pending.filter(p => p.key !== key);
374
- writeLedger(ledger);
517
+ return mutateLedger(undefined, () => {
518
+ const ledger = readLedger();
519
+ const key = pendingKey(event, toolName, argsDigest);
520
+ ledger.pending = ledger.pending.filter(p => p.key !== key);
521
+ writeLedger(ledger);
522
+ });
375
523
  }
376
524
  /**
377
525
  * Markers older than the TTL never saw a post-execution hook: the developer denied the IDE
@@ -379,24 +527,201 @@ function rejectDeveloperConfirmation(event, toolName, argsDigest) {
379
527
  * record them as not approved.
380
528
  */
381
529
  function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PENDING_TTL_MS) {
382
- const ledger = readLedger();
383
- const cutoff = now.getTime() - ttlMs;
384
- const expired = ledger.pending.filter(p => Date.parse(p.raisedAt) < cutoff);
385
- const remembered = ledger.remembered.filter(r => Date.parse(r.expiresAt) > now.getTime());
386
- const confirmedActions = ledger.confirmedActions.filter(c => Date.parse(c.expiresAt) > now.getTime());
387
- if (expired.length > 0 || remembered.length !== ledger.remembered.length || confirmedActions.length !== ledger.confirmedActions.length) {
388
- ledger.pending = ledger.pending.filter(p => Date.parse(p.raisedAt) >= cutoff);
389
- ledger.remembered = remembered;
390
- ledger.confirmedActions = confirmedActions;
530
+ return mutateLedger([], () => {
531
+ const ledger = readLedger();
532
+ const cutoff = now.getTime() - ttlMs;
533
+ const expired = ledger.pending.filter(p => Date.parse(p.raisedAt) < cutoff);
534
+ const remembered = ledger.remembered.filter(r => Date.parse(r.expiresAt) > now.getTime());
535
+ const confirmedActions = ledger.confirmedActions.filter(c => Date.parse(c.expiresAt) > now.getTime());
536
+ const grants = ledger.grants.filter(g => Date.parse(g.expiresAt) > now.getTime());
537
+ if (expired.length > 0 || remembered.length !== ledger.remembered.length || confirmedActions.length !== ledger.confirmedActions.length || grants.length !== ledger.grants.length) {
538
+ ledger.pending = ledger.pending.filter(p => Date.parse(p.raisedAt) >= cutoff);
539
+ ledger.remembered = remembered;
540
+ ledger.confirmedActions = confirmedActions;
541
+ ledger.grants = grants;
542
+ writeLedger(ledger);
543
+ }
544
+ return expired;
545
+ });
546
+ }
547
+ // ---------------------------------------------------------------------------
548
+ // "Always allow" grants
549
+ // ---------------------------------------------------------------------------
550
+ /** Shell words that run the program that follows them, not a program of their own. */
551
+ const SHELL_LEAD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'nice', 'nohup', 'time', 'command', 'builtin', 'exec', 'xargs', 'timeout', 'stdbuf', 'chronic', 'busybox', 'call', 'start', '&']);
552
+ /** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
553
+ function splitShellSegments(command) {
554
+ const segments = [];
555
+ let current = '';
556
+ let quote = null;
557
+ for (let i = 0; i < command.length; i++) {
558
+ const ch = command[i];
559
+ if (quote) {
560
+ current += ch;
561
+ if (ch === quote)
562
+ quote = null;
563
+ else if (ch === '\\' && quote === '"' && i + 1 < command.length)
564
+ current += command[++i];
565
+ continue;
566
+ }
567
+ if (ch === '"' || ch === "'" || ch === '`') {
568
+ quote = ch;
569
+ current += ch;
570
+ continue;
571
+ }
572
+ if (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i + 1] === '&')) {
573
+ if (ch === '&')
574
+ i++;
575
+ if (ch === '|' && command[i + 1] === '|')
576
+ i++;
577
+ segments.push(current);
578
+ current = '';
579
+ continue;
580
+ }
581
+ current += ch;
582
+ }
583
+ segments.push(current);
584
+ return segments;
585
+ }
586
+ /**
587
+ * The programs a shell line runs — one per segment, the wrapper (`sudo`, `env`),
588
+ * the `$var =` assignment, `KEY=value` prefixes and a leading `(` peeled off; the
589
+ * path and `.exe` dropped; lower-cased. Sorted and unique. A segment with no
590
+ * program (pure block structure like `} catch {`) contributes nothing.
591
+ */
592
+ function shellPrograms(command) {
593
+ const out = new Set();
594
+ for (const raw of splitShellSegments(command)) {
595
+ const words = raw.trim().replace(/^[\s({]+/, '').split(/\s+/).filter(Boolean);
596
+ let i = 0;
597
+ // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …`
598
+ while (i < words.length) {
599
+ const w = words[i];
600
+ if (/^\$[\w:.]+$/.test(w) && words[i + 1] && /^[+-]?=$/.test(words[i + 1])) {
601
+ i += 2;
602
+ continue;
603
+ }
604
+ if (/^\$[\w:.]+[+-]?=/.test(w) || /^[A-Za-z_][\w]*=/.test(w)) {
605
+ i++;
606
+ continue;
607
+ }
608
+ if (SHELL_LEAD_WRAPPERS.has(w.toLowerCase()) || /^-/.test(w)) {
609
+ i++;
610
+ continue;
611
+ }
612
+ break;
613
+ }
614
+ const lead = words[i];
615
+ if (!lead || /^[$@(){}\]\[|&;<>#'"`-]/.test(lead) || /^(if|then|else|elif|fi|do|done|for|while|until|case|esac|try|catch|finally|foreach|function|return|switch|break|continue)$/i.test(lead))
616
+ continue;
617
+ const name = lead.replace(/^["']|["']$/g, '').split(/[\\/]/).pop().replace(/\.(exe|cmd|bat|ps1|sh)$/i, '').toLowerCase();
618
+ if (name)
619
+ out.add(name);
620
+ }
621
+ return [...out].sort();
622
+ }
623
+ function grantKey(scope, policyId, matchedRule, policyHash) {
624
+ return sha256(JSON.stringify([scope.event, scope.toolName, scope.programs || null, scope.operation, scope.destinationDomain || null, policyId || null, matchedRule || null, policyHash]));
625
+ }
626
+ /**
627
+ * Does a live grant cover this action under this exact rule? Shell programs match by
628
+ * INCLUSION: a grant for `{gh, git}` covers a line that only runs `gh`, never a line
629
+ * that also runs `curl`. Everything else must be equal. Touch counts a use.
630
+ */
631
+ function findDeveloperGrant(scope, rule, now = new Date()) {
632
+ if (!rule.policyHash)
633
+ return undefined;
634
+ return mutateLedger(undefined, () => {
635
+ const ledger = readLedger();
636
+ const hit = ledger.grants.find(g => g.policyHash === rule.policyHash
637
+ && (g.policyId || null) === (rule.policyId || null)
638
+ && (g.matchedRule || null) === (rule.matchedRule || null)
639
+ && g.scope.event === scope.event
640
+ && g.scope.toolName === scope.toolName
641
+ && g.scope.operation === scope.operation
642
+ && (g.scope.destinationDomain || null) === (scope.destinationDomain || null)
643
+ && Number.isFinite(Date.parse(g.expiresAt)) && Date.parse(g.expiresAt) > now.getTime()
644
+ && Number.isFinite(Date.parse(g.grantedAt)) && Date.parse(g.grantedAt) <= now.getTime()
645
+ && Date.parse(g.expiresAt) - Date.parse(g.grantedAt) <= exports.GRANT_TTL_MS
646
+ && (scope.event !== 'shell'
647
+ || (Array.isArray(g.scope.programs) && (scope.programs || []).length > 0 && (scope.programs || []).every(p => g.scope.programs.includes(p)))));
648
+ if (!hit)
649
+ return undefined;
650
+ hit.lastUsedAt = now.toISOString();
651
+ hit.uses = (Number(hit.uses) || 0) + 1;
652
+ writeLedger(ledger);
653
+ return hit;
654
+ });
655
+ }
656
+ /** The developer pressed "Always allow": store the grant (replacing any prior one for the same key). */
657
+ function recordDeveloperGrant(scope, rule, now = new Date()) {
658
+ if (scope.event === 'shell' && (!scope.programs || scope.programs.length === 0))
659
+ return undefined;
660
+ const grant = {
661
+ key: grantKey(scope, rule.policyId, rule.matchedRule, rule.policyHash),
662
+ scope,
663
+ policyId: rule.policyId,
664
+ policyName: rule.policyName,
665
+ matchedRule: rule.matchedRule,
666
+ policyHash: rule.policyHash,
667
+ grantedAt: now.toISOString(),
668
+ expiresAt: new Date(now.getTime() + exports.GRANT_TTL_MS).toISOString(),
669
+ uses: 0,
670
+ };
671
+ return mutateLedger(undefined, () => {
672
+ const ledger = readLedger();
673
+ ledger.grants = ledger.grants.filter(g => g.key !== grant.key && Date.parse(g.expiresAt) > now.getTime());
674
+ ledger.grants.push(grant);
675
+ if (ledger.grants.length > MAX_GRANTS)
676
+ ledger.grants = ledger.grants.slice(-MAX_GRANTS);
391
677
  writeLedger(ledger);
678
+ return grant;
679
+ });
680
+ }
681
+ function listDeveloperGrants(now = new Date()) {
682
+ return readLedger().grants.filter(g => Date.parse(g.expiresAt) > now.getTime());
683
+ }
684
+ /** Remove one grant by key, or every grant when `key` is omitted. Returns how many were removed. */
685
+ function revokeDeveloperGrants(key) {
686
+ return mutateLedger(0, () => {
687
+ const ledger = readLedger();
688
+ const before = ledger.grants.length;
689
+ ledger.grants = key ? ledger.grants.filter(g => g.key !== key) : [];
690
+ if (ledger.grants.length !== before)
691
+ writeLedger(ledger);
692
+ return before - ledger.grants.length;
693
+ });
694
+ }
695
+ /** One line a person can read on the button and in the grant list: what "Always allow" covers. */
696
+ function describeGrantScope(scope) {
697
+ const what = scope.event === 'shell'
698
+ ? `\`${(scope.programs || []).join('`, `')}\` commands`
699
+ : scope.event === 'mcp'
700
+ ? `${scope.toolName.replace(/^mcp__/, '').replace(/__/g, ' / ')} calls`
701
+ : `${scope.toolName} ${scope.event} actions`;
702
+ const op = scope.operation && scope.operation !== 'SHELL' ? ` (${scope.operation.toLowerCase()})` : '';
703
+ const where = scope.destinationDomain ? ` to ${scope.destinationDomain}` : '';
704
+ return `${what}${op}${where}`;
705
+ }
706
+ /** The scope a grant would have for this action — from engine facts, never from text. */
707
+ function grantScopeFor(event, toolName, toolArgs, operation, context) {
708
+ const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
709
+ if (event === 'shell') {
710
+ const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
711
+ scope.programs = shellPrograms(command);
392
712
  }
393
- return expired;
713
+ const domain = context?.['destination.domain'];
714
+ if (typeof domain === 'string' && domain.trim())
715
+ scope.destinationDomain = domain.trim().toLowerCase();
716
+ return scope;
394
717
  }
395
718
  /**
396
- * Is this tool + rule covered by a still-valid developer confirmation? Session memory only
397
- * matches the same IDE session id; 1h memory matches any session on this machine.
719
+ * Is this exact action covered in the same session and policy context?
720
+ * The 1h choice is a shorter expiry, never authorization for another session.
398
721
  */
399
722
  function isConfirmationRemembered(scope, sessionId, now = new Date()) {
723
+ if (!scope.policyHash || !scope.argsDigest)
724
+ return undefined;
400
725
  const ledger = readLedger();
401
726
  const key = rememberKey(scope);
402
727
  const hit = ledger.remembered.find(r => r.key === key);
@@ -404,7 +729,12 @@ function isConfirmationRemembered(scope, sessionId, now = new Date()) {
404
729
  return undefined;
405
730
  if (!Number.isFinite(Date.parse(hit.expiresAt)) || Date.parse(hit.expiresAt) <= now.getTime())
406
731
  return undefined;
407
- if (hit.remember === 'session' && (!sessionId || hit.sessionId !== sessionId))
732
+ if (!sessionId || hit.sessionId !== sessionId)
733
+ return undefined;
734
+ if (!['session', '1h'].includes(hit.remember) || !Number.isFinite(Date.parse(hit.confirmedAt)) || Date.parse(hit.confirmedAt) > now.getTime())
735
+ return undefined;
736
+ const maxTtl = hit.remember === '1h' ? REMEMBER_1H_MS : REMEMBER_SESSION_MAX_MS;
737
+ if (Date.parse(hit.expiresAt) - Date.parse(hit.confirmedAt) > maxTtl)
408
738
  return undefined;
409
739
  return hit;
410
740
  }