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.
- 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/hook.js +26 -12
- package/dist/commands/installAll.js +2 -2
- package/dist/commands/installClaudeHook.d.ts +3 -3
- package/dist/commands/installClaudeHook.js +42 -2
- package/dist/commands/mcpGateway.js +47 -40
- package/dist/commands/posixShellGuard.js +2 -2
- package/dist/commands/shellGuard.js +57 -14
- 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/runtimeConfig.d.ts +5 -0
- package/dist/runtimeConfig.js +6 -0
- package/dist/version.json +1 -1
- package/package.json +3 -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
|
package/dist/commands/hook.js
CHANGED
|
@@ -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)
|
|
@@ -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
|
-
//
|
|
57
|
-
//
|
|
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
|
|
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
|
*/
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
1315
|
-
//
|
|
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
|
-
|
|
1337
|
-
|
|
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 &&
|
|
1467
|
+
else if (this.gatewayConfig.failClosed && !reportOnlyMode) {
|
|
1474
1468
|
// Fail-closed only bites when the machine is actually in block mode
|
|
1475
|
-
//
|
|
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
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
|
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='
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
` $
|
|
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') {
|
|
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 ($
|
|
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 = '
|
|
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'
|
|
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 = {};
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -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
|
package/dist/devConfirm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -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):
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
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",
|