fullcourtdefense-cli 1.32.0 → 1.33.0

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.
@@ -1352,6 +1352,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1352
1352
  // A `block` from a policy marked `enforcement: 'hard'` — kept as a block even
1353
1353
  // where a human is present (see the workstation ceiling below).
1354
1354
  let hardBlock;
1355
+ let requiredApproval;
1356
+ let requiredApprovalResult;
1357
+ const approvalObligations = [];
1355
1358
  for (const policy of policies) {
1356
1359
  // Org-scoped policies bind to attributed RUNTIME USERS — anonymous/system calls (no
1357
1360
  // developerName) are deliberately out of scope. Surfaced via skippedOrgPolicies so
@@ -1408,9 +1411,16 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1408
1411
  reason: `${matched.reason} — recorded as a warning: this only touches a file on this machine and nothing leaves it (the same content leaving the machine still blocks; mark the policy Hard block to stop file edits too)`,
1409
1412
  };
1410
1413
  }
1414
+ if (matched.verdict === 'require_approval') {
1415
+ approvalObligations.push(`${policy.id}:${matchedRule}`);
1416
+ if (!requiredApprovalResult || rule.approval?.scope !== 'developer')
1417
+ requiredApprovalResult = matched;
1418
+ const approval = resolveApprovalOptions(rule.approval);
1419
+ requiredApproval = requiredApproval ? mergeApprovalOptions(requiredApproval, approval) : approval;
1420
+ }
1411
1421
  if (rule.verdict === 'block' && policy.enforcement === 'hard' && !hardBlock)
1412
1422
  hardBlock = matched;
1413
- if (verdictPriority[rule.verdict] > verdictPriority[worstResult.verdict]) {
1423
+ if (verdictPriority[matched.verdict] > verdictPriority[worstResult.verdict]) {
1414
1424
  worstResult = matched;
1415
1425
  }
1416
1426
  else if (rule.verdict === 'require_approval' && worstResult.verdict === 'require_approval') {
@@ -1441,12 +1451,18 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1441
1451
  ...worstResult,
1442
1452
  allowed: false,
1443
1453
  verdict: 'require_approval',
1444
- approval: { ...exports.WORKSTATION_ASK_APPROVAL },
1454
+ approval: requiredApproval ? mergeApprovalOptions(exports.WORKSTATION_ASK_APPROVAL, requiredApproval) : { ...exports.WORKSTATION_ASK_APPROVAL },
1445
1455
  askedInsteadOfBlocked: true,
1446
1456
  reason: `${worstResult.reason || `Action policy "${worstResult.policyName}" blocked`} — asks you on a workstation (the policy is not marked Hard block)`,
1447
1457
  };
1448
1458
  }
1449
1459
  }
1460
+ if (worstResult.askedInsteadOfBlocked && requiredApproval?.scope === 'org' && requiredApprovalResult) {
1461
+ worstResult = { ...requiredApprovalResult, approval: mergeApprovalOptions(exports.WORKSTATION_ASK_APPROVAL, requiredApproval) };
1462
+ }
1463
+ if (worstResult.verdict === 'require_approval' && approvalObligations.length > 1) {
1464
+ worstResult = { ...worstResult, matchedRule: [...approvalObligations].sort().join('; ') };
1465
+ }
1450
1466
  if (monitorMatches.length > 0) {
1451
1467
  worstResult = { ...worstResult, monitorMatches };
1452
1468
  }
@@ -12,12 +12,12 @@ export declare function isAgentDrivenTerminal(env?: NodeJS.ProcessEnv): boolean;
12
12
  /** Shown (with the rule name) when an ask lands in an agent-driven terminal with no IDE answer behind it. */
13
13
  export declare const AGENT_TERMINAL_ASK_BLOCKED: string;
14
14
  /** Shown when the IDE prompt already answered for this exact command. */
15
- export declare const AGENT_TERMINAL_IDE_CONFIRMED = "allowed \u2014 the developer confirmed this exact command in the IDE prompt.";
15
+ export declare const AGENT_TERMINAL_IDE_CONFIRMED = "allowed through the IDE permission gate; confirmation is recorded by the IDE post-execution hook.";
16
16
  /** Pending-marker key the IDE hook wrote for this shell command (see devConfirm.pendingKey). */
17
17
  export declare function ideShellPendingKey(command: string): string;
18
18
  /** Remembered-confirmation key for a Local Safety ask rule on the shell tool (see devConfirm rememberKey). */
19
19
  export declare function ideLocalSafetyRememberKey(ruleId: string): string;
20
- /** How long a pending IDE marker counts as "the developer just allowed this" (mirrors CONFIRM_PENDING_TTL_MS). */
20
+ /** Maximum lifetime of an exact-command IDE permission handoff (not consent). */
21
21
  export declare const IDE_CONFIRMATION_WINDOW_MS: number;
22
22
  /**
23
23
  * Pure decision over a parsed dev-confirm ledger: did the IDE prompt already
@@ -25,6 +25,7 @@ export declare const IDE_CONFIRMATION_WINDOW_MS: number;
25
25
  * by the generated guard code below.
26
26
  */
27
27
  export declare function ideConfirmedFor(ledger: {
28
+ formatVersion?: number;
28
29
  pending?: Array<{
29
30
  key?: string;
30
31
  raisedAt?: string;
@@ -32,8 +33,10 @@ export declare function ideConfirmedFor(ledger: {
32
33
  remembered?: Array<{
33
34
  key?: string;
34
35
  expiresAt?: string;
36
+ remember?: string;
37
+ sessionId?: string;
35
38
  }>;
36
- } | undefined, command: string, ruleId: string, now?: number): boolean;
39
+ } | undefined, command: string, ruleId: string, now?: number, sessionId?: string): boolean;
37
40
  /**
38
41
  * Node snippet for the cmd.exe guard and the POSIX checker. Defines
39
42
  * `agentTerminal()` and `ideConfirmed(line, ruleId)`; expects `fs`, `crypto`,
@@ -48,8 +48,8 @@ exports.buildAgentTerminalPs = buildAgentTerminalPs;
48
48
  * Claude Code's Bash tool, ...) the agent is the one that would type the
49
49
  * answer — so an ask there is not a control at all. Rule: in an agent-driven
50
50
  * terminal the guard never offers y/N. It honors the answer the developer
51
- * already gave in the IDE prompt for this exact command (the hook raised it a
52
- * moment ago and the IDE only runs the command after "Allow"), and otherwise
51
+ * gave in the IDE, or delegates an explicitly marked exact-command handoff
52
+ * to the IDE permission gate. Handoffs are never recorded as consent. Otherwise
53
53
  * blocks with a message that says where the question is answered.
54
54
  *
55
55
  * Pure: no I/O. The guards render these markers + keys into their generated
@@ -82,7 +82,7 @@ exports.AGENT_TERMINAL_ASK_BLOCKED = 'an AI agent is driving this terminal, so n
82
82
  'The developer answers Allow / Deny in the IDE prompt (the agent must run the command through the IDE hook), ' +
83
83
  'or runs it in their own terminal.';
84
84
  /** Shown when the IDE prompt already answered for this exact command. */
85
- exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed — the developer confirmed this exact command in the IDE prompt.';
85
+ exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed through the IDE permission gate; confirmation is recorded by the IDE post-execution hook.';
86
86
  // ---------------------------------------------------------------------------
87
87
  // IDE-answer lookup keys. These MUST match devConfirm.ts: the hook raises a
88
88
  // pending marker keyed by sha256(`${event}|${tool}|${sha256('cmd:'+command)}`)
@@ -98,21 +98,20 @@ function ideShellPendingKey(command) {
98
98
  function ideLocalSafetyRememberKey(ruleId) {
99
99
  return sha256(`shell|local-safety:${ruleId}|${ruleId}`);
100
100
  }
101
- /** How long a pending IDE marker counts as "the developer just allowed this" (mirrors CONFIRM_PENDING_TTL_MS). */
101
+ /** Maximum lifetime of an exact-command IDE permission handoff (not consent). */
102
102
  exports.IDE_CONFIRMATION_WINDOW_MS = 10 * 60_000;
103
103
  /**
104
104
  * Pure decision over a parsed dev-confirm ledger: did the IDE prompt already
105
105
  * answer for this command / rule? Used by the in-process tests and mirrored
106
106
  * by the generated guard code below.
107
107
  */
108
- function ideConfirmedFor(ledger, command, ruleId, now = Date.now()) {
109
- if (!ledger)
108
+ function ideConfirmedFor(ledger, command, ruleId, now = Date.now(), sessionId) {
109
+ if (!ledger || ledger.formatVersion !== 2)
110
110
  return false;
111
- const pendingKey = ideShellPendingKey(command);
112
- if ((ledger.pending || []).some(p => p?.key === pendingKey && typeof p.raisedAt === 'string' && now - Date.parse(p.raisedAt) < exports.IDE_CONFIRMATION_WINDOW_MS))
113
- return true;
111
+ // A pending prompt is not an affirmative answer. IDE handoffs are handled
112
+ // separately by the generated guard, without claiming developer consent.
114
113
  const rememberKey = ideLocalSafetyRememberKey(ruleId);
115
- return (ledger.remembered || []).some(r => r?.key === rememberKey && typeof r.expiresAt === 'string' && Date.parse(r.expiresAt) > now);
114
+ return (ledger.remembered || []).some(r => r?.key === rememberKey && typeof r.expiresAt === 'string' && Date.parse(r.expiresAt) > now && (r.remember === '1h' || (r.remember === 'session' && !!sessionId && r.sessionId === sessionId)));
116
115
  }
117
116
  // ---------------------------------------------------------------------------
118
117
  // Rendered snippets for the generated guards (single source of truth).
@@ -130,7 +129,7 @@ function buildAgentTerminalJs() {
130
129
  `function agentTerminal(){const e=process.env;return AGENT_MARKERS.some(m=>m.v!==undefined?e[m.n]===m.v:(typeof e[m.n]==='string'&&e[m.n].length>0));}`,
131
130
  `const IDE_WINDOW_MS=${exports.IDE_CONFIRMATION_WINDOW_MS};`,
132
131
  `function sha(s){return crypto.createHash('sha256').update(s).digest('hex');}`,
133
- `function ideConfirmed(line,ruleId){try{const ledger=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.fullcourtdefense','dev-confirm.json'),'utf8'));const now=Date.now();const pk=sha('shell|shell|'+sha('cmd:'+line.trim()));if((ledger.pending||[]).some(p=>p&&p.key===pk&&typeof p.raisedAt==='string'&&now-Date.parse(p.raisedAt)<IDE_WINDOW_MS))return true;const rk=sha('shell|local-safety:'+ruleId+'|'+ruleId);return (ledger.remembered||[]).some(r=>r&&r.key===rk&&typeof r.expiresAt==='string'&&Date.parse(r.expiresAt)>now);}catch{return false;}}`,
132
+ `function ideConfirmed(line,ruleId){try{const ledger=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.fullcourtdefense','dev-confirm.json'),'utf8'));const now=Date.now();const pk=sha('shell|shell|'+sha('cmd:'+line.trim()));if((ledger.pending||[]).some(p=>p&&p.permissionHandoff===true&&p.key===pk&&typeof p.raisedAt==='string'&&now>=Date.parse(p.raisedAt)&&now-Date.parse(p.raisedAt)<IDE_WINDOW_MS))return true;if(ledger.formatVersion!==2)return false;const rk=sha('shell|local-safety:'+ruleId+'|'+ruleId);const session=process.env.FCD_SESSION_ID||process.env.CLAUDE_CODE_SESSION_ID||process.env.CODEX_THREAD_ID;return (ledger.remembered||[]).some(r=>r&&r.key===rk&&typeof r.expiresAt==='string'&&Date.parse(r.expiresAt)>now&&(r.remember==='1h'||(r.remember==='session'&&session&&r.sessionId===session)));}catch{return false;}}`,
134
133
  `const AGENT_ASK_BLOCKED=${JSON.stringify(exports.AGENT_TERMINAL_ASK_BLOCKED)};`,
135
134
  `const AGENT_IDE_CONFIRMED=${JSON.stringify(exports.AGENT_TERMINAL_IDE_CONFIRMED)};`,
136
135
  ].join('\n');
@@ -169,15 +168,19 @@ function buildAgentTerminalPs() {
169
168
  ` $now = [DateTimeOffset]::UtcNow`,
170
169
  ` $pk = Get-FcdSha256 ('shell|shell|' + (Get-FcdSha256 ('cmd:' + $Line.Trim())))`,
171
170
  ` foreach ($p in @($ledger.pending)) {`,
172
- ` if ($null -ne $p -and $p.key -eq $pk -and $p.raisedAt) {`,
173
- ` $age = $now - [DateTimeOffset]::Parse($p.raisedAt, [Globalization.CultureInfo]::InvariantCulture)`,
174
- ` if ($age.TotalMilliseconds -lt ${exports.IDE_CONFIRMATION_WINDOW_MS}) { return $true }`,
171
+ ` if ($null -ne $p -and $p.permissionHandoff -eq $true -and $p.key -eq $pk -and $p.raisedAt) {`,
172
+ ` $raised = if ($p.raisedAt -is [DateTime]) { [DateTimeOffset]$p.raisedAt } else { [DateTimeOffset]::Parse([string]$p.raisedAt, [Globalization.CultureInfo]::InvariantCulture) }`,
173
+ ` $age = $now - $raised`,
174
+ ` if ($age.TotalMilliseconds -ge 0 -and $age.TotalMilliseconds -lt ${exports.IDE_CONFIRMATION_WINDOW_MS}) { return $true }`,
175
175
  ` }`,
176
176
  ` }`,
177
+ ` if ($ledger.formatVersion -ne 2) { return $false }`,
177
178
  ` $rk = Get-FcdSha256 ('shell|local-safety:' + $RuleId + '|' + $RuleId)`,
179
+ ` $session = $env:FCD_SESSION_ID; if (-not $session) { $session = $env:CLAUDE_CODE_SESSION_ID }; if (-not $session) { $session = $env:CODEX_THREAD_ID }`,
178
180
  ` foreach ($r in @($ledger.remembered)) {`,
179
- ` if ($null -ne $r -and $r.key -eq $rk -and $r.expiresAt) {`,
180
- ` if ([DateTimeOffset]::Parse($r.expiresAt, [Globalization.CultureInfo]::InvariantCulture) -gt $now) { return $true }`,
181
+ ` if ($null -ne $r -and $r.key -eq $rk -and $r.expiresAt -and ($r.remember -eq '1h' -or ($r.remember -eq 'session' -and $session -and $r.sessionId -eq $session))) {`,
182
+ ` $expiry = if ($r.expiresAt -is [DateTime]) { [DateTimeOffset]$r.expiresAt } else { [DateTimeOffset]::Parse([string]$r.expiresAt, [Globalization.CultureInfo]::InvariantCulture) }`,
183
+ ` if ($expiry -gt $now) { return $true }`,
181
184
  ` }`,
182
185
  ` }`,
183
186
  ` } catch { }`,
@@ -160,12 +160,12 @@ function buildGuardJs(nodePath, cliEntry) {
160
160
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
161
161
  `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
162
162
  `// Precedence on one line: block outranks ask, ask outranks warn.`,
163
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null,askHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){if(!askHit)askHit=r;}else if(!warnHit)warnHit=r;}}catch{}}return askHit||warnHit;}`,
163
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;const asks=[];for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){asks.push(r);}else if(!warnHit)warnHit=r;}}catch{}}if(asks.length===1)return asks[0];if(asks.length>1){asks.sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0);return{...asks[0],id:'asks:'+asks.map(r=>encodeURIComponent(r.id)).join('+'),reason:asks.map(r=>r.reason).join('; ')}}return warnHit;}`,
164
164
  `// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
165
165
  `// made the user's terminal wait for a full CLI process INCLUDING the network`,
166
166
  `// flush after every finding. Monitor mode must never add synchronous latency.`,
167
167
  `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' | 'ide' | 'agent-terminal' — the "ask" outcome, reported with the same facts as an IDE prompt.`,
168
- `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this cmd window (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;else if(developer==='ide')reason='developer confirmed in the IDE prompt (agent-driven terminal) — '+reason;else if(developer==='agent-terminal')reason='ask cannot be answered in an agent-driven terminal (no IDE confirmation) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered'||developer==='ide')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
168
+ `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this cmd window (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;else if(developer==='ide')reason='delegated to the IDE permission gate (confirmation recorded by post-hook) — '+reason;else if(developer==='agent-terminal')reason='ask cannot be answered in an agent-driven terminal (no IDE confirmation) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
169
169
  `function askRemembered(ruleId){try{const m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'));const until=m&&m[ruleId];return typeof until==='number'&&until>Date.now();}catch{return false;}}`,
170
170
  `function rememberAsk(ruleId){try{let m={};try{m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'))||{};}catch{}m[ruleId]=Date.now()+ASK_REMEMBER_MS;fs.mkdirSync(path.dirname(ASK_MEMORY_PATH),{recursive:true});fs.writeFileSync(ASK_MEMORY_PATH,JSON.stringify(m),{encoding:'utf8',mode:0o600});}catch{}}`,
171
171
  (0, agentTerminal_1.buildAgentTerminalJs)(),
@@ -134,6 +134,8 @@ export interface DeterministicScanOutcome {
134
134
  * events, shell wrappers, no human present) treats it exactly as a block.
135
135
  */
136
136
  askFinding?: DeterministicFinding;
137
+ /** Every matching approval obligation, retained for diagnostics and tests. */
138
+ askFindings?: DeterministicFinding[];
137
139
  /** warn-action findings that matched — the action proceeds; report these. */
138
140
  warnings: DeterministicFinding[];
139
141
  }
@@ -1259,17 +1259,30 @@ function findingAction(finding) {
1259
1259
  */
1260
1260
  function resolveDeterministicOutcome(scan, options) {
1261
1261
  const warnings = [];
1262
- let askFinding;
1262
+ const askFindings = [];
1263
+ const result = () => {
1264
+ if (!askFindings.length)
1265
+ return { warnings };
1266
+ const ordered = [...askFindings].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
1267
+ // Bind memory to every matching rule. One dialog describes the full set.
1268
+ const askFinding = ordered.length === 1 ? ordered[0] : {
1269
+ ...ordered[0],
1270
+ itemId: ordered.map(f => f.itemId).join('+'),
1271
+ ruleId: ordered.map(f => f.ruleId).join('+'),
1272
+ reason: ordered.map(f => `${f.itemId}: ${f.reason}`).join('; '),
1273
+ };
1274
+ return { askFinding, askFindings, warnings };
1275
+ };
1263
1276
  let current = options;
1264
1277
  for (let pass = 0; pass < MAX_ACTION_PASSES; pass++) {
1265
1278
  const finding = scan(current);
1266
1279
  if (!finding)
1267
- return askFinding ? { askFinding, warnings } : { warnings };
1280
+ return result();
1268
1281
  const action = findingAction(finding);
1269
1282
  if (action === 'warn')
1270
1283
  warnings.push(finding);
1271
1284
  else if (action === 'ask')
1272
- askFinding = askFinding || finding;
1285
+ askFindings.push(finding);
1273
1286
  else
1274
1287
  return { blockingFinding: finding, warnings };
1275
1288
  current = withRuleSuppressed(current, finding);
@@ -1278,7 +1291,7 @@ function resolveDeterministicOutcome(scan, options) {
1278
1291
  const last = scan(current);
1279
1292
  if (last)
1280
1293
  return { blockingFinding: last, warnings };
1281
- return askFinding ? { askFinding, warnings } : { warnings };
1294
+ return result();
1282
1295
  }
1283
1296
  /**
1284
1297
  * Resolve a TOOL RESPONSE under per-rule actions. The response is the one
@@ -681,7 +681,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
681
681
  offlineEnforced: true,
682
682
  });
683
683
  }
684
- const remembered = (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, policyId: source.policyId, matchedRule: source.matchedRule }, sessId);
684
+ const remembered = source.remember === 'none' ? undefined : (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, policyId: source.policyId, matchedRule: source.matchedRule }, sessId);
685
685
  if (remembered) {
686
686
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_remembered', event, tool: call.toolName, operation, remember: remembered.remember, where: source.where });
687
687
  spoolCallEvent(call, {
@@ -704,6 +704,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
704
704
  matchedRule: source.matchedRule,
705
705
  sessionId: sessId,
706
706
  remember: source.remember,
707
+ permissionHandoff: event === 'shell',
707
708
  });
708
709
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_ask', event, tool: call.toolName, operation, key: marker.key, remember: source.remember, where: source.where });
709
710
  spoolCallEvent(call, {
@@ -1090,7 +1091,7 @@ async function evaluateHookRequest(args, stdinRaw, config) {
1090
1091
  // Same contract as the process-level wrapper, including the kill-switch
1091
1092
  // exception: a crash during daemon-side evaluation must not unlock a
1092
1093
  // suspended machine.
1093
- const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`);
1094
+ const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config);
1094
1095
  return { stdout: outcome.stdout, stderr: '', exitCode: outcome.exitCode };
1095
1096
  }
1096
1097
  }
@@ -1152,7 +1153,7 @@ async function hookCommandOuter(args, config, startedAt) {
1152
1153
  }
1153
1154
  catch (err) {
1154
1155
  const message = err instanceof Error ? err.message : String(err);
1155
- const outcome = failOpenOutcome(args.event, message);
1156
+ const outcome = failOpenOutcome(args.event, message, args, config);
1156
1157
  try {
1157
1158
  process.stdout.write(outcome.stdout);
1158
1159
  }
@@ -1167,9 +1168,10 @@ async function hookCommandOuter(args, config, startedAt) {
1167
1168
  * not become a way to bypass an administrator's suspension — a crash (induced
1168
1169
  * or accidental) on a suspended machine would otherwise unlock every agent
1169
1170
  * action. So the catch handlers consult the local cache directly: suspended
1170
- * machines still deny, everything else still fails open silently.
1171
+ * machines still deny. An enforcing Shield with fail-closed also denies
1172
+ * evaluation failures; Monitor and fail-open configurations continue.
1171
1173
  */
1172
- function failOpenOutcome(event, message) {
1174
+ function failOpenOutcome(event, message, args, config) {
1173
1175
  const toolName = event || 'unknown';
1174
1176
  // Label the spooled event below as a crash-allow: a rising fail_open rate is
1175
1177
  // the fleet's earliest signal that the guard itself is broken on a machine.
@@ -1182,18 +1184,20 @@ function failOpenOutcome(event, message) {
1182
1184
  suspended = (0, runtimeConfig_1.isSuspendedByCache)();
1183
1185
  }
1184
1186
  catch { /* never throw */ }
1187
+ const state = (0, runtimeConfig_1.cachedEnforcementState)(args?.shieldId || config?.shieldId || process.env.FCD_SHIELD_ID || process.env.FULLCOURTDEFENSE_SHIELD_ID);
1188
+ const denyForError = state.mode === 'block' && (args?.failClosed !== undefined ? args.failClosed === 'true' : state.failClosed) && !(args?.shadow === 'true' && args?.fcdManaged !== 'true');
1185
1189
  try {
1186
1190
  (0, hookIo_1.dbg)({ phase: 'hook_fail_open', event, error: message, suspended });
1187
1191
  }
1188
1192
  catch { /* never throw */ }
1189
1193
  try {
1190
1194
  (0, telemetry_1.spoolEvent)({
1191
- decision: suspended ? 'block' : 'allow',
1195
+ decision: suspended || denyForError ? 'block' : 'allow',
1192
1196
  toolName,
1193
1197
  operation: `${toolName}:hook_error`,
1194
1198
  reason: suspended
1195
1199
  ? `Hook error on a SUSPENDED machine — denied (kill-switch holds): ${message.slice(0, 200)}`
1196
- : `Hook error failed open: ${message.slice(0, 200)}`,
1200
+ : `${denyForError ? 'fail-closed: hook evaluation failed' : 'Hook error failed open'}: ${message.slice(0, 200)}`,
1197
1201
  offlineEnforced: true,
1198
1202
  ...(suspended ? { severity: 'critical', source: 'backend' } : {}),
1199
1203
  });
@@ -1211,6 +1215,12 @@ function failOpenOutcome(event, message) {
1211
1215
  exitCode: 0,
1212
1216
  };
1213
1217
  }
1218
+ if (denyForError) {
1219
+ const reason = 'FullCourtDefense could not verify this action. Your administrator enabled fail-closed. Retry after policy checking recovers.';
1220
+ return { stdout: JSON.stringify({ continue: false, permission: 'deny', user_message: reason,
1221
+ permissionDecision: 'deny', permissionDecisionReason: reason,
1222
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }) + '\n', exitCode: 0 };
1223
+ }
1214
1224
  // `continue` is the Cursor prompt shape, `permission` the Cursor action
1215
1225
  // shape; Claude-format clients ignore unknown keys and treat the absence
1216
1226
  // of a decision as allow.
@@ -1272,8 +1282,8 @@ async function hookCommandInner(args, config, io) {
1272
1282
  // machine applies it on the next poll. Cached locally (stale-while-error), so
1273
1283
  // this is instant when fresh and bounded (~1.5s) only when stale.
1274
1284
  let machineSuspended = false;
1275
- let modeResolved = false; // add this line
1276
- if (!forceMonitor && !forceEnforce && shieldId) {
1285
+ let modeResolved = false;
1286
+ if (shieldId) {
1277
1287
  try {
1278
1288
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
1279
1289
  // Kill-switch holds even on a 'default' bundle: with the cache missing
@@ -1285,8 +1295,8 @@ async function hookCommandInner(args, config, io) {
1285
1295
  shadow = false;
1286
1296
  }
1287
1297
  if (bundle.source !== 'default') {
1288
- shadow = bundle.mode !== 'block'; // 'monitor'/'shadow' => report-only
1289
- modeResolved = true; // add this line
1298
+ shadow = forceMonitor || bundle.mode !== 'block'; // A known Shield Monitor mode cannot be overridden locally.
1299
+ modeResolved = true;
1290
1300
  effectivePolicyHash = bundle.policyHash || bundle.version;
1291
1301
  // Cached org policies: hash-covered by the bundle ETag, evaluated
1292
1302
  // locally when the policy gate is unreachable (offline enforcement).
@@ -1308,7 +1318,7 @@ async function hookCommandInner(args, config, io) {
1308
1318
  shadow = true;
1309
1319
  (0, hookIo_1.dbg)({ phase: 'mode_default_monitor_first', shadow });
1310
1320
  }
1311
- if (forceEnforce)
1321
+ if (forceEnforce && !modeResolved)
1312
1322
  shadow = false;
1313
1323
  // Keep device liveness fresh during active use (throttled, detached — never blocks).
1314
1324
  if (shieldId)
@@ -1422,6 +1432,10 @@ async function hookCommandInner(args, config, io) {
1422
1432
  }
1423
1433
  else if (denied || ask) {
1424
1434
  decision = {
1435
+ // Copilot CLI reads the top-level decision; Claude Code and VS Code
1436
+ // read hookSpecificOutput. Emit the same verdict in both contracts.
1437
+ permissionDecision: ask ? 'ask' : 'deny',
1438
+ permissionDecisionReason: userMsg || agentMsg || 'FullCourtDefense policy decision.',
1425
1439
  hookSpecificOutput: {
1426
1440
  hookEventName: 'PreToolUse',
1427
1441
  permissionDecision: ask ? 'ask' : 'deny',
@@ -53,8 +53,8 @@ async function installAllCommand(args, config) {
53
53
  catch (error) {
54
54
  console.log(`Cursor hooks skipped: ${error instanceof Error ? error.message : String(error)}`);
55
55
  }
56
- // ONE Claude-format hook file (~/.claude/settings.json) covers Claude Code,
57
- // VS Code Copilot agent mode, and GitHub Copilot CLI.
56
+ // Claude/VS Code share Claude settings; Copilot CLI receives separate
57
+ // user tool hooks from the same installer.
58
58
  console.log('\n\x1b[1mInstalling Claude Code / VS Code / Copilot CLI runtime hooks…\x1b[0m');
59
59
  const claudeHookArgs = {
60
60
  shieldId: creds.shieldId,
@@ -3,14 +3,14 @@ import { BotGuardConfig } from '../config';
3
3
  * `fullcourtdefense install-claude-hook` — Claude-format hook installer.
4
4
  *
5
5
  * Writes PreToolUse + UserPromptSubmit hooks into ~/.claude/settings.json.
6
- * That ONE file is read by THREE clients:
6
+ * That file is read by:
7
7
  * - Claude Code (CLI + desktop)
8
8
  * - VS Code Copilot agent mode (user-scope Claude-format hooks)
9
- * - GitHub Copilot CLI
9
+ * Copilot CLI additionally receives user hooks in ~/.copilot/settings.json.
10
10
  *
11
11
  * The hook command is the same `fullcourtdefense hook` bridge used for Cursor —
12
12
  * it detects the Claude payload format from stdin (`hook_event_name`), so no
13
- * per-client configuration is needed. VS Code ignores `matcher` values (hooks
13
+ * payload adapter is needed for those PascalCase hooks. VS Code ignores `matcher` values (hooks
14
14
  * run on every tool), so the bridge self-filters by tool name and returns an
15
15
  * empty verdict for tools we don't police.
16
16
  */
@@ -189,7 +189,43 @@ function repairClaudeManagedHooks() {
189
189
  fs.mkdirSync(path.dirname(before.file), { recursive: true });
190
190
  fs.writeFileSync(before.file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
191
191
  }
192
- return { ...before, changed };
192
+ const copilotChanged = syncCopilotToolHooks(buildHookCommand({ shadow: false, failClosed: false }));
193
+ return { ...before, changed: changed || copilotChanged };
194
+ }
195
+ /** Copilot CLI user settings are separate from Claude's global settings.
196
+ * Use inline settings, not ~/.copilot/hooks, which VS Code also loads and
197
+ * would therefore execute twice alongside its Claude hook.
198
+ */
199
+ function syncCopilotToolHooks(command) {
200
+ const root = process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot');
201
+ const file = path.join(root, 'settings.json');
202
+ if (!command && !fs.existsSync(file))
203
+ return false;
204
+ // Preserve user settings: malformed existing JSON must never be overwritten.
205
+ const settings = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
206
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings))
207
+ throw new Error(`Invalid Copilot settings: ${file}`);
208
+ const before = JSON.stringify(settings);
209
+ const hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
210
+ for (const event of ['PreToolUse', 'PostToolUse']) {
211
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
212
+ const kept = entries.filter(entry => !isManagedCommand(entry));
213
+ if (command)
214
+ kept.push({ type: 'command', command, timeoutSec: event === 'PreToolUse' ? TOOL_HOOK_TIMEOUT_SEC : POST_TOOL_HOOK_TIMEOUT_SEC });
215
+ if (kept.length)
216
+ hooks[event] = kept;
217
+ else
218
+ delete hooks[event];
219
+ }
220
+ if (Object.keys(hooks).length)
221
+ settings.hooks = hooks;
222
+ else
223
+ delete settings.hooks;
224
+ if (before === JSON.stringify(settings))
225
+ return false;
226
+ fs.mkdirSync(path.dirname(file), { recursive: true });
227
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
228
+ return true;
193
229
  }
194
230
  // PostToolUse companion of PreToolUse: enforces nothing; it is how a developer's
195
231
  // Allow on the client's own `ask` prompt (developer-scoped approval) is learned.
@@ -290,11 +326,13 @@ async function installClaudeHookCommand(args, config) {
290
326
  }
291
327
  fs.mkdirSync(path.dirname(file), { recursive: true });
292
328
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
329
+ if (!projectScope)
330
+ syncCopilotToolHooks(wantTools ? command : undefined);
293
331
  console.log('');
294
332
  console.log(`${COLOR.green}${COLOR.bold}FullCourtDefense Claude-format hook installed.${COLOR.reset}`);
295
333
  console.log(`${COLOR.gray}Scope:${COLOR.reset} ${projectScope ? 'project (.claude/settings.json)' : 'machine-wide (~/.claude/settings.json)'}`);
296
334
  console.log(`${COLOR.gray}File:${COLOR.reset} ${file}`);
297
- console.log(`${COLOR.gray}Covers:${COLOR.reset} Claude Code, VS Code Copilot agent mode, GitHub Copilot CLI (all read this file)`);
335
+ console.log(`${COLOR.gray}Covers:${COLOR.reset} Claude Code and VS Code Copilot; Copilot CLI tool hooks use ${projectScope ? 'this project configuration' : 'separate user settings in .copilot/settings.json'}.`);
298
336
  console.log(`${COLOR.gray}Note:${COLOR.reset} Claude Desktop's regular chat has no hook — it is protected separately by the Windows chat guard the daemon runs (advisory).`);
299
337
  const parts = [];
300
338
  if (wantTools)
@@ -318,6 +356,8 @@ async function installClaudeHookCommand(args, config) {
318
356
  }
319
357
  async function uninstallClaudeHookCommand(args) {
320
358
  const projectScope = args.project === 'true';
359
+ if (!projectScope)
360
+ syncCopilotToolHooks();
321
361
  const file = settingsPath(projectScope);
322
362
  if (!fs.existsSync(file)) {
323
363
  console.log(`${COLOR.yellow}No settings.json found at ${file}. Nothing to remove.${COLOR.reset}`);
@@ -1109,6 +1109,8 @@ class McpGatewayServer {
1109
1109
  let downstreamResult;
1110
1110
  let downstreamAttempted = false;
1111
1111
  let downstreamRan = false;
1112
+ let reportOnlyMode = true;
1113
+ let policyWarning;
1112
1114
  try {
1113
1115
  // The runtime bundle carries the org's current Local Safety policy hash —
1114
1116
  // passing it as `expectedPolicyHash` makes a console policy edit take
@@ -1123,7 +1125,7 @@ class McpGatewayServer {
1123
1125
  let cachedPolicies;
1124
1126
  // Monitor unless a server/cache bundle explicitly says 'block': a machine
1125
1127
  // with no signal (fresh install + offline, wiped cache) must never block.
1126
- let reportOnlyMode = true;
1128
+ reportOnlyMode = true;
1127
1129
  let machineRole;
1128
1130
  try {
1129
1131
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
@@ -1183,6 +1185,10 @@ class McpGatewayServer {
1183
1185
  expectedPolicyHash,
1184
1186
  preferCached: reportOnlyMode,
1185
1187
  });
1188
+ if (reportOnlyMode && snapshot) {
1189
+ snapshot.itemActions = Object.fromEntries(Object.entries(snapshot.itemActions || {}).map(([id, action]) => [id, action === 'mask' ? 'warn' : action]));
1190
+ snapshot.customBlocks = snapshot.customBlocks.map(rule => rule.action === 'mask' ? { ...rule, action: 'warn' } : rule);
1191
+ }
1186
1192
  const requestOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1187
1193
  for (const warning of requestOutcome.warnings) {
1188
1194
  this.spoolLocalFinding(warning, toolName, operation, 'warn');
@@ -1238,7 +1244,7 @@ class McpGatewayServer {
1238
1244
  localBlock = undefined;
1239
1245
  }
1240
1246
  else {
1241
- (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1247
+ (0, devConfirm_1.rejectDeveloperConfirmation)('mcp_gateway', toolName, digest);
1242
1248
  const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1243
1249
  (0, telemetry_1.spoolEvent)({
1244
1250
  decision: 'block', toolName, operation: rememberScope.operation,
@@ -1311,8 +1317,8 @@ class McpGatewayServer {
1311
1317
  // MONITOR CONTRACT (latency half): a monitor/no-signal machine makes
1312
1318
  // ZERO synchronous network calls. The verdict is always allow; the
1313
1319
  // "would block" signal comes from the locally cached policy bundle,
1314
- // and the console report is fired without awaiting (server records the
1315
- // action and its own verdict; failures spool for the next flush).
1320
+ // and the console report uses the audit spool. checkToolCall is an
1321
+ // enforcing endpoint: even an unawaited request can open approvals.
1316
1322
  const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs);
1317
1323
  const localOperation = operation || inferred.operation;
1318
1324
  if (cachedPolicies && cachedPolicies.length > 0) {
@@ -1333,22 +1339,8 @@ class McpGatewayServer {
1333
1339
  process.stderr.write(`FullCourtDefense (monitor) would ${wouldWhat} ${toolName}: ${local.reason || local.verdict}\n`);
1334
1340
  }
1335
1341
  }
1336
- // PROXY RESILIENCE: while the gate is in its recently-down window
1337
- // (bank proxy, backend outage) don't even fire the async report —
1338
- // spool straight to disk; the flush child delivers it when the
1339
- // network returns. Zero network churn per call during an outage.
1340
- if ((0, policyGateHealth_1.gateRecentlyDown)()) {
1341
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: 'monitor: action recorded locally (gate recently unreachable)', offlineEnforced: true });
1342
- (0, telemetry_1.triggerFlush)(false);
1343
- }
1344
- else {
1345
- void this.api.checkToolCall({ toolName, operation, toolArgs }, 1_500)
1346
- .then(() => (0, policyGateHealth_1.recordGateSuccess)())
1347
- .catch(() => {
1348
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: 'monitor: action recorded locally (console unreachable)', offlineEnforced: true });
1349
- (0, telemetry_1.triggerFlush)(false);
1350
- });
1351
- }
1342
+ (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: 'monitor: action allowed and recorded without requesting approval' });
1343
+ (0, telemetry_1.triggerFlush)(false);
1352
1344
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
1353
1345
  }
1354
1346
  else if (cachedPolicies && cachedPolicies.length > 0) {
@@ -1388,6 +1380,8 @@ class McpGatewayServer {
1388
1380
  // "Warn only": the call runs, the fleet records Warned (not a silent
1389
1381
  // allow). Final verdict — no gate round trip; the audit rides the spool.
1390
1382
  const reason = local.reason || `${toolName}: flagged by org Action Policy`;
1383
+ policyWarning = reason;
1384
+ process.stderr.write(`FullCourtDefense warning: ${reason}\n`);
1391
1385
  (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation: localOperation, reason: `policy warn (local-first): ${reason}`, ruleId: local.policyName ? `action-policy:${local.policyName}` : undefined, offlineEnforced: true });
1392
1386
  (0, telemetry_1.triggerFlush)(false);
1393
1387
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
@@ -1470,9 +1464,9 @@ class McpGatewayServer {
1470
1464
  process.stderr.write(`FullCourtDefense offline: policy service unreachable (${detail}); ${toolName} checked against locally cached org policies → ${blocking ? `${local.verdict} (report-only)` : 'allow'}.\n`);
1471
1465
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
1472
1466
  }
1473
- else if (this.gatewayConfig.failClosed && (!reportOnlyMode || this.gatewayConfig.failClosedExplicit)) {
1467
+ else if (this.gatewayConfig.failClosed && !reportOnlyMode) {
1474
1468
  // Fail-closed only bites when the machine is actually in block mode
1475
- // (or the operator passed --fail-closed explicitly at install time).
1469
+ // An explicit --fail-closed flag never overrides Shield Monitor.
1476
1470
  // A monitor machine must never hard-block — not even during an
1477
1471
  // outage with a bundle-supplied failClosed=true left over.
1478
1472
  (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason: `fail-closed: ${detail}`, offlineEnforced: true });
@@ -1575,13 +1569,13 @@ class McpGatewayServer {
1575
1569
  }
1576
1570
  }
1577
1571
  if (responseOutcome.maskFindings.length > 0) {
1578
- // mask-action rules: redact the matched secrets in place. Masking is
1579
- // monitor-compatible (a redaction is not a block), so it applies in
1580
- // every mode. If the result shape cannot be rewritten (secret hidden
1572
+ // Enforcing mask-action rules redact matched secrets in place.
1573
+ // Monitor snapshots downgrade mask to warn before evaluation.
1574
+ // If the result shape cannot be rewritten (secret hidden
1581
1575
  // in a non-text payload), fail strict and block — a mask rule must
1582
1576
  // never leak what it was set to hide — except under monitor, where
1583
1577
  // the contract is observe-only: record the would-block and pass the
1584
- // response through with whatever partial masking was possible.
1578
+ // original response through.
1585
1579
  const { masked, fullyMasked } = maskToolResult(rawResult, responseOutcome.maskFindings);
1586
1580
  if (!fullyMasked) {
1587
1581
  const finding = responseOutcome.maskFindings[0];
@@ -1642,10 +1636,11 @@ class McpGatewayServer {
1642
1636
  let finalResult = rawResult;
1643
1637
  if (effectiveScanResponse) {
1644
1638
  try {
1645
- finalResult = await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult });
1639
+ const scannedResult = await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult });
1640
+ finalResult = reportOnlyMode ? rawResult : scannedResult;
1646
1641
  }
1647
1642
  catch (scanErr) {
1648
- if (isBlockError(scanErr))
1643
+ if (isBlockError(scanErr) && !reportOnlyMode)
1649
1644
  throw scanErr; // Shield verdict — a real block
1650
1645
  // Scan API/infra failure. On an ENFORCING machine, returning the raw
1651
1646
  // response would silently skip the org's exfil scan — that is a
@@ -1656,7 +1651,7 @@ class McpGatewayServer {
1656
1651
  (0, telemetry_1.triggerFlush)(true);
1657
1652
  throw blockError(`FullCourtDefense could not scan the ${toolName} response (${detail}) and this machine enforces response scanning — response withheld. Retry once the scan service is reachable.`);
1658
1653
  }
1659
- (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] response scan unavailable: ${detail.slice(0, 200)}`, offlineEnforced: true });
1654
+ (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] ${isBlockError(scanErr) ? 'response would be blocked' : 'response scan unavailable'}: ${detail.slice(0, 200)}`, offlineEnforced: true });
1660
1655
  (0, telemetry_1.triggerFlush)(false);
1661
1656
  process.stderr.write(`FullCourtDefense (monitor): response scan unavailable for ${toolName} (${detail}) — response passed through.\n`);
1662
1657
  finalResult = rawResult;
@@ -1678,6 +1673,10 @@ class McpGatewayServer {
1678
1673
  (0, telemetry_1.triggerFlush)(false);
1679
1674
  });
1680
1675
  }
1676
+ if (policyWarning && finalResult && typeof finalResult === 'object') {
1677
+ const result = finalResult;
1678
+ finalResult = { ...result, content: [...(Array.isArray(result.content) ? result.content : []), { type: 'text', text: `FullCourtDefense warning: ${policyWarning}` }] };
1679
+ }
1681
1680
  return finalResult;
1682
1681
  }
1683
1682
  catch (error) {
@@ -1713,6 +1712,14 @@ class McpGatewayServer {
1713
1712
  content: [{ type: 'text', text: 'This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.' }],
1714
1713
  };
1715
1714
  }
1715
+ if (!isBlockError(error) && !downstreamAttempted && !reportOnlyMode && this.gatewayConfig.failClosed) {
1716
+ try {
1717
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason: `fail-closed: gateway evaluation failed: ${message.slice(0, 200)}`, offlineEnforced: true });
1718
+ (0, telemetry_1.triggerFlush)(true);
1719
+ }
1720
+ catch { /* preserve the configured decision even when telemetry fails */ }
1721
+ return { isError: true, blocked: true, content: [{ type: 'text', text: 'FullCourtDefense could not verify this action. Your administrator enabled fail-closed. Retry after policy checking recovers.' }] };
1722
+ }
1716
1723
  if (!isBlockError(error) && downstreamRan) {
1717
1724
  // Post-call scan bug: the tool already succeeded — return its result.
1718
1725
  try {
@@ -1807,7 +1814,7 @@ class McpGatewayServer {
1807
1814
  const sessionId = runtimeSessionId() || this.gatewaySessionId;
1808
1815
  const policyLabel = verdict.policyName ? ` (${verdict.policyName})` : '';
1809
1816
  const ruleId = verdict.policyName ? `action-policy:${verdict.policyName}` : undefined;
1810
- const remembered = (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
1817
+ const remembered = verdict.approval?.remember === 'none' ? undefined : (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
1811
1818
  if (remembered) {
1812
1819
  (0, telemetry_1.spoolEvent)({
1813
1820
  decision: 'allow', toolName, operation,
@@ -1837,11 +1844,11 @@ class McpGatewayServer {
1837
1844
  const answer = await this.elicitDeveloperConfirmation({
1838
1845
  toolName,
1839
1846
  reason: `${verdict.reason || `${toolName} needs your confirmation`}.${ceiling}`,
1847
+ allowRemember: false,
1840
1848
  ruleLabel: verdict.policyName ? `Action Policy "${verdict.policyName}"${verdict.matchedRule ? ` — ${verdict.matchedRule}` : ''}` : 'Action Policy',
1841
1849
  });
1842
1850
  if (answer.answer === 'accept') {
1843
- if (answer.remember && remember === 'none')
1844
- (0, devConfirm_1.raiseDeveloperConfirmation)({ ...marker, remember: 'session' });
1851
+ // Policy memory limits are authoritative; a client cannot upgrade 'none'.
1845
1852
  (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1846
1853
  (0, telemetry_1.spoolEvent)({
1847
1854
  decision: 'allow', toolName, operation,
@@ -1851,7 +1858,7 @@ class McpGatewayServer {
1851
1858
  (0, telemetry_1.triggerFlush)(false);
1852
1859
  return true;
1853
1860
  }
1854
- (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1861
+ (0, devConfirm_1.rejectDeveloperConfirmation)('mcp_gateway', toolName, digest);
1855
1862
  const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1856
1863
  (0, telemetry_1.spoolEvent)({
1857
1864
  decision: 'block', toolName, operation,
@@ -1875,12 +1882,12 @@ class McpGatewayServer {
1875
1882
  description: 'Yes runs the tool call once. No (or closing this dialog) does not run it.',
1876
1883
  default: false,
1877
1884
  },
1878
- remember: {
1879
- type: 'boolean',
1880
- title: 'Remember for this session',
1881
- description: 'Do not ask again for this tool and rule until this MCP session ends (max 12 hours).',
1882
- default: false,
1883
- },
1885
+ ...(input.allowRemember === false ? {} : { remember: {
1886
+ type: 'boolean',
1887
+ title: 'Remember for this session',
1888
+ description: 'Do not ask again for this tool and rule until this MCP session ends (max 12 hours).',
1889
+ default: false,
1890
+ } }),
1884
1891
  },
1885
1892
  required: ['confirm'],
1886
1893
  },
@@ -1891,7 +1898,7 @@ class McpGatewayServer {
1891
1898
  const result = (response.result && typeof response.result === 'object') ? response.result : undefined;
1892
1899
  const content = (result?.content && typeof result.content === 'object') ? result.content : undefined;
1893
1900
  if (result?.action === 'accept' && content?.confirm === true) {
1894
- return { answer: 'accept', remember: content.remember === true, latencyMs };
1901
+ return { answer: 'accept', remember: input.allowRemember !== false && content.remember === true, latencyMs };
1895
1902
  }
1896
1903
  return { answer: 'decline', remember: false, latencyMs };
1897
1904
  }
@@ -173,10 +173,10 @@ function buildGuardJs(nodePath, cliEntry) {
173
173
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
174
174
  `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
175
175
  `// Precedence on one line: block outranks ask, ask outranks warn.`,
176
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null,askHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){if(!askHit)askHit=r;}else if(!warnHit)warnHit=r;}}catch{}}return askHit||warnHit;}`,
176
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;const asks=[];for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){asks.push(r);}else if(!warnHit)warnHit=r;}}catch{}}if(asks.length===1)return asks[0];if(asks.length>1){asks.sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0);return{...asks[0],id:'asks:'+asks.map(r=>encodeURIComponent(r.id)).join('+'),reason:asks.map(r=>r.reason).join('; ')}}return warnHit;}`,
177
177
  `function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
178
178
  `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' | 'ide' | 'agent-terminal' — the "ask" outcome, reported with the same facts as an IDE prompt.`,
179
- `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this shell (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;else if(developer==='ide')reason='developer confirmed in the IDE prompt (agent-driven terminal) — '+reason;else if(developer==='agent-terminal')reason='ask cannot be answered in an agent-driven terminal (no IDE confirmation) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered'||developer==='ide')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
179
+ `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this shell (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;else if(developer==='ide')reason='delegated to the IDE permission gate (confirmation recorded by post-hook) — '+reason;else if(developer==='agent-terminal')reason='ask cannot be answered in an agent-driven terminal (no IDE confirmation) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
180
180
  (0, agentTerminal_1.buildAgentTerminalJs)(),
181
181
  `let argv=process.argv.slice(2);`,
182
182
  `// --record <confirmed|remembered|declined|timeout> <ruleId> -- <line>: the shell answered an ask prompt; report it and exit.`,
@@ -54,6 +54,9 @@ exports.installShellGuardCommand = installShellGuardCommand;
54
54
  exports.uninstallShellGuardCommand = uninstallShellGuardCommand;
55
55
  const child_process_1 = require("child_process");
56
56
  const fs = __importStar(require("fs"));
57
+ const crypto = __importStar(require("crypto"));
58
+ const config_1 = require("../config");
59
+ const localSafetySnapshot_1 = require("../localSafetySnapshot");
57
60
  const os = __importStar(require("os"));
58
61
  const path = __importStar(require("path"));
59
62
  const deterministicGuard_1 = require("./deterministicGuard");
@@ -379,16 +382,48 @@ function builtinRuleCommandNames() {
379
382
  function escapeDotNetRegex(literal) {
380
383
  return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
381
384
  }
382
- /** Cached Shield mode from the runtime bundle cache (offline read, never network). */
385
+ /** Select the enrolled Shield and its exact identity snapshot, never merge old caches. */
386
+ function shellEnrollment() {
387
+ const config = (0, config_1.loadMachineConfig)();
388
+ return {
389
+ shieldId: config.shieldId || process.env.FCD_SHIELD_ID || process.env.FULLCOURTDEFENSE_SHIELD_ID,
390
+ apiUrl: (config.apiUrl || process.env.FULLCOURTDEFENSE_API_URL || process.env.FCD_API_URL || 'https://api.fullcourtdefense.ai').replace(/\/$/, ''),
391
+ };
392
+ }
393
+ function cachedSnapshotFiles() {
394
+ try {
395
+ const enrollment = shellEnrollment();
396
+ if (enrollment.shieldId) {
397
+ const identity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
398
+ const key = crypto.createHash('sha1').update([enrollment.apiUrl, enrollment.shieldId, identity.developerName, identity.machineName].join('|')).digest('hex');
399
+ const name = `local-safety-${key}.json`;
400
+ return fs.existsSync(path.join(SNAPSHOT_CACHE_DIR, name)) ? [name] : [];
401
+ }
402
+ // Legacy standalone guards have no enrollment. Pick one coherent snapshot;
403
+ // never combine a disabled list from one version with actions from another.
404
+ return fs.readdirSync(SNAPSHOT_CACHE_DIR)
405
+ .filter(name => name.startsWith('local-safety-') && name.endsWith('.json'))
406
+ .map(name => { try {
407
+ return { name, at: JSON.parse(fs.readFileSync(path.join(SNAPSHOT_CACHE_DIR, name), 'utf8')).loadedAt || 0 };
408
+ }
409
+ catch {
410
+ return { name, at: -1 };
411
+ } })
412
+ .filter(entry => entry.at >= 0).sort((a, b) => b.at - a.at || a.name.localeCompare(b.name))
413
+ .slice(0, 1).map(entry => entry.name);
414
+ }
415
+ catch {
416
+ return [];
417
+ }
418
+ }
419
+ /** Cached Shield mode from the active enrollment (offline, never network). */
383
420
  function cachedMode() {
384
421
  try {
385
422
  const parsed = JSON.parse(fs.readFileSync(RUNTIME_CACHE_PATH, 'utf8'));
423
+ const { shieldId } = shellEnrollment();
386
424
  const entries = Object.values(parsed).filter(e => e && typeof e === 'object');
387
425
  entries.sort((a, b) => (b.fetchedAt || 0) - (a.fetchedAt || 0));
388
- const mode = entries[0]?.mode;
389
- // Enforce only on an explicit cached 'block'; missing/unknown = monitor.
390
- // A machine that has never been told to enforce must not block typed
391
- // commands (fresh install before the first bundle fetch, wiped cache).
426
+ const mode = shieldId ? parsed[shieldId]?.mode : entries[0]?.mode;
392
427
  return mode === 'block' ? 'block' : 'monitor';
393
428
  }
394
429
  catch {
@@ -404,7 +439,7 @@ function cachedDisabledItemIds() {
404
439
  const disabled = new Set();
405
440
  let snapshots = 0;
406
441
  try {
407
- for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
442
+ for (const file of cachedSnapshotFiles()) {
408
443
  if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
409
444
  continue;
410
445
  try {
@@ -443,7 +478,7 @@ function cachedItemActions() {
443
478
  const ask = new Set();
444
479
  const blocked = new Set();
445
480
  try {
446
- for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
481
+ for (const file of cachedSnapshotFiles()) {
447
482
  if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
448
483
  continue;
449
484
  try {
@@ -481,7 +516,7 @@ function cachedItemActions() {
481
516
  function cachedCustomRules() {
482
517
  const rules = [];
483
518
  try {
484
- for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
519
+ for (const file of cachedSnapshotFiles()) {
485
520
  if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
486
521
  continue;
487
522
  try {
@@ -524,11 +559,12 @@ function cachedCustomRules() {
524
559
  function effectiveBuiltinRules() {
525
560
  const disabled = cachedDisabledItemIds();
526
561
  const itemActions = cachedItemActions();
562
+ const hasSnapshot = cachedSnapshotFiles().length > 0;
527
563
  return BUILTIN_RULES
528
564
  .filter(rule => !disabled.has(rule.id))
529
565
  .map(rule => {
530
566
  const action = itemActions.get(rule.id);
531
- return action ? { ...rule, action } : rule;
567
+ return action ? { ...rule, action } : hasSnapshot ? rule : { ...rule, action: 'warn' };
532
568
  });
533
569
  }
534
570
  /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
@@ -707,7 +743,7 @@ function buildGuardPs1(nodePath, cliEntry) {
707
743
  ` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
708
744
  ` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
709
745
  ` $warnHit = $null`,
710
- ` $askHit = $null`,
746
+ ` $askHits = @()`,
711
747
  ` foreach ($rule in $global:FcdGuardRules) {`,
712
748
  ` try {`,
713
749
  ` # Built-in rules ask "can this text ACT?" — they see the execution`,
@@ -719,12 +755,19 @@ function buildGuardPs1(nodePath, cliEntry) {
719
755
  ` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
720
756
  ` # Precedence on one line: block outranks ask, ask outranks warn.`,
721
757
  ` if ($rule.Action -eq 'block') { return $rule }`,
722
- ` if ($rule.Action -eq 'ask') { if ($null -eq $askHit) { $askHit = $rule } }`,
758
+ ` if ($rule.Action -eq 'ask') { $askHits += $rule }`,
723
759
  ` elseif ($null -eq $warnHit) { $warnHit = $rule }`,
724
760
  ` }`,
725
761
  ` } catch { }`,
726
762
  ` }`,
727
- ` if ($null -ne $askHit) { return $askHit }`,
763
+ ` if ($askHits.Count -eq 1) { return $askHits[0] }`,
764
+ ` if ($askHits.Count -gt 1) {`,
765
+ ` $askHits = @($askHits | Sort-Object -Property Id -CaseSensitive)`,
766
+ ` $combined = $askHits[0].PSObject.Copy()`,
767
+ ` $combined.Id = 'asks:' + (($askHits | ForEach-Object { [Uri]::EscapeDataString($_.Id) }) -join '+')`,
768
+ ` $combined.Reason = ($askHits | ForEach-Object { $_.Reason }) -join '; '`,
769
+ ` return $combined`,
770
+ ` }`,
728
771
  ` return $warnHit`,
729
772
  `}`,
730
773
  ``,
@@ -774,7 +817,7 @@ function buildGuardPs1(nodePath, cliEntry) {
774
817
  ` 'remembered' { $reason = 'developer confirmed earlier in this shell (session memory) — ' + $reason }`,
775
818
  ` 'declined' { $reason = 'developer declined in terminal prompt — ' + $reason }`,
776
819
  ` 'timeout' { $reason = 'developer confirmation not given (terminal prompt timed out) — ' + $reason }`,
777
- ` 'ide' { $reason = 'developer confirmed in the IDE prompt (agent-driven terminal) — ' + $reason }`,
820
+ ` 'ide' { $reason = 'delegated to the IDE permission gate (confirmation recorded by post-hook) — ' + $reason }`,
778
821
  ` 'agent-terminal' { $reason = 'ask cannot be answered in an agent-driven terminal (no IDE confirmation) — ' + $reason }`,
779
822
  ` }`,
780
823
  ` $event = [ordered]@{`,
@@ -793,7 +836,7 @@ function buildGuardPs1(nodePath, cliEntry) {
793
836
  ` }`,
794
837
  ` if ($Developer -ne '') {`,
795
838
  ` $event['approvalScope'] = 'developer'`,
796
- ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered' -or $Developer -eq 'ide') { $event['approvedBy'] = 'developer' }`,
839
+ ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered') { $event['approvedBy'] = 'developer' }`,
797
840
  ` }`,
798
841
  ` $event = [pscustomobject]$event`,
799
842
  ` # BOM-free UTF8 append — PS5's Add-Content -Encoding utf8 writes a BOM that breaks JSONL parsing.`,
package/dist/config.d.ts CHANGED
@@ -61,6 +61,8 @@ export declare function dpapiRoundtripProbe(): {
61
61
  detail: string;
62
62
  };
63
63
  export declare function getHomeConfigPath(): string;
64
+ /** Machine enrollment only; terminal guards must not inherit a repository's Shield. */
65
+ export declare function loadMachineConfig(): BotGuardConfig;
64
66
  export declare function loadConfig(configPath?: string): BotGuardConfig;
65
67
  export declare function getDefaultConfigPath(): string;
66
68
  export declare function saveShieldConfig(input: {
package/dist/config.js CHANGED
@@ -38,6 +38,7 @@ exports.getPowershellHealth = getPowershellHealth;
38
38
  exports.capturePowershellLanguageMode = capturePowershellLanguageMode;
39
39
  exports.dpapiRoundtripProbe = dpapiRoundtripProbe;
40
40
  exports.getHomeConfigPath = getHomeConfigPath;
41
+ exports.loadMachineConfig = loadMachineConfig;
41
42
  exports.loadConfig = loadConfig;
42
43
  exports.getDefaultConfigPath = getDefaultConfigPath;
43
44
  exports.saveShieldConfig = saveShieldConfig;
@@ -295,6 +296,10 @@ function mergeConfig(base, override) {
295
296
  function getHomeConfigPath() {
296
297
  return path.join(os.homedir(), '.fullcourtdefense.yml');
297
298
  }
299
+ /** Machine enrollment only; terminal guards must not inherit a repository's Shield. */
300
+ function loadMachineConfig() {
301
+ return readConfigFile(getHomeConfigPath());
302
+ }
298
303
  function loadConfig(configPath) {
299
304
  const homeConfig = readConfigFile(getHomeConfigPath());
300
305
  let projectConfig = {};
@@ -13,6 +13,8 @@ export interface PendingConfirmation {
13
13
  sessionId?: string;
14
14
  remember: ConfirmRemember;
15
15
  raisedAt: string;
16
+ /** Exact-command handoff to the IDE permission gate; not proof of consent. */
17
+ permissionHandoff?: boolean;
16
18
  }
17
19
  export interface RememberedConfirmation {
18
20
  /**
@@ -65,6 +67,8 @@ export declare function settleDeveloperConfirmation(event: string, toolName: str
65
67
  marker: PendingConfirmation;
66
68
  confirmLatencyMs: number;
67
69
  } | undefined;
70
+ /** Reject or cancel a pending request without creating approval memory. */
71
+ export declare function rejectDeveloperConfirmation(event: string, toolName: string, argsDigest: string): void;
68
72
  /**
69
73
  * Markers older than the TTL never saw a post-execution hook: the developer denied the IDE
70
74
  * prompt, dismissed it, or the client ignored `ask`. Removed and returned so the caller can
@@ -40,6 +40,7 @@ exports.actionDigest = actionDigest;
40
40
  exports.pendingKey = pendingKey;
41
41
  exports.raiseDeveloperConfirmation = raiseDeveloperConfirmation;
42
42
  exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
43
+ exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
43
44
  exports.sweepExpiredConfirmations = sweepExpiredConfirmations;
44
45
  exports.isConfirmationRemembered = isConfirmationRemembered;
45
46
  /**
@@ -106,7 +107,8 @@ function readLedger() {
106
107
  const parsed = JSON.parse(fs.readFileSync(ledgerFile(), 'utf8'));
107
108
  return {
108
109
  pending: Array.isArray(parsed?.pending) ? parsed.pending.filter((p) => p && typeof p === 'object') : [],
109
- remembered: Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
110
+ // Older ledgers could remember declines. Require fresh affirmative consent.
111
+ remembered: parsed?.formatVersion === 2 && Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
110
112
  };
111
113
  }
112
114
  catch {
@@ -116,7 +118,9 @@ function readLedger() {
116
118
  function writeLedger(ledger) {
117
119
  try {
118
120
  fs.mkdirSync(path.dirname(ledgerFile()), { recursive: true });
119
- fs.writeFileSync(ledgerFile(), JSON.stringify(ledger), { encoding: 'utf8', mode: 0o600 });
121
+ const temp = `${ledgerFile()}.${process.pid}.${crypto.randomUUID()}.tmp`;
122
+ fs.writeFileSync(temp, JSON.stringify({ ...ledger, formatVersion: 2 }), { encoding: 'utf8', mode: 0o600 });
123
+ fs.renameSync(temp, ledgerFile());
120
124
  }
121
125
  catch { /* best-effort — losing a record must never change a verdict */ }
122
126
  }
@@ -185,7 +189,7 @@ function settleDeveloperConfirmation(event, toolName, argsDigest, now = new Date
185
189
  if (!marker)
186
190
  return undefined;
187
191
  ledger.pending = ledger.pending.filter(p => p.key !== key);
188
- if (marker.remember !== 'none') {
192
+ if (marker.remember !== 'none' && Date.parse(marker.raisedAt) <= now.getTime() && now.getTime() - Date.parse(marker.raisedAt) < exports.CONFIRM_PENDING_TTL_MS) {
189
193
  const ttl = marker.remember === '1h' ? REMEMBER_1H_MS : REMEMBER_SESSION_MAX_MS;
190
194
  const remembered = {
191
195
  key: rememberKey(marker),
@@ -206,6 +210,13 @@ function settleDeveloperConfirmation(event, toolName, argsDigest, now = new Date
206
210
  writeLedger(ledger);
207
211
  return { marker, confirmLatencyMs: Math.max(0, now.getTime() - Date.parse(marker.raisedAt)) };
208
212
  }
213
+ /** Reject or cancel a pending request without creating approval memory. */
214
+ function rejectDeveloperConfirmation(event, toolName, argsDigest) {
215
+ const ledger = readLedger();
216
+ const key = pendingKey(event, toolName, argsDigest);
217
+ ledger.pending = ledger.pending.filter(p => p.key !== key);
218
+ writeLedger(ledger);
219
+ }
209
220
  /**
210
221
  * Markers older than the TTL never saw a post-execution hook: the developer denied the IDE
211
222
  * prompt, dismissed it, or the client ignored `ask`. Removed and returned so the caller can
@@ -233,7 +244,7 @@ function isConfirmationRemembered(scope, sessionId, now = new Date()) {
233
244
  const hit = ledger.remembered.find(r => r.key === key);
234
245
  if (!hit)
235
246
  return undefined;
236
- if (Date.parse(hit.expiresAt) <= now.getTime())
247
+ if (!Number.isFinite(Date.parse(hit.expiresAt)) || Date.parse(hit.expiresAt) <= now.getTime())
237
248
  return undefined;
238
249
  if (hit.remember === 'session' && (!sessionId || hit.sessionId !== sessionId))
239
250
  return undefined;
@@ -168,6 +168,11 @@ export declare function resetCacheOnEnrollment(): void;
168
168
  * "exception = allow" must not become a kill-switch bypass. Reads only the
169
169
  * local cache file (no network, no config parsing).
170
170
  */
171
+ /** Failure handling reads only the requested Shield, never another enrollment. */
172
+ export declare function cachedEnforcementState(shieldId: string | undefined): {
173
+ mode: ShieldMode;
174
+ failClosed: boolean;
175
+ };
171
176
  export declare function isSuspendedByCache(): boolean;
172
177
  /**
173
178
  * Admin scan-folder choices from the most recent cached bundle (any shield):
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getRuntimeBundle = getRuntimeBundle;
37
37
  exports.validateShieldCredentials = validateShieldCredentials;
38
38
  exports.resetCacheOnEnrollment = resetCacheOnEnrollment;
39
+ exports.cachedEnforcementState = cachedEnforcementState;
39
40
  exports.isSuspendedByCache = isSuspendedByCache;
40
41
  exports.getCachedScanRootOverrides = getCachedScanRootOverrides;
41
42
  exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
@@ -434,6 +435,11 @@ function resetCacheOnEnrollment() {
434
435
  * "exception = allow" must not become a kill-switch bypass. Reads only the
435
436
  * local cache file (no network, no config parsing).
436
437
  */
438
+ /** Failure handling reads only the requested Shield, never another enrollment. */
439
+ function cachedEnforcementState(shieldId) {
440
+ const entry = shieldId ? readCacheFile()[shieldId] : undefined;
441
+ return { mode: entry && isMode(entry.mode) ? entry.mode : 'monitor', failClosed: entry?.failClosed === true };
442
+ }
437
443
  function isSuspendedByCache() {
438
444
  try {
439
445
  const cache = readCacheFile();
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.32.0"
2
+ "version": "1.33.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "README.md"
14
14
  ],
15
15
  "scripts": {
16
+ "test:endpoint-approvals": "npm run build && node scripts/audit-endpoint-mcp-shell.js",
17
+ "test:endpoint-clients": "npm run build && node scripts/test-endpoint-client-matrix.js && node scripts/test-copilot-hook-install.js",
16
18
  "build": "tsc && node scripts/copy-attack-corpus.js",
17
19
  "test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
18
20
  "test:drive-delete-guard": "npm run build && node scripts/test-drive-delete-guard.js",