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.
- package/dist/actionPolicyEngine.js +18 -2
- package/dist/agentTerminal.d.ts +6 -3
- package/dist/agentTerminal.js +19 -16
- package/dist/commands/cmdGuard.js +2 -2
- package/dist/commands/deterministicGuard.d.ts +2 -0
- package/dist/commands/deterministicGuard.js +17 -4
- package/dist/commands/discoverPaths.js +2 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +34 -19
- package/dist/commands/installAll.js +13 -2
- package/dist/commands/installClaudeHook.d.ts +9 -3
- package/dist/commands/installClaudeHook.js +48 -2
- package/dist/commands/installNativeHooks.d.ts +9 -0
- package/dist/commands/installNativeHooks.js +145 -0
- package/dist/commands/mcpGateway.js +48 -41
- package/dist/commands/posixShellGuard.js +2 -2
- package/dist/commands/shellGuard.js +57 -14
- package/dist/commands/uninstallAll.js +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -0
- package/dist/devConfirm.d.ts +4 -0
- package/dist/devConfirm.js +15 -4
- package/dist/hookIo.js +1 -0
- package/dist/index.js +21 -0
- package/dist/nativeHook.d.ts +25 -0
- package/dist/nativeHook.js +239 -0
- package/dist/runtimeConfig.d.ts +5 -0
- package/dist/runtimeConfig.js +6 -0
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +7 -0
- package/dist/version.json +1 -1
- package/package.json +4 -1
|
@@ -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[
|
|
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
|
}
|
package/dist/agentTerminal.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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`,
|
package/dist/agentTerminal.js
CHANGED
|
@@ -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
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
` $
|
|
174
|
-
`
|
|
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)
|
|
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
|
|
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='
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
];
|
package/dist/commands/hook.d.ts
CHANGED
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
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
|
-
:
|
|
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;
|
|
1276
|
-
if (
|
|
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'; //
|
|
1289
|
-
modeResolved = true;
|
|
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
|
-
//
|
|
57
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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[];
|