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.
- package/README.md +1 -0
- package/dist/actionIdentity.d.ts +1 -0
- package/dist/actionIdentity.js +2 -0
- package/dist/actionPolicyEngine.d.ts +5 -0
- package/dist/actionPolicyEngine.js +273 -30
- package/dist/agentTerminal.d.ts +1 -3
- package/dist/agentTerminal.js +14 -29
- package/dist/approvalPrompt.d.ts +7 -0
- package/dist/approvalPrompt.js +46 -0
- package/dist/askDialog.d.ts +87 -0
- package/dist/askDialog.js +209 -0
- package/dist/blockExplanation.js +18 -6
- package/dist/boundaryVerification.d.ts +20 -0
- package/dist/boundaryVerification.js +147 -0
- package/dist/commands/daemon.js +198 -70
- package/dist/commands/deterministicGuard.d.ts +2 -0
- package/dist/commands/deterministicGuard.js +161 -9
- package/dist/commands/hook.js +81 -29
- package/dist/commands/installClaudeHook.js +11 -5
- package/dist/commands/installCursorHook.js +5 -4
- package/dist/commands/mcpGateway.d.ts +2 -0
- package/dist/commands/mcpGateway.js +163 -56
- package/dist/commands/onboard.js +6 -5
- package/dist/commands/onboardingSummary.d.ts +5 -0
- package/dist/commands/onboardingSummary.js +11 -0
- package/dist/commands/protectedRun.d.ts +14 -0
- package/dist/commands/protectedRun.js +207 -0
- package/dist/containment-runtime/index.mjs +30118 -0
- package/dist/containment-runtime/licenses/_anthropic-ai_sandbox-runtime.txt +201 -0
- package/dist/containment-runtime/licenses/_pondwader_socks5-server.txt +20 -0
- package/dist/containment-runtime/licenses/commander.txt +22 -0
- package/dist/containment-runtime/licenses/node-forge.txt +331 -0
- package/dist/containment-runtime/licenses/zod.txt +21 -0
- package/dist/containment-runtime/runtime-version.json +1 -0
- package/dist/containment-runtime/vendor/java-proxy-agent/build.ts +95 -0
- package/dist/containment-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
- package/dist/containment-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/dist/containment-runtime/vendor/seccomp/build.ts +69 -0
- package/dist/containment-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
- package/dist/containment-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
- package/dist/containment-runtime/vendor/srt-win/build.ts +21 -0
- package/dist/containment-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
- package/dist/containmentLease.d.ts +5 -0
- package/dist/containmentLease.js +72 -0
- package/dist/detectionUpdates.d.ts +23 -3
- package/dist/detectionUpdates.js +45 -14
- package/dist/detectorArtifact.d.ts +17 -0
- package/dist/detectorArtifact.js +35 -0
- package/dist/detectorBaseline.d.ts +2 -0
- package/dist/detectorBaseline.js +5 -0
- package/dist/detectorModule.d.ts +9 -0
- package/dist/detectorModule.js +84 -0
- package/dist/detectorRuntime.d.ts +21 -0
- package/dist/detectorRuntime.js +49 -0
- package/dist/detectorUpdates.d.ts +21 -0
- package/dist/detectorUpdates.js +86 -0
- package/dist/devConfirm.d.ts +97 -15
- package/dist/devConfirm.js +447 -117
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/frictionRollout.js +103 -0
- package/dist/gatewayRuntime.d.ts +19 -0
- package/dist/gatewayRuntime.js +93 -0
- package/dist/hookSlim.js +17 -18
- package/dist/index.js +35 -0
- package/dist/localDetectionUpdates.d.ts +3 -1
- package/dist/localDetectionUpdates.js +8 -1
- package/dist/localSafetySnapshot.d.ts +9 -5
- package/dist/localSafetySnapshot.js +42 -15
- package/dist/mcpToolReview.d.ts +17 -0
- package/dist/mcpToolReview.js +127 -0
- package/dist/mcpToolTrust.d.ts +22 -0
- package/dist/mcpToolTrust.js +66 -0
- package/dist/nativeHook.d.ts +4 -0
- package/dist/nativeHook.js +31 -17
- package/dist/policyRefresh.d.ts +3 -0
- package/dist/policyRefresh.js +27 -0
- package/dist/protectionMaintenance.d.ts +10 -0
- package/dist/protectionMaintenance.js +64 -0
- package/dist/protectionProfile.d.ts +26 -0
- package/dist/protectionProfile.js +63 -0
- package/dist/runtimeConfig.d.ts +2 -0
- package/dist/runtimeConfig.js +65 -26
- package/dist/selfTest.d.ts +2 -0
- package/dist/selfTest.js +9 -1
- package/dist/sessionLimits.d.ts +2 -0
- package/dist/sessionLimits.js +24 -0
- package/dist/telemetry.d.ts +5 -0
- package/dist/telemetry.js +8 -0
- package/dist/version.json +1 -1
- package/package.json +10 -6
package/dist/devConfirm.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
166
|
-
|
|
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:
|
|
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.
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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(
|
|
383
|
+
return sha256(`invalid-action:${crypto.randomUUID()}`);
|
|
257
384
|
}
|
|
258
385
|
}
|
|
259
386
|
function pendingKey(event, toolName, argsDigest) {
|
|
260
|
-
return sha256(
|
|
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(
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
ledger.confirmedActions
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
ledger.pending
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
ledger.remembered
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
ledger.remembered
|
|
390
|
-
|
|
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
|
-
|
|
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
|
|
397
|
-
*
|
|
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 (
|
|
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
|
}
|