fullcourtdefense-cli 1.25.4 → 1.25.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cmdGuard.js +10 -6
- package/dist/commands/deterministicGuard.js +42 -16
- package/dist/commands/hook.js +57 -12
- package/dist/commands/mcpGateway.js +28 -3
- package/dist/commands/posixShellGuard.js +5 -4
- package/dist/commands/shellGuard.js +16 -10
- package/dist/policyGateHealth.d.ts +8 -0
- package/dist/policyGateHealth.js +14 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -112,22 +112,26 @@ function batQuote(value) {
|
|
|
112
112
|
function buildGuardJs(nodePath, cliEntry) {
|
|
113
113
|
return [
|
|
114
114
|
`'use strict';`,
|
|
115
|
-
`const fs=require('fs');const os=require('os');const path=require('path');const{spawnSync}=require('child_process');const crypto=require('crypto');`,
|
|
115
|
+
`const fs=require('fs');const os=require('os');const path=require('path');const{spawn,spawnSync}=require('child_process');const crypto=require('crypto');`,
|
|
116
116
|
`const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
|
|
117
117
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
118
118
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
119
119
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
120
120
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
121
|
-
`// Execution view (aligned with the CLI's stripInertDataSegments):
|
|
122
|
-
`//
|
|
123
|
-
|
|
121
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
122
|
+
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
123
|
+
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
124
|
+
`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;}`,
|
|
124
125
|
`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;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!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
125
|
-
|
|
126
|
+
`// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
|
|
127
|
+
`// made the user's terminal wait for a full CLI process INCLUDING the network`,
|
|
128
|
+
`// flush after every finding. Monitor mode must never add synchronous latency.`,
|
|
129
|
+
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};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{}}`,
|
|
126
130
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
127
131
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
128
132
|
`const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
129
133
|
`if(!hit)delegate(args);`,
|
|
130
|
-
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'
|
|
134
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
131
135
|
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
132
136
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
133
137
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
@@ -783,20 +783,38 @@ function isSensitivePathContext(toolName, candidate) {
|
|
|
783
783
|
return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
|
|
784
784
|
}
|
|
785
785
|
// ---- execution view: message/document DATA inside a command is inert ---------
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
786
|
+
// Industry-standard model (Sigma / EDR command-line parsing): detection matches
|
|
787
|
+
// what EXECUTES — the command word and its argv — never free text riding inside
|
|
788
|
+
// a data argument. Quoted spans containing whitespace are prose (commit
|
|
789
|
+
// messages, issue/PR bodies, file contents via Set-Content/-Value, docs) and
|
|
790
|
+
// are inert BY DEFAULT; code positions are the enumerated exception. This
|
|
791
|
+
// inverts the old text-sink flag allowlist (-m/--body/-Value…), which lost
|
|
792
|
+
// every time a new data flag appeared — code sinks are a small stable set,
|
|
793
|
+
// data flags are infinite.
|
|
794
|
+
//
|
|
795
|
+
// Safety invariants (false-negative guards):
|
|
789
796
|
// 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
|
|
790
|
-
// eval, xargs, base64 -d, -EncodedCommand, FromBase64String)
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
797
|
+
// eval, xargs, base64 -d, -EncodedCommand, FromBase64String) or invoke an
|
|
798
|
+
// interpreter code flag (bash -c, python -c) or a remote/nested executor
|
|
799
|
+
// (ssh, wsl, chroot, docker|kubectl|podman exec/run). A whole-line bailout
|
|
800
|
+
// is always FN-safe and also defeats quote-splitting obfuscation
|
|
801
|
+
// (`wsl -e bash -c ""r""m"…`) that per-span reasoning cannot survive.
|
|
802
|
+
// 2. A double-quoted span containing $( … ) or real backtick command
|
|
803
|
+
// substitution is kept — substitution executes BEFORE the outer command.
|
|
804
|
+
// A single-char backtick pair (`n, `t) is a PowerShell ESCAPE, not code.
|
|
805
|
+
// 3. Single-token quoted spans (rm -rf "/") are argv, not prose — kept.
|
|
806
|
+
// 4. Quoted spans in a CODE POSITION (interpreter -c/-Command/-e flags, or a
|
|
807
|
+
// remote executor earlier in the same statement) are kept.
|
|
808
|
+
// 5. Secret-literal rules and honeypot/custom rules never use this view —
|
|
795
809
|
// a token pasted into a commit message still leaks.
|
|
796
|
-
const DATA_TO_INTERPRETER = /\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/i;
|
|
797
|
-
|
|
810
|
+
const DATA_TO_INTERPRETER = /\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;
|
|
811
|
+
// $( … ) always executes; backtick pairs only with ≥2 chars of content
|
|
812
|
+
// (PowerShell escapes like `n / `t are single-char and inert).
|
|
813
|
+
const INTERPOLATES = /\$\(|`[^`]{2,}`/;
|
|
798
814
|
const INERT_DATA = ' fcd_inert_data ';
|
|
799
|
-
|
|
815
|
+
// Code position immediately before a quoted span: interpreter code flags or a
|
|
816
|
+
// remote/nested executor earlier in the same statement (no ; & | between).
|
|
817
|
+
const CODE_CONTEXT_BEFORE = /(?:(?:^|[;&|(]|\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;
|
|
800
818
|
function stripInertDataSegments(text) {
|
|
801
819
|
if (DATA_TO_INTERPRETER.test(text))
|
|
802
820
|
return text;
|
|
@@ -807,11 +825,19 @@ function stripInertDataSegments(text) {
|
|
|
807
825
|
// POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
|
|
808
826
|
out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
|
|
809
827
|
out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
|
|
810
|
-
//
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
828
|
+
// Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
|
|
829
|
+
// unless the span sits in a code position (invariants 2–4 above).
|
|
830
|
+
const src = out;
|
|
831
|
+
out = src.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
|
|
832
|
+
const body = dq !== undefined ? dq : (sq || '');
|
|
833
|
+
if (!/\s/.test(body))
|
|
834
|
+
return whole; // single token: argv, not prose
|
|
835
|
+
const before = src.slice(Math.max(0, offset - 80), offset);
|
|
836
|
+
if (CODE_CONTEXT_BEFORE.test(before))
|
|
837
|
+
return whole; // code position: executes
|
|
838
|
+
if (dq !== undefined && INTERPOLATES.test(body))
|
|
839
|
+
return whole; // $( )/`cmd` executes first
|
|
840
|
+
return INERT_DATA;
|
|
815
841
|
});
|
|
816
842
|
return out;
|
|
817
843
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -348,9 +348,9 @@ function degradedAllowMessage(event, detail) {
|
|
|
348
348
|
* field hit the timeout on transient jitter, and a single retry converts
|
|
349
349
|
* nearly all of them into normal verdicts instead of degraded decisions.
|
|
350
350
|
*/
|
|
351
|
-
async function fetchWithOneRetry(url, init, timeoutMs, onFailure) {
|
|
351
|
+
async function fetchWithOneRetry(url, init, timeoutMs, onFailure, attempts = 2) {
|
|
352
352
|
let lastError;
|
|
353
|
-
for (let attempt = 1; attempt <=
|
|
353
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
354
354
|
const controller = new AbortController();
|
|
355
355
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
356
356
|
try {
|
|
@@ -385,8 +385,11 @@ function respondDegraded(ctx, detail, toolName, authRejected = false) {
|
|
|
385
385
|
const advice = authRejected
|
|
386
386
|
? 'Re-enroll this machine: fullcourtdefense onboard --token <fleet-enrollment-token>.'
|
|
387
387
|
: 'Do not retry until the connection is restored.';
|
|
388
|
+
// Always track the outage — monitor/shadow machines use the streak too
|
|
389
|
+
// (gateRecentlyDown) to skip synchronous gate calls while the backend is
|
|
390
|
+
// down, so a dead network costs them zero added latency per action.
|
|
391
|
+
const health = (0, policyGateHealth_1.recordGateFailure)();
|
|
388
392
|
if (ctx.failClosed && !ctx.shadow) {
|
|
389
|
-
const health = (0, policyGateHealth_1.recordGateFailure)();
|
|
390
393
|
if ((0, policyGateHealth_1.shouldFailClosed)(health)) {
|
|
391
394
|
// Persistent outage on an enforcing machine: block, and raise a CRITICAL
|
|
392
395
|
// distress code — the daemon heartbeat ships it and org admins get the
|
|
@@ -422,14 +425,19 @@ function respondDegraded(ctx, detail, toolName, authRejected = false) {
|
|
|
422
425
|
* which is unreachable by definition here. Monitor/shadow machines stay
|
|
423
426
|
* report-only — the local verdict is logged, nothing is stopped.
|
|
424
427
|
*/
|
|
425
|
-
function tryLocalPolicyEnforcement(ctx, call, detail) {
|
|
428
|
+
function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
426
429
|
const policies = ctx.localPolicies;
|
|
427
430
|
if (!policies || policies.length === 0)
|
|
428
431
|
return false;
|
|
429
432
|
// Track the outage for visibility: distress ships on the next heartbeat and
|
|
430
|
-
// the failure streak still feeds the grace-window state.
|
|
431
|
-
(
|
|
432
|
-
|
|
433
|
+
// the failure streak still feeds the grace-window state. Skipped-call paths
|
|
434
|
+
// (monitor gate-down fast path) pass countAsGateFailure:false — refreshing
|
|
435
|
+
// lastFailureAt on every SKIP would keep gateRecentlyDown() true forever and
|
|
436
|
+
// the machine would never retry the gate.
|
|
437
|
+
if (opts.countAsGateFailure !== false) {
|
|
438
|
+
(0, policyGateHealth_1.recordGateFailure)();
|
|
439
|
+
(0, distress_1.reportDistress)('hook', distress_1.DISTRESS.NETWORK_DOWN, `policy gate unreachable — enforcing locally from cached bundle (${policies.length} policies): ${detail}`);
|
|
440
|
+
}
|
|
433
441
|
const { operation, context } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
|
|
434
442
|
context.toolName = call.toolName;
|
|
435
443
|
context.developerName = developerId();
|
|
@@ -440,7 +448,9 @@ function tryLocalPolicyEnforcement(ctx, call, detail) {
|
|
|
440
448
|
if (local.verdict === 'block' || local.verdict === 'require_approval') {
|
|
441
449
|
const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
|
|
442
450
|
if (ctx.shadow) {
|
|
443
|
-
|
|
451
|
+
// Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
|
|
452
|
+
// (which hides the would-block from the console) and never 'block'.
|
|
453
|
+
(0, telemetry_1.spoolEvent)({ decision: 'warn', toolName: call.toolName, reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
|
|
444
454
|
(0, telemetry_1.triggerFlush)(false);
|
|
445
455
|
ctx.respond(false, undefined, `[FullCourtDefense shadow] would ${local.verdict === 'block' ? 'block' : 'require approval for'} ${call.toolName} (offline, locally cached policy): ${reason}`);
|
|
446
456
|
}
|
|
@@ -811,7 +821,8 @@ async function hookCommand(args, config) {
|
|
|
811
821
|
if (localBlock) {
|
|
812
822
|
dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
813
823
|
if (shadow) {
|
|
814
|
-
|
|
824
|
+
// Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
|
|
825
|
+
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn' });
|
|
815
826
|
respond(false, undefined, `[FullCourtDefense shadow] would block prompt: ${localBlock.reason}`);
|
|
816
827
|
return;
|
|
817
828
|
}
|
|
@@ -935,7 +946,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
935
946
|
if (localBlock) {
|
|
936
947
|
dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
937
948
|
if (shadow) {
|
|
938
|
-
|
|
949
|
+
// Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
|
|
950
|
+
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn' });
|
|
939
951
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
|
|
940
952
|
return;
|
|
941
953
|
}
|
|
@@ -982,6 +994,26 @@ async function enforceActionPolicy(ctx) {
|
|
|
982
994
|
const headers = { 'Content-Type': 'application/json' };
|
|
983
995
|
if (shieldKey)
|
|
984
996
|
headers['x-shield-key'] = shieldKey;
|
|
997
|
+
// MONITOR CONTRACT (latency half): a monitor/shadow machine must never make
|
|
998
|
+
// the developer WAIT on the network. During a known outage (a gate failure in
|
|
999
|
+
// the last 60s) skip the synchronous call entirely — cached org policies are
|
|
1000
|
+
// evaluated locally and any finding rides the async spool. Otherwise keep the
|
|
1001
|
+
// call (it records the action in the console) but bound it to ONE attempt
|
|
1002
|
+
// with a tight window; on a miss the catch below degrades to local
|
|
1003
|
+
// evaluation and the action proceeds. Enforce machines keep the full
|
|
1004
|
+
// timeout + retry: their verdict actually gates the action.
|
|
1005
|
+
if (shadow && (0, policyGateHealth_1.gateRecentlyDown)()) {
|
|
1006
|
+
dbg({ phase: 'policy_skip_gate_down', event, tool: call.toolName });
|
|
1007
|
+
const detail = 'Policy gate recently unreachable — monitor mode skipped the synchronous check.';
|
|
1008
|
+
if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
|
|
1009
|
+
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `monitor: ${detail}`, offlineEnforced: true });
|
|
1010
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1011
|
+
respond(false);
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const gateTimeoutMs = shadow ? Math.min(timeoutMs, 1500) : timeoutMs;
|
|
1016
|
+
const gateAttempts = shadow ? 1 : 2;
|
|
985
1017
|
let result;
|
|
986
1018
|
try {
|
|
987
1019
|
const resp = await fetchWithOneRetry(`${apiUrl}/api/agent-security/runtime/check-tool-call`, {
|
|
@@ -1001,7 +1033,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1001
1033
|
...(event === 'mcp' && mcpServerName(payload) ? { mcpServer: mcpServerName(payload) } : {}),
|
|
1002
1034
|
...machineMetadata(ctx.client),
|
|
1003
1035
|
}),
|
|
1004
|
-
},
|
|
1036
|
+
}, gateTimeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
|
|
1005
1037
|
if (!resp.ok) {
|
|
1006
1038
|
dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
|
|
1007
1039
|
const authRejected = resp.status === 401 || resp.status === 403;
|
|
@@ -1131,6 +1163,19 @@ async function waitForApproval(input) {
|
|
|
1131
1163
|
}
|
|
1132
1164
|
async function enforceShieldText(ctx) {
|
|
1133
1165
|
const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond } = ctx;
|
|
1166
|
+
// MONITOR CONTRACT (latency half — same rule as enforceActionPolicy): a
|
|
1167
|
+
// monitor/shadow machine never makes the developer WAIT on the network.
|
|
1168
|
+
// Known outage => skip the synchronous Shield call (local deterministic
|
|
1169
|
+
// rules already ran; reporting rides the async spool). Otherwise one tight
|
|
1170
|
+
// attempt; a miss degrades to allow via the catch below.
|
|
1171
|
+
if (shadow && (0, policyGateHealth_1.gateRecentlyDown)()) {
|
|
1172
|
+
dbg({ phase: 'shield_skip_gate_down', event });
|
|
1173
|
+
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: 'prompt', reason: 'monitor: Shield gate recently unreachable — skipped the synchronous scan.', offlineEnforced: true });
|
|
1174
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1175
|
+
respond(false);
|
|
1176
|
+
}
|
|
1177
|
+
const gateTimeoutMs = shadow ? Math.min(timeoutMs, 1500) : timeoutMs;
|
|
1178
|
+
const gateAttempts = shadow ? 1 : 2;
|
|
1134
1179
|
try {
|
|
1135
1180
|
const headers = {
|
|
1136
1181
|
'Content-Type': 'application/json',
|
|
@@ -1145,7 +1190,7 @@ async function enforceShieldText(ctx) {
|
|
|
1145
1190
|
method: 'POST',
|
|
1146
1191
|
headers,
|
|
1147
1192
|
body: JSON.stringify({ message: text }),
|
|
1148
|
-
},
|
|
1193
|
+
}, gateTimeoutMs, (attempt, error) => dbg({ phase: 'shield_retry', event, attempt, error }), gateAttempts);
|
|
1149
1194
|
if (!resp.ok) {
|
|
1150
1195
|
respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, 'prompt');
|
|
1151
1196
|
return;
|
|
@@ -619,7 +619,7 @@ class AgentGuardApi {
|
|
|
619
619
|
constructor(config) {
|
|
620
620
|
this.config = config;
|
|
621
621
|
}
|
|
622
|
-
async checkToolCall(input) {
|
|
622
|
+
async checkToolCall(input, timeoutMs) {
|
|
623
623
|
const result = await this.post('/api/agent-security/runtime/check-tool-call', {
|
|
624
624
|
shieldId: this.config.shieldId,
|
|
625
625
|
agentName: this.config.agentName,
|
|
@@ -633,7 +633,7 @@ class AgentGuardApi {
|
|
|
633
633
|
userObjective: this.config.userObjective,
|
|
634
634
|
authority: this.config.authority,
|
|
635
635
|
source: 'runtime_sdk',
|
|
636
|
-
});
|
|
636
|
+
}, timeoutMs);
|
|
637
637
|
if (!result.success || !result.data) {
|
|
638
638
|
if (result.status === 401 || result.status === 403)
|
|
639
639
|
throw new CredentialsRejectedError(result.status);
|
|
@@ -975,7 +975,12 @@ class McpGatewayServer {
|
|
|
975
975
|
}
|
|
976
976
|
let preflight;
|
|
977
977
|
try {
|
|
978
|
-
|
|
978
|
+
// MONITOR CONTRACT (latency half): a monitor/no-signal machine must not
|
|
979
|
+
// make the developer WAIT on the network. Keep the synchronous call (it
|
|
980
|
+
// records the action in the console) but bound it tightly — on timeout
|
|
981
|
+
// the catch below degrades to cached-policy evaluation and the call
|
|
982
|
+
// proceeds. Enforce machines keep the full window: their verdict gates.
|
|
983
|
+
preflight = await this.api.checkToolCall({ toolName, operation, toolArgs }, reportOnlyMode ? 1_500 : undefined);
|
|
979
984
|
}
|
|
980
985
|
catch (err) {
|
|
981
986
|
// Policy service unreachable. Local deterministic rules (above) already
|
|
@@ -1029,6 +1034,26 @@ class McpGatewayServer {
|
|
|
1029
1034
|
}
|
|
1030
1035
|
}
|
|
1031
1036
|
operation = preflight.operation;
|
|
1037
|
+
if (!preflight.allowed && reportOnlyMode) {
|
|
1038
|
+
// MONITOR CONTRACT (online mirror of the offline gate above): the
|
|
1039
|
+
// backend's check-tool-call does NOT downgrade verdicts by shield mode,
|
|
1040
|
+
// so a monitor machine CAN receive block/approval here (e.g. an enforce
|
|
1041
|
+
// Action Policy created from discovery). A monitor machine never blocks
|
|
1042
|
+
// and never pauses for approval — record the would-verdict, let it run.
|
|
1043
|
+
const wouldReason = preflight.actionPolicy?.reason
|
|
1044
|
+
|| preflight.intentEvaluation?.reasons?.[0]
|
|
1045
|
+
|| `Tool call ${preflight.decision} by FullCourtDefense.`;
|
|
1046
|
+
(0, telemetry_1.spoolEvent)({
|
|
1047
|
+
decision: 'warn',
|
|
1048
|
+
toolName,
|
|
1049
|
+
operation,
|
|
1050
|
+
reason: `[monitor] policy would ${preflight.decision === 'approval' ? 'require approval' : preflight.decision}: ${wouldReason}`,
|
|
1051
|
+
ruleId: preflight.actionPolicy?.policyName ? `action-policy:${preflight.actionPolicy.policyName}` : undefined,
|
|
1052
|
+
});
|
|
1053
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1054
|
+
process.stderr.write(`FullCourtDefense (monitor) would ${preflight.decision === 'approval' ? 'require approval for' : 'block'} ${toolName}: ${wouldReason}\n`);
|
|
1055
|
+
preflight = { allowed: true, decision: 'allow', operation };
|
|
1056
|
+
}
|
|
1032
1057
|
if (!preflight.allowed) {
|
|
1033
1058
|
if (preflight.decision === 'approval' && this.gatewayConfig.approvalMode === 'wait') {
|
|
1034
1059
|
approvalActionId = preflight.approvalActionId;
|
|
@@ -104,9 +104,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
104
104
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
105
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
106
106
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
107
|
-
`// Execution view (aligned with the CLI's stripInertDataSegments):
|
|
108
|
-
`//
|
|
109
|
-
|
|
107
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
108
|
+
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
109
|
+
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
110
|
+
`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;}`,
|
|
110
111
|
`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;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!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
111
112
|
`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{}}`,
|
|
112
113
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
|
|
@@ -115,7 +116,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
115
116
|
`if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
|
|
116
117
|
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
117
118
|
`if(!hit)process.exit(0);`,
|
|
118
|
-
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'
|
|
119
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
119
120
|
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
120
121
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
121
122
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
@@ -485,22 +485,28 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
485
485
|
` }`,
|
|
486
486
|
`} catch { $global:FcdGuardRules = @() }`,
|
|
487
487
|
``,
|
|
488
|
-
`# Execution view (aligned with the CLI's stripInertDataSegments):
|
|
489
|
-
`#
|
|
490
|
-
`#
|
|
491
|
-
`#
|
|
492
|
-
`#
|
|
488
|
+
`# Execution view (aligned with the CLI's stripInertDataSegments): quoted`,
|
|
489
|
+
`# spans containing whitespace are prose — inert BY DEFAULT — unless in a`,
|
|
490
|
+
`# code position: interpreter -c/-Command flags, remote executors (ssh/wsl/`,
|
|
491
|
+
`# docker exec), $( ) substitution, or real backtick command substitution`,
|
|
492
|
+
`# (>=2 chars of content; a 1-char pair like \`n is a PS ESCAPE, not code).`,
|
|
493
|
+
`# Whole-line bailout on any interpreter/executor construct keeps detection`,
|
|
494
|
+
`# false-negative-safe. Single-token quoted args stay: argv (rm -rf "/").`,
|
|
493
495
|
`function global:Get-FcdExecView {`,
|
|
494
496
|
` param([string]$CommandLine)`,
|
|
495
|
-
String.raw ` if ($CommandLine -match '(?i)\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') { return $CommandLine }`,
|
|
497
|
+
String.raw ` if ($CommandLine -match '(?i)\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') { return $CommandLine }`,
|
|
496
498
|
` $out = $CommandLine`,
|
|
497
|
-
" $
|
|
499
|
+
" $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
|
|
500
|
+
" $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
|
|
498
501
|
String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
|
|
499
502
|
` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
|
|
500
503
|
String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
|
|
501
504
|
String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
|
|
502
|
-
String.raw ` $
|
|
503
|
-
|
|
505
|
+
String.raw ` $codeCtx = '(?i)(?:(?:^|[;&|(]|\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)$'`,
|
|
506
|
+
String.raw ` $quotedRe = '"((?:[^"\\]|\\.)*)"|''((?:[^''\\]|\\.)*)'''`,
|
|
507
|
+
` $src = $out`,
|
|
508
|
+
String.raw ` $spanEval = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $body = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value }; if ($body -notmatch '\s') { return $m.Value }; $start = [Math]::Max(0, $m.Index - 80); if ($src.Substring($start, $m.Index - $start) -match $codeCtx) { return $m.Value }; if ($m.Groups[1].Success -and $body -match $interp) { return $m.Value }; return ' fcd_inert_data ' }`,
|
|
509
|
+
` $out = [regex]::Replace($src, $quotedRe, $spanEval)`,
|
|
504
510
|
` return $out`,
|
|
505
511
|
`}`,
|
|
506
512
|
``,
|
|
@@ -574,7 +580,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
574
580
|
` if ($global:FcdGuardMode -eq 'monitor') {`,
|
|
575
581
|
` Write-Host ''`,
|
|
576
582
|
` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
|
|
577
|
-
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision '
|
|
583
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
|
|
578
584
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
579
585
|
` return`,
|
|
580
586
|
` }`,
|
|
@@ -29,5 +29,13 @@ export interface GateHealth {
|
|
|
29
29
|
export declare function recordGateSuccess(): void;
|
|
30
30
|
/** Record one failed gate round-trip and return the updated streak. */
|
|
31
31
|
export declare function recordGateFailure(now?: Date): GateHealth;
|
|
32
|
+
/**
|
|
33
|
+
* True when the gate failed very recently. Monitor/shadow surfaces use this to
|
|
34
|
+
* skip the synchronous policy call entirely during an outage (reporting rides
|
|
35
|
+
* the async spool instead), so a dead/hanging backend costs a monitor machine
|
|
36
|
+
* ZERO added latency after the first bounded miss. The 60s window guarantees
|
|
37
|
+
* an automatic retry, which resets the streak via recordGateSuccess().
|
|
38
|
+
*/
|
|
39
|
+
export declare function gateRecentlyDown(now?: Date): boolean;
|
|
32
40
|
/** Fail closed only for persistent outages, never for a single blip. */
|
|
33
41
|
export declare function shouldFailClosed(health: GateHealth, now?: Date): boolean;
|
package/dist/policyGateHealth.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.FAIL_CLOSED_AFTER_MS = exports.FAIL_CLOSED_AFTER_FAILURES = void 0;
|
|
37
37
|
exports.recordGateSuccess = recordGateSuccess;
|
|
38
38
|
exports.recordGateFailure = recordGateFailure;
|
|
39
|
+
exports.gateRecentlyDown = gateRecentlyDown;
|
|
39
40
|
exports.shouldFailClosed = shouldFailClosed;
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
41
42
|
const os = __importStar(require("os"));
|
|
@@ -104,6 +105,19 @@ function recordGateFailure(now = new Date()) {
|
|
|
104
105
|
catch { /* best-effort */ }
|
|
105
106
|
return next;
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* True when the gate failed very recently. Monitor/shadow surfaces use this to
|
|
110
|
+
* skip the synchronous policy call entirely during an outage (reporting rides
|
|
111
|
+
* the async spool instead), so a dead/hanging backend costs a monitor machine
|
|
112
|
+
* ZERO added latency after the first bounded miss. The 60s window guarantees
|
|
113
|
+
* an automatic retry, which resets the streak via recordGateSuccess().
|
|
114
|
+
*/
|
|
115
|
+
function gateRecentlyDown(now = new Date()) {
|
|
116
|
+
const health = readHealth();
|
|
117
|
+
return health.consecutiveFailures >= 1
|
|
118
|
+
&& health.lastFailureAt !== undefined
|
|
119
|
+
&& now.getTime() - Date.parse(health.lastFailureAt) < 60_000;
|
|
120
|
+
}
|
|
107
121
|
/** Fail closed only for persistent outages, never for a single blip. */
|
|
108
122
|
function shouldFailClosed(health, now = new Date()) {
|
|
109
123
|
if (health.consecutiveFailures >= exports.FAIL_CLOSED_AFTER_FAILURES)
|
package/dist/version.json
CHANGED