fullcourtdefense-cli 1.32.0 → 1.34.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
@@ -215,6 +215,8 @@ function candidateConfigPaths(cwd, extra) {
215
215
  { path: path.join(xdg, 'Code', 'User', 'settings.json'), source: 'VS Code (user, linux)', clientKey: 'vscode' },
216
216
  { path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), source: 'Windsurf', clientKey: 'windsurf' },
217
217
  { path: path.join(home, '.kiro', 'mcp.json'), source: 'Kiro', clientKey: 'kiro' },
218
+ { path: path.join(home, '.kiro', 'settings', 'mcp.json'), source: 'Kiro (settings)', clientKey: 'kiro' },
219
+ { path: path.join(cwd, '.kiro', 'settings', 'mcp.json'), source: 'Kiro (project settings)', clientKey: 'kiro' },
218
220
  { path: path.join(cwd, '.mcp.json'), source: 'Repo (.mcp.json)', clientKey: 'other' },
219
221
  { path: path.join(cwd, 'mcp.json'), source: 'Repo (mcp.json)', clientKey: 'other' },
220
222
  ];
@@ -20,6 +20,7 @@ import { BotGuardConfig } from '../config';
20
20
  * human approves/rejects in the FullCourtDefense console (same as the gateway).
21
21
  */
22
22
  export interface HookArgs {
23
+ agentClient?: string;
23
24
  event?: string;
24
25
  apiUrl?: string;
25
26
  shieldId?: string;
@@ -191,10 +191,10 @@ function mapClaudeToolPayload(payload) {
191
191
  // a file read is `read_file { path }`. Built-ins with no enforcement surface
192
192
  // (search, task, web) are ignored. Nothing here decides anything.
193
193
  // ---------------------------------------------------------------------------
194
- const BUILTIN_SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal']);
195
- const BUILTIN_READ_TOOLS = new Set(['read', 'readfile', 'view']);
194
+ const BUILTIN_SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal', 'runshellcommand', 'executebash', 'execcommand']);
195
+ const BUILTIN_READ_TOOLS = new Set(['read', 'readfile', 'view', 'fsread']);
196
196
  const BUILTIN_WRITE_TOOLS = new Set([
197
- 'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles',
197
+ 'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles', 'writefile', 'fswrite', 'replace',
198
198
  'replacestringinfile', 'editfile', 'applypatch', 'strreplaceeditor', 'insertedittofile', 'strreplace', 'editnotebook',
199
199
  ]);
200
200
  /**
@@ -221,11 +221,11 @@ function mapBuiltinToolPayload(toolName, input, payload) {
221
221
  if (BUILTIN_SHELL_TOOLS.has(canon)) {
222
222
  return {
223
223
  event: 'shell',
224
- payload: { ...payload, command: str(input.command), cwd: str(input.cwd) || str(input.working_directory) || str(payload.cwd) || undefined },
224
+ payload: { ...payload, command: str(input.command) || str(input.cmd), cwd: str(input.cwd) || str(input.workdir) || str(input.working_directory) || str(payload.cwd) || undefined },
225
225
  };
226
226
  }
227
227
  if (BUILTIN_READ_TOOLS.has(canon)) {
228
- return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.path) || str(input.target_file) } };
228
+ return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.filePath) || str(input.absolute_path) || str(input.path) || str(input.target_file) } };
229
229
  }
230
230
  if (BUILTIN_WRITE_TOOLS.has(canon)) {
231
231
  const filePath = str(input.file_path) || str(input.notebook_path) || str(input.path) || str(input.filePath) || str(input.target_file)
@@ -235,7 +235,7 @@ function mapBuiltinToolPayload(toolName, input, payload) {
235
235
  // `new_source`) are reduced to the lines they add. Both then drop the
236
236
  // credential-shaped words the file on disk already contains — re-stating a
237
237
  // value that is already in the file is not putting a secret into it.
238
- const delta = str(input.new_string) || str(input.new_str) || str(input.new_text)
238
+ const delta = str(input.new_string) || str(input.newString) || str(input.newText) || str(input.new_str) || str(input.new_text)
239
239
  || (Array.isArray(input.edits)
240
240
  ? input.edits.map((e) => str(e?.new_string) || str(e?.new_text) || str(e?.newText) || str(e?.content)).filter(Boolean).join('\n')
241
241
  : '');
@@ -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)
@@ -1335,7 +1345,8 @@ async function hookCommandInner(args, config, io) {
1335
1345
  // client that reads ~/.claude/settings.json.
1336
1346
  const claudeNormalized = normalizeClaudePayload(payload);
1337
1347
  const hookFormat = claudeNormalized ? 'claude' : 'cursor';
1338
- const client = claudeNormalized ? claudeFormatClient() : 'cursor';
1348
+ const client = args.agentClient || (claudeNormalized ? claudeFormatClient() : 'cursor');
1349
+ (0, telemetry_1.setVerdictClient)(client);
1339
1350
  let event;
1340
1351
  let ignoreEvent = false;
1341
1352
  if (claudeNormalized) {
@@ -1422,6 +1433,10 @@ async function hookCommandInner(args, config, io) {
1422
1433
  }
1423
1434
  else if (denied || ask) {
1424
1435
  decision = {
1436
+ // Copilot CLI reads the top-level decision; Claude Code and VS Code
1437
+ // read hookSpecificOutput. Emit the same verdict in both contracts.
1438
+ permissionDecision: ask ? 'ask' : 'deny',
1439
+ permissionDecisionReason: userMsg || agentMsg || 'FullCourtDefense policy decision.',
1425
1440
  hookSpecificOutput: {
1426
1441
  hookEventName: 'PreToolUse',
1427
1442
  permissionDecision: ask ? 'ask' : 'deny',
@@ -5,6 +5,7 @@ const config_1 = require("../config");
5
5
  const discover_1 = require("./discover");
6
6
  const installCursorHook_1 = require("./installCursorHook");
7
7
  const installClaudeHook_1 = require("./installClaudeHook");
8
+ const installNativeHooks_1 = require("./installNativeHooks");
8
9
  const discoverSchedule_1 = require("./discoverSchedule");
9
10
  const mcpGateway_1 = require("./mcpGateway");
10
11
  const autoProtect_1 = require("./autoProtect");
@@ -35,6 +36,16 @@ async function installAllCommand(args, config) {
35
36
  console.log('\x1b[2mAuto-protect MCP servers across AI clients + Cursor built-in action hooks. No --mcp-command or folder path needed.\x1b[0m\n');
36
37
  await (0, mcpGateway_1.protectAllCommand)(gatewayArgs, config);
37
38
  if (args.hooks !== 'false') {
39
+ try {
40
+ const nativeFiles = (0, installNativeHooks_1.installNativeHooks)();
41
+ for (const file of nativeFiles)
42
+ console.log(`Native action hooks configured: ${file}`);
43
+ if (nativeFiles.some(file => file.includes('.codex')))
44
+ console.log('Codex: review and trust the new hooks using /hooks before they can run.');
45
+ }
46
+ catch (error) {
47
+ console.error(`Native hooks incomplete: ${error instanceof Error ? error.message : String(error)}. Repair the settings and rerun install-native-hooks.`);
48
+ }
38
49
  console.log('\n\x1b[1mInstalling Cursor runtime hooks…\x1b[0m');
39
50
  const hookArgs = {
40
51
  shieldId: creds.shieldId,
@@ -53,8 +64,8 @@ async function installAllCommand(args, config) {
53
64
  catch (error) {
54
65
  console.log(`Cursor hooks skipped: ${error instanceof Error ? error.message : String(error)}`);
55
66
  }
56
- // ONE Claude-format hook file (~/.claude/settings.json) covers Claude Code,
57
- // VS Code Copilot agent mode, and GitHub Copilot CLI.
67
+ // Claude/VS Code share Claude settings; Copilot CLI receives separate
68
+ // user tool hooks from the same installer.
58
69
  console.log('\n\x1b[1mInstalling Claude Code / VS Code / Copilot CLI runtime hooks…\x1b[0m');
59
70
  const claudeHookArgs = {
60
71
  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
  */
@@ -35,5 +35,11 @@ export interface ClaudeManagedHookRepairResult {
35
35
  export declare function inspectClaudeManagedHooks(): ClaudeManagedHookRepairResult;
36
36
  /** Repair managed commands without changing matcher/event/fail-open behavior. */
37
37
  export declare function repairClaudeManagedHooks(): ClaudeManagedHookRepairResult;
38
+ /** Copilot CLI user settings are separate from Claude's global settings.
39
+ * Use inline settings, not ~/.copilot/hooks, which VS Code also loads and
40
+ * would therefore execute twice alongside its Claude hook.
41
+ */
42
+ export declare function syncCopilotToolHooks(command?: string): boolean;
43
+ export declare function installCopilotHookCommand(): void;
38
44
  export declare function installClaudeHookCommand(args: InstallClaudeHookArgs, config: BotGuardConfig): Promise<void>;
39
45
  export declare function uninstallClaudeHookCommand(args: InstallClaudeHookArgs): Promise<void>;
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.inspectClaudeManagedHooks = inspectClaudeManagedHooks;
37
37
  exports.repairClaudeManagedHooks = repairClaudeManagedHooks;
38
+ exports.syncCopilotToolHooks = syncCopilotToolHooks;
39
+ exports.installCopilotHookCommand = installCopilotHookCommand;
38
40
  exports.installClaudeHookCommand = installClaudeHookCommand;
39
41
  exports.uninstallClaudeHookCommand = uninstallClaudeHookCommand;
40
42
  const fs = __importStar(require("fs"));
@@ -189,7 +191,47 @@ function repairClaudeManagedHooks() {
189
191
  fs.mkdirSync(path.dirname(before.file), { recursive: true });
190
192
  fs.writeFileSync(before.file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
191
193
  }
192
- return { ...before, changed };
194
+ const copilotChanged = syncCopilotToolHooks(buildHookCommand({ shadow: false, failClosed: false }));
195
+ return { ...before, changed: changed || copilotChanged };
196
+ }
197
+ /** Copilot CLI user settings are separate from Claude's global settings.
198
+ * Use inline settings, not ~/.copilot/hooks, which VS Code also loads and
199
+ * would therefore execute twice alongside its Claude hook.
200
+ */
201
+ function syncCopilotToolHooks(command) {
202
+ const root = process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot');
203
+ const file = path.join(root, 'settings.json');
204
+ if (!command && !fs.existsSync(file))
205
+ return false;
206
+ // Preserve user settings: malformed existing JSON must never be overwritten.
207
+ const settings = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
208
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings))
209
+ throw new Error(`Invalid Copilot settings: ${file}`);
210
+ const before = JSON.stringify(settings);
211
+ const hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
212
+ for (const event of ['PreToolUse', 'PostToolUse']) {
213
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
214
+ const kept = entries.filter(entry => !isManagedCommand(entry));
215
+ if (command)
216
+ kept.push({ type: 'command', command: `${command} --agent-client copilot`, timeoutSec: event === 'PreToolUse' ? TOOL_HOOK_TIMEOUT_SEC : POST_TOOL_HOOK_TIMEOUT_SEC });
217
+ if (kept.length)
218
+ hooks[event] = kept;
219
+ else
220
+ delete hooks[event];
221
+ }
222
+ if (Object.keys(hooks).length)
223
+ settings.hooks = hooks;
224
+ else
225
+ delete settings.hooks;
226
+ if (before === JSON.stringify(settings))
227
+ return false;
228
+ fs.mkdirSync(path.dirname(file), { recursive: true });
229
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
230
+ return true;
231
+ }
232
+ function installCopilotHookCommand() {
233
+ syncCopilotToolHooks(buildHookCommand({ shadow: false, failClosed: false }));
234
+ console.log('Copilot CLI tool hooks configured. Restart Copilot CLI to load the changes.');
193
235
  }
194
236
  // PostToolUse companion of PreToolUse: enforces nothing; it is how a developer's
195
237
  // Allow on the client's own `ask` prompt (developer-scoped approval) is learned.
@@ -290,11 +332,13 @@ async function installClaudeHookCommand(args, config) {
290
332
  }
291
333
  fs.mkdirSync(path.dirname(file), { recursive: true });
292
334
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
335
+ if (!projectScope)
336
+ syncCopilotToolHooks(wantTools ? command : undefined);
293
337
  console.log('');
294
338
  console.log(`${COLOR.green}${COLOR.bold}FullCourtDefense Claude-format hook installed.${COLOR.reset}`);
295
339
  console.log(`${COLOR.gray}Scope:${COLOR.reset} ${projectScope ? 'project (.claude/settings.json)' : 'machine-wide (~/.claude/settings.json)'}`);
296
340
  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)`);
341
+ 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
342
  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
343
  const parts = [];
300
344
  if (wantTools)
@@ -318,6 +362,8 @@ async function installClaudeHookCommand(args, config) {
318
362
  }
319
363
  async function uninstallClaudeHookCommand(args) {
320
364
  const projectScope = args.project === 'true';
365
+ if (!projectScope)
366
+ syncCopilotToolHooks();
321
367
  const file = settingsPath(projectScope);
322
368
  if (!fs.existsSync(file)) {
323
369
  console.log(`${COLOR.yellow}No settings.json found at ${file}. Nothing to remove.${COLOR.reset}`);
@@ -0,0 +1,9 @@
1
+ import type { NativeClient } from '../nativeHook';
2
+ export declare function nativeHookCommand(client: NativeClient): string;
3
+ /** Preserve unrelated settings and hooks; refuse malformed files. */
4
+ export declare function installNativeHooks(options?: {
5
+ home?: string;
6
+ project?: string;
7
+ clients?: NativeClient[];
8
+ remove?: boolean;
9
+ }): string[];