fullcourtdefense-cli 1.39.0 → 1.39.2
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/commands/cmdGuard.js +2 -2
- package/dist/commands/daemon.js +10 -5
- package/dist/commands/demoActions.js +37 -6
- package/dist/commands/deterministicGuard.js +48 -4
- package/dist/commands/hook.d.ts +18 -0
- package/dist/commands/hook.js +97 -6
- package/dist/commands/posixShellGuard.js +2 -2
- package/dist/commands/shellGuard.d.ts +6 -0
- package/dist/commands/shellGuard.js +5 -2
- package/dist/devConfirm.d.ts +1 -26
- package/dist/devConfirm.js +46 -192
- package/dist/devConfirmLedger.d.ts +74 -0
- package/dist/devConfirmLedger.js +330 -0
- package/dist/housekeepingOps.js +12 -3
- package/dist/monitorFastPath.d.ts +1 -1
- package/dist/monitorFastPath.js +5 -9
- package/dist/pollLoop.d.ts +6 -0
- package/dist/pollLoop.js +5 -2
- package/dist/telemetry.d.ts +12 -0
- package/dist/telemetry.js +27 -7
- package/dist/verdictIpc.d.ts +16 -2
- package/dist/verdictIpc.js +76 -3
- package/dist/verdictIpcClient.d.ts +17 -0
- package/dist/verdictIpcClient.js +42 -3
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -157,7 +157,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
157
157
|
`function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
|
|
158
158
|
`// "s = remember for this cmd window": keyed by the parent cmd.exe pid (doskey runs us directly under it); dies with the window, capped in time.`,
|
|
159
159
|
`const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
|
|
160
|
-
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
160
|
+
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',fallback:r.fallback===true,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
161
161
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
162
162
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
163
163
|
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
@@ -182,7 +182,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
182
182
|
`const line=args.join(' ');let mode='monitor';let hit=null;try{const r=loadRules();mode=r.mode;hit=matchLine(line,r.rules);}catch{hit=null;}`,
|
|
183
183
|
`if(!hit)delegate(args);`,
|
|
184
184
|
`// Monitor is quiet: a would-block / would-ask is recorded ('warn') and the command runs with nothing printed. A warn-only rule is a real warning (below).`,
|
|
185
|
-
`if(mode==='monitor'&&hit.action!=='warn'){spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
185
|
+
`if(mode==='monitor'&&(hit.action!=='warn'||hit.fallback)){spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
186
186
|
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
187
187
|
`if(hit.action==='ask'){`,
|
|
188
188
|
` if(agentTerminal()){`,
|
package/dist/commands/daemon.js
CHANGED
|
@@ -1921,9 +1921,8 @@ async function runDaemon(args, config) {
|
|
|
1921
1921
|
// reports every failure by returning null and never throws, so without
|
|
1922
1922
|
// this flag a heartbeat that never left the machine was indistinguishable
|
|
1923
1923
|
// from a delivered one — and the loop happily waited a full cadence before
|
|
1924
|
-
// trying again.
|
|
1925
|
-
// flush
|
|
1926
|
-
// not set `daemon: true`), so retrying soon is right in that case too.
|
|
1924
|
+
// trying again. A hook flusher holding the spool lock is not a miss: the
|
|
1925
|
+
// flush op then sends this beat without the spool (housekeepingOps).
|
|
1927
1926
|
let reported = false;
|
|
1928
1927
|
let refusal;
|
|
1929
1928
|
try {
|
|
@@ -2071,9 +2070,14 @@ async function runDaemon(args, config) {
|
|
|
2071
2070
|
// the `unhandledRejection` handler, which is worse than dying: it holds the
|
|
2072
2071
|
// pid lock, so the scheduled task declines to start a replacement, and it
|
|
2073
2072
|
// never polls the bundle, so no remote action can reach it. Booting before
|
|
2074
|
-
// the network is up (captive portal, VPN, resumed in a lift) is routine
|
|
2075
|
-
// the heartbeat loop below
|
|
2073
|
+
// the network is up (captive portal, VPN, resumed in a lift) is routine.
|
|
2074
|
+
// A failed first beat is handed to the heartbeat loop below as a failure, so
|
|
2075
|
+
// its first tick is a retry in seconds. It used to wait a full cadence
|
|
2076
|
+
// (5 min): after the 1.39.1 auto-update the console kept showing the old
|
|
2077
|
+
// version, "auto-update pending" and a stale "Online" time for that window.
|
|
2078
|
+
let firstHeartbeatFailed = false;
|
|
2076
2079
|
await heartbeat().catch(error => {
|
|
2080
|
+
firstHeartbeatFailed = true;
|
|
2077
2081
|
log(`First heartbeat failed: ${error?.message || String(error)}. The heartbeat loop will retry shortly.`);
|
|
2078
2082
|
});
|
|
2079
2083
|
// Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
|
|
@@ -2217,6 +2221,7 @@ async function runDaemon(args, config) {
|
|
|
2217
2221
|
run: heartbeat,
|
|
2218
2222
|
intervalMs: () => HEARTBEAT_INTERVAL_MS,
|
|
2219
2223
|
deadlineMs: HEARTBEAT_DEADLINE_MS,
|
|
2224
|
+
initialFailures: firstHeartbeatFailed ? 1 : 0,
|
|
2220
2225
|
onStalled: deadlineMs => {
|
|
2221
2226
|
log(`Heartbeat did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; spooled events stay on disk for the next tick.`);
|
|
2222
2227
|
(0, distress_1.reportDistress)('heartbeat', distress_1.DISTRESS.POLL_STALLED, `heartbeat exceeded ${deadlineMs}ms`);
|
|
@@ -274,6 +274,9 @@ function runScenario(scenario, opts) {
|
|
|
274
274
|
// The resident daemon (if any) must not answer for the sandbox: it would
|
|
275
275
|
// evaluate with the REAL home and spool real events.
|
|
276
276
|
FCD_VERDICT_IPC: 'off',
|
|
277
|
+
// Monitor is quiet to the agent (monitorHint); this replay reports what
|
|
278
|
+
// a monitor machine WOULD block, so it asks the hook for that text.
|
|
279
|
+
FCD_MONITOR_HINTS: '1',
|
|
277
280
|
FULLCOURTDEFENSE_DEVELOPER_ID: opts.developerId,
|
|
278
281
|
},
|
|
279
282
|
});
|
|
@@ -367,6 +370,38 @@ function renderReport(report) {
|
|
|
367
370
|
lines.push('');
|
|
368
371
|
return lines.join('\n');
|
|
369
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* The last replayed hook starts a detached flush-spool child in the sandbox
|
|
375
|
+
* home (its cwd, and it holds the spool open). Windows refuses to remove a
|
|
376
|
+
* folder in use, so wait for that child (bounded), then remove with retries.
|
|
377
|
+
*/
|
|
378
|
+
async function removeSandboxHome(home) {
|
|
379
|
+
const marker = path.join(home, '.fullcourtdefense-flush.ts');
|
|
380
|
+
const deadline = Date.now() + 5_000;
|
|
381
|
+
while (Date.now() < deadline) {
|
|
382
|
+
let running = false;
|
|
383
|
+
try {
|
|
384
|
+
const [, pid, state] = fs.readFileSync(marker, 'utf8').trim().split(':');
|
|
385
|
+
if (state !== 'done' && state !== 'fail' && Number(pid) > 0) {
|
|
386
|
+
try {
|
|
387
|
+
process.kill(Number(pid), 0);
|
|
388
|
+
running = true;
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
running = error.code === 'EPERM';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch { /* no marker: no flusher was started */ }
|
|
396
|
+
if (!running)
|
|
397
|
+
break;
|
|
398
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
402
|
+
}
|
|
403
|
+
catch { /* best effort */ }
|
|
404
|
+
}
|
|
370
405
|
async function demoActionsCommand(args, config) {
|
|
371
406
|
const live = args.live === 'true';
|
|
372
407
|
const json = args.json === 'true';
|
|
@@ -419,12 +454,8 @@ async function demoActionsCommand(args, config) {
|
|
|
419
454
|
}
|
|
420
455
|
}
|
|
421
456
|
finally {
|
|
422
|
-
if (cleanup)
|
|
423
|
-
|
|
424
|
-
fs.rmSync(cleanup, { recursive: true, force: true });
|
|
425
|
-
}
|
|
426
|
-
catch { /* best effort */ }
|
|
427
|
-
}
|
|
457
|
+
if (cleanup)
|
|
458
|
+
await removeSandboxHome(cleanup);
|
|
428
459
|
}
|
|
429
460
|
const report = {
|
|
430
461
|
mode: live ? 'live' : 'offline',
|
|
@@ -286,6 +286,14 @@ function deobfuscateShellTokens(text) {
|
|
|
286
286
|
out = out.replace(/\\(?=[A-Za-z0-9])/g, '');
|
|
287
287
|
return out.replace(/\s+/g, ' ').trim();
|
|
288
288
|
}
|
|
289
|
+
/** Only `${IFS}` / `$IFS` and `{brace,words}` undone: quotes and backslashes keep their meaning. */
|
|
290
|
+
function unscrambleShellWords(text) {
|
|
291
|
+
let out = text.replace(/\$\{IFS[^}]*\}/g, ' ').replace(/\$IFS\b/g, ' ');
|
|
292
|
+
for (let i = 0; i < 3 && /\{[^{}]*,[^{}]*\}/.test(out); i++) {
|
|
293
|
+
out = out.replace(/\{([^{}]*,[^{}]*)\}/g, (_m, body) => body.split(',').join(' '));
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
289
297
|
/** bash/zsh ANSI-C quoting: `$'\x72\x6d'` IS `rm`. Decode `\xHH`, `\uHHHH`,
|
|
290
298
|
* octal, and single-char escapes, splicing plain text back into the command. */
|
|
291
299
|
function decodeAnsiCQuoting(text) {
|
|
@@ -2025,7 +2033,39 @@ function destructiveCommandReason(value, opts = {}, options, rawValue) {
|
|
|
2025
2033
|
return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
|
|
2026
2034
|
return undefined;
|
|
2027
2035
|
};
|
|
2028
|
-
const windowsDelete = () =>
|
|
2036
|
+
const windowsDelete = () => {
|
|
2037
|
+
if (!on('windows_drive_delete'))
|
|
2038
|
+
return undefined;
|
|
2039
|
+
const raw = rawValue === undefined ? value : rawValue;
|
|
2040
|
+
const direct = windowsRecursiveDeleteHit(raw);
|
|
2041
|
+
if (direct)
|
|
2042
|
+
return direct;
|
|
2043
|
+
// Word disguises (${IFS}, {brace,words}, ANSI-C $'\x..'), also inside encoded
|
|
2044
|
+
// payloads, undone WITHOUT dequoting: quoted text stays data and backslashes
|
|
2045
|
+
// stay path separators, so the same target classifier still tells a drive
|
|
2046
|
+
// from a deep project path (1.38 parity: 306/306 disguised drive wipes).
|
|
2047
|
+
const seeds = [raw];
|
|
2048
|
+
let frontier = [raw];
|
|
2049
|
+
for (let depth = 0; depth < 2 && frontier.length > 0; depth++) {
|
|
2050
|
+
const next = [];
|
|
2051
|
+
for (const source of frontier)
|
|
2052
|
+
for (const decoded of decodeEncodedPayloads(source)) {
|
|
2053
|
+
seeds.push(decoded);
|
|
2054
|
+
next.push(decoded);
|
|
2055
|
+
}
|
|
2056
|
+
frontier = next;
|
|
2057
|
+
}
|
|
2058
|
+
for (const seed of seeds) {
|
|
2059
|
+
for (const variant of [unscrambleShellWords(seed), decodeAnsiCQuoting(seed), unscrambleShellWords(decodeAnsiCQuoting(seed))]) {
|
|
2060
|
+
if (variant === seed)
|
|
2061
|
+
continue;
|
|
2062
|
+
const hit = windowsRecursiveDeleteHit(variant);
|
|
2063
|
+
if (hit)
|
|
2064
|
+
return hit;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
return undefined;
|
|
2068
|
+
};
|
|
2029
2069
|
if (!opts.literalOnly)
|
|
2030
2070
|
return matchCommand(value, check) || windowsDelete();
|
|
2031
2071
|
const literal = normalizeForCommand(value);
|
|
@@ -2125,7 +2165,7 @@ function windowsRecursiveDeleteHit(raw) {
|
|
|
2125
2165
|
// Cheap gate on the verbs, past carets and quote splitting (`r^d`, `de"l"`); an
|
|
2126
2166
|
// encoded payload (`powershell -e <b64>`, FromBase64String, `base64 -d`) is
|
|
2127
2167
|
// decoded below however it is spelled, as commandVariants decodes it.
|
|
2128
|
-
if (!/\b(?:del|erase|rd|rmdir)\b/i.test(raw.replace(/[\^"'`]/g, '')) && !decodeEncodedPayloads(raw).length)
|
|
2168
|
+
if (!/\b(?:del|erase|rd|rmdir)\b/i.test(raw.replace(/[\^"'`]|\\(?=[A-Za-z])/g, '')) && !decodeEncodedPayloads(raw).length)
|
|
2129
2169
|
return undefined;
|
|
2130
2170
|
let text = raw;
|
|
2131
2171
|
const heredocs = heredocView(raw);
|
|
@@ -2136,7 +2176,8 @@ function windowsRecursiveDeleteHit(raw) {
|
|
|
2136
2176
|
// Quoted text that can reach an interpreter (`… | cmd`, `powershell -c "…"`) is code.
|
|
2137
2177
|
const feeds = DATA_TO_INTERPRETER.test(unquotedCode(text));
|
|
2138
2178
|
const assigned = new Map();
|
|
2139
|
-
|
|
2179
|
+
// PowerShell runs an unquoted word with its backtick escapes removed (`d`e`l` is `del`).
|
|
2180
|
+
const dequote = (token) => stripCmdCarets(token.replace(/["']/g, '').replace(/`(.)/g, '$1'));
|
|
2140
2181
|
const resolve = (operand) => {
|
|
2141
2182
|
let via;
|
|
2142
2183
|
let value = dequote(operand);
|
|
@@ -2181,7 +2222,10 @@ function windowsRecursiveDeleteHit(raw) {
|
|
|
2181
2222
|
}
|
|
2182
2223
|
continue;
|
|
2183
2224
|
}
|
|
2184
|
-
|
|
2225
|
+
// A POSIX shell reads `\d\e\l` as `del` (Git Bash, WSL); only the verb is
|
|
2226
|
+
// unescaped this way — a target keeps its backslashes (`C:\dev` is a path).
|
|
2227
|
+
const verb = dequote(token);
|
|
2228
|
+
if (!WINDOWS_DELETE_VERBS.has(programName(verb)) && !WINDOWS_DELETE_VERBS.has(verb.replace(/\\([A-Za-z])/g, '$1').toLowerCase()))
|
|
2185
2229
|
continue;
|
|
2186
2230
|
let recurse = false, quiet = false;
|
|
2187
2231
|
const targets = [];
|
package/dist/commands/hook.d.ts
CHANGED
|
@@ -237,6 +237,8 @@ export interface HookIo {
|
|
|
237
237
|
viaDaemon?: boolean;
|
|
238
238
|
/** Set when this evaluation's verdict is a stop still being settled (approval wait). */
|
|
239
239
|
pendingStop?: string;
|
|
240
|
+
/** The daemon already gave the hook this event's allow (postExecutionAnswer); this evaluation only records and settles. */
|
|
241
|
+
answeredFirst?: boolean;
|
|
240
242
|
}
|
|
241
243
|
/**
|
|
242
244
|
* The daemon declines a request whose verdict is a human wait (org approval,
|
|
@@ -281,7 +283,23 @@ export declare function evaluateHookRequest(args: HookArgs, stdinRaw: string, co
|
|
|
281
283
|
stdout: string;
|
|
282
284
|
stderr: string;
|
|
283
285
|
exitCode: number;
|
|
286
|
+
afterReply?: () => Promise<void>;
|
|
284
287
|
}>;
|
|
288
|
+
/**
|
|
289
|
+
* The verdict a post-execution event gets from hookCommandInner — byte for
|
|
290
|
+
* byte what respond(false) writes for it — or undefined for every other
|
|
291
|
+
* request. Every path such an event takes ends in that plain allow: the
|
|
292
|
+
* kill-switch branch (the action already ran; a post hook is not a
|
|
293
|
+
* pre-execution surface), the ignored branch (a passive tool's post event:
|
|
294
|
+
* it does not precede an action) and the post branch, none with a message.
|
|
295
|
+
* Covered: Claude-format PostToolUse (any tool), a Cursor generic postToolUse
|
|
296
|
+
* (any tool), and a Cursor hook whose --event the full hook reads as `post`
|
|
297
|
+
* (after* / post-*). Read from the names alone, the way normalizeClaudePayload
|
|
298
|
+
* and canonicalizeCursorPayload route them — never by mapping the tool, which
|
|
299
|
+
* for a file write reads the file. Parsed exactly as hookCommandInner parses;
|
|
300
|
+
* a payload it cannot read is not one.
|
|
301
|
+
*/
|
|
302
|
+
export declare function postExecutionAnswer(args: HookArgs, stdinRaw: string): string | undefined;
|
|
285
303
|
/**
|
|
286
304
|
* TOP-LEVEL FAIL-OPEN WRAPPER. An unexpected exception anywhere in the hook
|
|
287
305
|
* must never surface as a stack trace, a nonzero exit, or a blocked command —
|
package/dist/commands/hook.js
CHANGED
|
@@ -50,6 +50,7 @@ exports.readBackendError = readBackendError;
|
|
|
50
50
|
exports.humanWaitDeadline = humanWaitDeadline;
|
|
51
51
|
exports.crashVerdictForProcess = crashVerdictForProcess;
|
|
52
52
|
exports.evaluateHookRequest = evaluateHookRequest;
|
|
53
|
+
exports.postExecutionAnswer = postExecutionAnswer;
|
|
53
54
|
exports.hookCommand = hookCommand;
|
|
54
55
|
exports.installHookCrashHandlers = installHookCrashHandlers;
|
|
55
56
|
const os = __importStar(require("os"));
|
|
@@ -1093,18 +1094,34 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
1093
1094
|
* analysis) and the confirmation memory is stored. Otherwise the IDE may have approved its
|
|
1094
1095
|
* own card: the run is recorded as unverified — nobody named as approver — and nothing is
|
|
1095
1096
|
* remembered, so the next identical action asks again.
|
|
1097
|
+
*
|
|
1098
|
+
* A post hook whose own action has no confirmation pending (nearly every one) settles
|
|
1099
|
+
* nothing, and learns that from one unlocked read off the event loop
|
|
1100
|
+
* (pendingConfirmationKeys): no lock file, no synchronous read — and, with nothing
|
|
1101
|
+
* pending at all, no look at the call (a file write's diff reads the file). In the daemon
|
|
1102
|
+
* (`offThread`) the settle itself runs on a worker thread: its lock wait, read, write and
|
|
1103
|
+
* fsync never hold the verdict loop.
|
|
1096
1104
|
*/
|
|
1097
|
-
function settlePostExecution(explicitEvent, payload, opts = {}) {
|
|
1105
|
+
async function settlePostExecution(explicitEvent, payload, opts = {}) {
|
|
1098
1106
|
try {
|
|
1099
1107
|
const inner = inferPostInnerEvent(explicitEvent, payload);
|
|
1100
1108
|
if (inner === 'unknown' || inner === 'prompt' || inner === 'post')
|
|
1101
1109
|
return;
|
|
1110
|
+
const pending = await devConfirm().pendingConfirmationKeys();
|
|
1111
|
+
if (pending && pending.size === 0)
|
|
1112
|
+
return;
|
|
1102
1113
|
const call = buildToolCall(inner, payload);
|
|
1103
1114
|
if (!call)
|
|
1104
1115
|
return;
|
|
1116
|
+
const argsDigest = devConfirm().actionDigest(call.toolArgs);
|
|
1117
|
+
if (pending && !pending.has(devConfirm().pendingKey(inner, call.toolName, argsDigest)))
|
|
1118
|
+
return;
|
|
1105
1119
|
// A suspended machine never mints session memory: the answer is recorded, nothing is remembered.
|
|
1106
1120
|
// The answer is inferred from the IDE's own card (our dialog settles in its own process).
|
|
1107
|
-
const
|
|
1121
|
+
const settleOpts = { noMemory: opts.suspended === true, via: 'ide' };
|
|
1122
|
+
const settled = opts.offThread
|
|
1123
|
+
? await devConfirm().settleDeveloperConfirmationOffThread(inner, call.toolName, argsDigest, new Date(), settleOpts)
|
|
1124
|
+
: devConfirm().settleDeveloperConfirmation(inner, call.toolName, argsDigest, new Date(), settleOpts);
|
|
1108
1125
|
if (!settled)
|
|
1109
1126
|
return;
|
|
1110
1127
|
(0, hookIo_1.dbg)({ phase: 'dev_confirm_settled', event: inner, tool: call.toolName, confirmLatencyMs: settled.confirmLatencyMs, remember: settled.marker.remember, humanProven: settled.humanProven, ...(opts.suspended ? { suspended: true } : {}) });
|
|
@@ -1533,6 +1550,20 @@ function processIo(stdin, stdinErr) {
|
|
|
1533
1550
|
* `deferGateReports`: native hooks — see HookIo.deferGateReports.
|
|
1534
1551
|
*/
|
|
1535
1552
|
async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
|
|
1553
|
+
// POST-EXECUTION HOOKS ARE ANSWERED FIRST (daemon, a hook waiting on the
|
|
1554
|
+
// pipe). The action already ran and the answer is the same plain allow
|
|
1555
|
+
// whatever the evaluation finds, so the hook gets it now; the evaluation
|
|
1556
|
+
// itself — mode, settling a developer's IDE answer, its records — runs
|
|
1557
|
+
// after the reply (the verdict server's afterReply queue). One slow post
|
|
1558
|
+
// evaluation (a laptop under memory pressure: 8-26s, past Claude Code's
|
|
1559
|
+
// PostToolUse cutoff) used to keep its hook waiting until it fell back to a
|
|
1560
|
+
// cold local copy of the same evaluation.
|
|
1561
|
+
if (opts.viaDaemon && !opts.exitsProcess && verdictIpc().servingWaitingClient()) {
|
|
1562
|
+
const answer = postExecutionAnswer(args, stdinRaw);
|
|
1563
|
+
if (answer !== undefined) {
|
|
1564
|
+
return { stdout: answer, stderr: '', exitCode: 0, afterReply: () => evaluatePostAfterReply(args, stdinRaw, config, answer) };
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1536
1567
|
// Structural shell facts (shadow by default). A missing/slow parser means
|
|
1537
1568
|
// "no facts" and the regex floor decides alone — never a wait, never fail-open.
|
|
1538
1569
|
// A one-shot process (`exitsProcess`) starts the parser only where it may
|
|
@@ -1543,6 +1574,63 @@ async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
|
|
|
1543
1574
|
isTTY: false, exitsProcess: opts.exitsProcess === true, deferGateReports: opts.exitsProcess === true && opts.deferGateReports === true, viaDaemon: opts.viaDaemon,
|
|
1544
1575
|
});
|
|
1545
1576
|
}
|
|
1577
|
+
/**
|
|
1578
|
+
* The verdict a post-execution event gets from hookCommandInner — byte for
|
|
1579
|
+
* byte what respond(false) writes for it — or undefined for every other
|
|
1580
|
+
* request. Every path such an event takes ends in that plain allow: the
|
|
1581
|
+
* kill-switch branch (the action already ran; a post hook is not a
|
|
1582
|
+
* pre-execution surface), the ignored branch (a passive tool's post event:
|
|
1583
|
+
* it does not precede an action) and the post branch, none with a message.
|
|
1584
|
+
* Covered: Claude-format PostToolUse (any tool), a Cursor generic postToolUse
|
|
1585
|
+
* (any tool), and a Cursor hook whose --event the full hook reads as `post`
|
|
1586
|
+
* (after* / post-*). Read from the names alone, the way normalizeClaudePayload
|
|
1587
|
+
* and canonicalizeCursorPayload route them — never by mapping the tool, which
|
|
1588
|
+
* for a file write reads the file. Parsed exactly as hookCommandInner parses;
|
|
1589
|
+
* a payload it cannot read is not one.
|
|
1590
|
+
*/
|
|
1591
|
+
function postExecutionAnswer(args, stdinRaw) {
|
|
1592
|
+
const cleaned = stdinRaw.replace(/^\uFEFF/, '').replace(/^[\s\uFEFF\xA0]+/, '').trim();
|
|
1593
|
+
if (!cleaned)
|
|
1594
|
+
return undefined;
|
|
1595
|
+
let payload;
|
|
1596
|
+
try {
|
|
1597
|
+
payload = JSON.parse(cleaned);
|
|
1598
|
+
}
|
|
1599
|
+
catch {
|
|
1600
|
+
return undefined;
|
|
1601
|
+
}
|
|
1602
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1603
|
+
return undefined;
|
|
1604
|
+
const record = payload;
|
|
1605
|
+
const hookEventName = str(record.hook_event_name);
|
|
1606
|
+
// Claude format (PascalCase): PostToolUse is `post` or an ignored passive tool.
|
|
1607
|
+
if (hookEventName && !/^[a-z]/.test(hookEventName))
|
|
1608
|
+
return hookEventName === 'PostToolUse' ? JSON.stringify({}) : undefined;
|
|
1609
|
+
// Cursor generic tool events: postToolUse is `post` or ignored (canonicalizeCursorGenericToolEvent).
|
|
1610
|
+
if (hookEventName === 'postToolUse')
|
|
1611
|
+
return JSON.stringify({ permission: 'allow' });
|
|
1612
|
+
if (hookEventName === 'preToolUse')
|
|
1613
|
+
return undefined;
|
|
1614
|
+
return inferEvent(args.event, record) === 'post' ? JSON.stringify({ permission: 'allow' }) : undefined;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* The full evaluation of a post-execution event the daemon already answered
|
|
1618
|
+
* (postExecutionAnswer): the same code, its verdict discarded. Run by the
|
|
1619
|
+
* verdict server after the reply, never inside the hook's wait.
|
|
1620
|
+
*/
|
|
1621
|
+
async function evaluatePostAfterReply(args, stdinRaw, config, answered) {
|
|
1622
|
+
try {
|
|
1623
|
+
await (0, shellAstLoader_1.ensureShellAst)();
|
|
1624
|
+
const result = await evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: true, answeredFirst: true });
|
|
1625
|
+
// The answer given first must be the one this evaluation reached.
|
|
1626
|
+
if (result.stdout !== answered || result.exitCode !== 0) {
|
|
1627
|
+
(0, hookIo_1.dbg)({ phase: 'post_answer_mismatch', event: args.event, answered, verdict: result.stdout.slice(0, 200), exitCode: result.exitCode });
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
catch (err) {
|
|
1631
|
+
(0, hookIo_1.dbg)({ phase: 'post_after_reply_error', event: args.event, error: err instanceof Error ? err.message : String(err) });
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1546
1634
|
/**
|
|
1547
1635
|
* Run the hook against a buffered io and return the verdict instead of writing
|
|
1548
1636
|
* it. `exitsProcess: true` keeps the standalone hook's delivery contract (spool +
|
|
@@ -1562,6 +1650,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
|
|
|
1562
1650
|
exitsProcess: mode.exitsProcess,
|
|
1563
1651
|
deferGateReports: mode.deferGateReports,
|
|
1564
1652
|
viaDaemon: mode.viaDaemon,
|
|
1653
|
+
...(mode.answeredFirst ? { answeredFirst: true } : {}),
|
|
1565
1654
|
};
|
|
1566
1655
|
try {
|
|
1567
1656
|
await hookCommandInner(args, config, io);
|
|
@@ -2082,7 +2171,8 @@ async function hookCommandInner(args, config, io) {
|
|
|
2082
2171
|
const ideMode = idePermissionMode(payload);
|
|
2083
2172
|
decision = askDialog().attachAskEnvelope(decision, { ...askEnvelope, agentClient: client, ...(identity ? { identity } : {}), ...(ideMode ? { ideMode } : {}) });
|
|
2084
2173
|
}
|
|
2085
|
-
(0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog().ASK_ENVELOPE_FIELD]: '(envelope)' } : decision
|
|
2174
|
+
(0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog().ASK_ENVELOPE_FIELD]: '(envelope)' } : decision,
|
|
2175
|
+
...(io.answeredFirst ? { answeredFirst: true } : {}) });
|
|
2086
2176
|
if (hookFormat === 'claude' && event === 'prompt' && denied) {
|
|
2087
2177
|
// Claude Desktop/Code variants do not all honor the structured
|
|
2088
2178
|
// UserPromptSubmit decision consistently. Exit 2 is the documented hard
|
|
@@ -2112,7 +2202,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
2112
2202
|
// would keep the agent running. A post-execution hook still closes the ask it
|
|
2113
2203
|
// answers, but a suspended machine never mints session memory from it.
|
|
2114
2204
|
if (event === 'post')
|
|
2115
|
-
settlePostExecution(args.event, payload, { suspended: true });
|
|
2205
|
+
await settlePostExecution(args.event, payload, { suspended: true, offThread: io.viaDaemon === true });
|
|
2116
2206
|
respond(false);
|
|
2117
2207
|
}
|
|
2118
2208
|
// Cursor's GENERIC pre-event of an action its dedicated hook also reports
|
|
@@ -2152,9 +2242,10 @@ async function hookCommandInner(args, config, io) {
|
|
|
2152
2242
|
// Post-execution hooks: the action already ran. The only job is correlation —
|
|
2153
2243
|
// a matching pending developer confirmation means the IDE prompt we raised let
|
|
2154
2244
|
// it through (a person's Allow only where the client said its prompt reaches
|
|
2155
|
-
// one). Never enforces, never needs the network.
|
|
2245
|
+
// one). Never enforces, never needs the network. In the daemon the hook got
|
|
2246
|
+
// this allow before the evaluation began (postExecutionAnswer).
|
|
2156
2247
|
if (event === 'post') {
|
|
2157
|
-
settlePostExecution(args.event, payload);
|
|
2248
|
+
await settlePostExecution(args.event, payload, { offThread: io.viaDaemon === true });
|
|
2158
2249
|
respond(false);
|
|
2159
2250
|
}
|
|
2160
2251
|
// No Shield configured → allow (fail-open by design: a misconfigured machine
|
|
@@ -167,7 +167,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
167
167
|
`const RULES_URL=${JSON.stringify((0, notify_1.shellRulesConsoleUrl)())};`,
|
|
168
168
|
`// Which rule (by id + origin) and where an admin changes it — printed on every block so the developer is never left guessing.`,
|
|
169
169
|
`function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
|
|
170
|
-
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
170
|
+
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',fallback:r.fallback===true,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
171
171
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
172
172
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
173
173
|
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
@@ -187,7 +187,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
187
187
|
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
188
188
|
`if(!hit)process.exit(0);`,
|
|
189
189
|
`// Monitor is quiet: a would-block / would-ask is recorded ('warn') and the line runs with nothing printed. A warn-only rule is a real warning (below).`,
|
|
190
|
-
`if(mode==='monitor'&&hit.action!=='warn'){spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
190
|
+
`if(mode==='monitor'&&(hit.action!=='warn'||hit.fallback)){spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
191
191
|
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
192
192
|
`if(hit.action==='ask'){`,
|
|
193
193
|
` if(agentTerminal()){`,
|
|
@@ -33,6 +33,12 @@ export interface ShellGuardRule {
|
|
|
33
33
|
* allow. 'mask' is meaningless for a typed command and degrades to block.
|
|
34
34
|
*/
|
|
35
35
|
action?: ShellGuardAction;
|
|
36
|
+
/**
|
|
37
|
+
* 'warn' only because no Local Safety snapshot is cached yet (the org's
|
|
38
|
+
* choice is unknown). A monitor machine records it silently like any
|
|
39
|
+
* would-block; an enforcing machine still shows the warning.
|
|
40
|
+
*/
|
|
41
|
+
fallback?: boolean;
|
|
36
42
|
}
|
|
37
43
|
export type ShellGuardAction = 'block' | 'warn' | 'ask';
|
|
38
44
|
/**
|
|
@@ -617,7 +617,9 @@ function effectiveBuiltinRules() {
|
|
|
617
617
|
.filter(rule => !disabled.has(rule.id))
|
|
618
618
|
.map(rule => {
|
|
619
619
|
const action = itemActions.get(rule.id);
|
|
620
|
-
|
|
620
|
+
// No snapshot yet: the org's choice is unknown, so the rule only warns —
|
|
621
|
+
// and `fallback` keeps a monitor machine silent about it (silent monitor).
|
|
622
|
+
return action ? { ...rule, action } : hasSnapshot ? rule : { ...rule, action: 'warn', fallback: true };
|
|
621
623
|
});
|
|
622
624
|
}
|
|
623
625
|
/** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
|
|
@@ -779,6 +781,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
779
781
|
` Reason = [string]$_.reason`,
|
|
780
782
|
` Source = [string]$_.source`,
|
|
781
783
|
` Action = $(switch ([string]$_.action) { 'warn' { 'warn' } 'ask' { 'ask' } default { 'block' } })`,
|
|
784
|
+
` Fallback = ($_.fallback -eq $true)`,
|
|
782
785
|
` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
|
|
783
786
|
` }`,
|
|
784
787
|
` } catch { $null }`,
|
|
@@ -949,7 +952,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
949
952
|
` # BELOW the message so the warning stays in the scrollback permanently.`,
|
|
950
953
|
` # Monitor is quiet: a would-block / would-ask is recorded ('warn') and the`,
|
|
951
954
|
` # line runs with nothing printed. A warn-only rule is a real warning (below).`,
|
|
952
|
-
` if ($global:FcdGuardMode -eq 'monitor' -and $hit.Action -ne 'warn') {`,
|
|
955
|
+
` if ($global:FcdGuardMode -eq 'monitor' -and ($hit.Action -ne 'warn' -or $hit.Fallback)) {`,
|
|
953
956
|
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
|
|
954
957
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
955
958
|
` return`,
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type AlwaysCovers, type AskSubject, type NativeDialogView } from './approvalPrompt';
|
|
2
2
|
import { type CredentialGrantBinding, type CredentialRuleFacts } from './credentialGrant';
|
|
3
3
|
export { isCiEnvironment, isHumanPresent } from './callerEnv';
|
|
4
|
+
export { CONFIRM_PENDING_TTL_MS, pendingConfirmationKeys, pendingKey, settleDeveloperConfirmation, settleDeveloperConfirmationOffThread, type SettleOptions, type SettledConfirmation, } from './devConfirmLedger';
|
|
4
5
|
export type ConfirmRemember = 'none' | 'session' | '1h';
|
|
5
6
|
/**
|
|
6
7
|
* Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
|
|
@@ -243,8 +244,6 @@ export interface RuleGrantLimits {
|
|
|
243
244
|
maxDays?: number | null;
|
|
244
245
|
maxUses?: number | null;
|
|
245
246
|
}
|
|
246
|
-
/** A raised confirmation that received no post-execution signal in time counts as not approved. */
|
|
247
|
-
export declare const CONFIRM_PENDING_TTL_MS: number;
|
|
248
247
|
/** The hook's dialog answer only carries over to the gateway call it was given for. */
|
|
249
248
|
export declare const CONFIRMED_ACTION_TTL_MS: number;
|
|
250
249
|
/** "Always" is long, not forever: a standing grant lapses after 30 days unless renewed by use of the dialog. */
|
|
@@ -308,7 +307,6 @@ export declare function localSafetyRuleHash(finding: {
|
|
|
308
307
|
action?: string;
|
|
309
308
|
explanation?: string;
|
|
310
309
|
}>): string;
|
|
311
|
-
export declare function pendingKey(event: string, toolName: string, argsDigest: string): string;
|
|
312
310
|
/**
|
|
313
311
|
* The tool name as the MCP server sees it. The IDE hook names an MCP call
|
|
314
312
|
* `mcp__<server>__<tool>` (Codex/Claude) or `<server>/<tool>`; the gateway sees `<tool>`.
|
|
@@ -333,29 +331,6 @@ export interface RememberScope {
|
|
|
333
331
|
}
|
|
334
332
|
/** Record that the IDE was asked to confirm this action. Returns the marker (for logging). */
|
|
335
333
|
export declare function raiseDeveloperConfirmation(input: Omit<PendingConfirmation, 'key' | 'raisedAt'>, now?: Date): PendingConfirmation;
|
|
336
|
-
/**
|
|
337
|
-
* The developer's answer to this action arrived: Allow in the FullCourtDefense dialog
|
|
338
|
-
* (askDialog, MCP gateway — the default `via: 'dialog'`), or the post-execution hook
|
|
339
|
-
* after the IDE's own card (`via: 'ide'`). Removes the marker, records memory if the
|
|
340
|
-
* rule asked for it, and returns the settled marker plus how long the answer took (the
|
|
341
|
-
* rubber-stamp signal). Undefined when nothing was pending — the action was simply
|
|
342
|
-
* allowed and this post-hook is noise.
|
|
343
|
-
*
|
|
344
|
-
* `humanProven` (v139 F-13): an IDE card's answer is a person's only when the asking
|
|
345
|
-
* client said its card reaches one (`idePrompt: 'human'`). Otherwise the IDE may have
|
|
346
|
-
* approved its own card (auto-run / bypass modes, or a client that does not say): the
|
|
347
|
-
* marker is closed, nothing is remembered, and the caller records an unverified run.
|
|
348
|
-
*/
|
|
349
|
-
export declare function settleDeveloperConfirmation(event: string, toolName: string, argsDigest: string, now?: Date, opts?: {
|
|
350
|
-
/** Never store the rule's session / 1h memory for this answer (e.g. the machine is suspended). */
|
|
351
|
-
noMemory?: boolean;
|
|
352
|
-
/** Where the answer came from: our dialog (default) or the IDE's own card (inferred from the post hook). */
|
|
353
|
-
via?: 'dialog' | 'ide';
|
|
354
|
-
}): {
|
|
355
|
-
marker: PendingConfirmation;
|
|
356
|
-
confirmLatencyMs: number;
|
|
357
|
-
humanProven: boolean;
|
|
358
|
-
} | undefined;
|
|
359
334
|
/**
|
|
360
335
|
* Whether the IDE's own ask card reaches a person, from the permission mode the client
|
|
361
336
|
* reports with its hook call (Claude Code `permission_mode`; written by the client, never
|