fullcourtdefense-cli 1.25.3 → 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 +11 -4
- package/dist/commands/deterministicGuard.d.ts +1 -0
- package/dist/commands/deterministicGuard.js +77 -6
- package/dist/commands/hook.js +57 -12
- package/dist/commands/mcpGateway.js +28 -3
- package/dist/commands/posixShellGuard.js +6 -2
- package/dist/commands/shellGuard.js +36 -2
- 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,19 +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
|
-
|
|
122
|
-
|
|
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;}`,
|
|
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;}`,
|
|
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{}}`,
|
|
123
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);}`,
|
|
124
131
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
125
132
|
`const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
126
133
|
`if(!hit)delegate(args);`,
|
|
127
|
-
`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);}`,
|
|
128
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);}`,
|
|
129
136
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
130
137
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
@@ -55,6 +55,7 @@ export interface LocalSafetyScanOptions {
|
|
|
55
55
|
*/
|
|
56
56
|
trustedScriptPaths?: string[];
|
|
57
57
|
}
|
|
58
|
+
export declare function stripInertDataSegments(text: string): string;
|
|
58
59
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
59
60
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
60
61
|
export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.stripInertDataSegments = stripInertDataSegments;
|
|
36
37
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
37
38
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
38
39
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
@@ -615,10 +616,12 @@ function credentialCommandReason(value) {
|
|
|
615
616
|
function destructiveCommandReason(value) {
|
|
616
617
|
return matchCommand(value, (text, lower) => {
|
|
617
618
|
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
|
|
618
|
-
// may be the root `/`, the root wildcard `/*`, or a bare `*`.
|
|
619
|
-
|
|
619
|
+
// may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
|
|
620
|
+
// include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
|
|
621
|
+
// outer command — is caught too.
|
|
622
|
+
if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
|
|
620
623
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
621
|
-
if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
|
|
624
|
+
if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
|
|
622
625
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
623
626
|
// Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
|
|
624
627
|
// is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
|
|
@@ -779,13 +782,81 @@ function isSensitivePathContext(toolName, candidate) {
|
|
|
779
782
|
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
780
783
|
return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
|
|
781
784
|
}
|
|
782
|
-
|
|
783
|
-
|
|
785
|
+
// ---- execution view: message/document DATA inside a command is inert ---------
|
|
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):
|
|
796
|
+
// 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
|
|
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 —
|
|
809
|
+
// a token pasted into a commit message still leaks.
|
|
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,}`/;
|
|
814
|
+
const INERT_DATA = ' fcd_inert_data ';
|
|
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;
|
|
818
|
+
function stripInertDataSegments(text) {
|
|
819
|
+
if (DATA_TO_INTERPRETER.test(text))
|
|
820
|
+
return text;
|
|
821
|
+
let out = text;
|
|
822
|
+
// PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
|
|
823
|
+
out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
|
|
824
|
+
out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
|
|
825
|
+
// POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
|
|
826
|
+
out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
|
|
827
|
+
out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
|
|
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;
|
|
841
|
+
});
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
function scanTextValue(toolName, rawValue, options) {
|
|
845
|
+
// Honeypot decoys and org custom patterns scan the RAW value: a decoy path
|
|
846
|
+
// even inside message data is exfiltration staging, and custom rules define
|
|
847
|
+
// their own scope.
|
|
848
|
+
const honeypot = honeypotTouch(rawValue, options);
|
|
784
849
|
if (honeypot)
|
|
785
850
|
return honeypot;
|
|
786
|
-
const custom = customBlock(
|
|
851
|
+
const custom = customBlock(rawValue, options);
|
|
787
852
|
if (custom)
|
|
788
853
|
return custom;
|
|
854
|
+
// Every rule below asks "can this text ACT?" — so it sees the execution
|
|
855
|
+
// view: quoted message/document data (commit -m, issue --body, here-strings,
|
|
856
|
+
// heredocs) is inert unless it can flow into an interpreter (see
|
|
857
|
+
// stripInertDataSegments invariants). This is the generic FP guard for
|
|
858
|
+
// "wrote ABOUT a dangerous command" vs "ran one".
|
|
859
|
+
const value = stripInertDataSegments(rawValue);
|
|
789
860
|
// NOTE: a disabled item must FALL THROUGH to the later rules, not end the
|
|
790
861
|
// scan — otherwise turning off (or gating) a path rule would mask a real
|
|
791
862
|
// reverse shell / destructive command in the same value.
|
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,7 +104,11 @@ 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
|
-
|
|
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;}`,
|
|
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;}`,
|
|
108
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{}}`,
|
|
109
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{}}`,
|
|
110
114
|
`let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
|
|
@@ -112,7 +116,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
112
116
|
`if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
|
|
113
117
|
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
114
118
|
`if(!hit)process.exit(0);`,
|
|
115
|
-
`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);}`,
|
|
116
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);}`,
|
|
117
121
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
118
122
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
@@ -485,14 +485,48 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
485
485
|
` }`,
|
|
486
486
|
`} catch { $global:FcdGuardRules = @() }`,
|
|
487
487
|
``,
|
|
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 "/").`,
|
|
495
|
+
`function global:Get-FcdExecView {`,
|
|
496
|
+
` param([string]$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 }`,
|
|
498
|
+
` $out = $CommandLine`,
|
|
499
|
+
" $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
|
|
500
|
+
" $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
|
|
501
|
+
String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
|
|
502
|
+
` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
|
|
503
|
+
String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
|
|
504
|
+
String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
|
|
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)`,
|
|
510
|
+
` return $out`,
|
|
511
|
+
`}`,
|
|
512
|
+
``,
|
|
488
513
|
`function global:Test-FcdShellGuard {`,
|
|
489
514
|
` param([string]$CommandLine)`,
|
|
490
515
|
` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
|
|
491
516
|
` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
|
|
517
|
+
` $execView = $CommandLine`,
|
|
518
|
+
` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
|
|
519
|
+
` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
|
|
492
520
|
` $warnHit = $null`,
|
|
493
521
|
` foreach ($rule in $global:FcdGuardRules) {`,
|
|
494
522
|
` try {`,
|
|
495
|
-
`
|
|
523
|
+
` # Built-in rules ask "can this text ACT?" — they see the execution`,
|
|
524
|
+
` # view, so a commit message quoting a dangerous command is inert.`,
|
|
525
|
+
` # Console custom rules define their own scope — they see the raw line.`,
|
|
526
|
+
` $isCustom = ([string]$rule.Source -eq 'custom')`,
|
|
527
|
+
` $target = if ($isCustom) { $CommandLine } else { $execView }`,
|
|
528
|
+
` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
|
|
529
|
+
` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
|
|
496
530
|
` # A block rule always outranks a warn rule matching the same line.`,
|
|
497
531
|
` if ($rule.Action -ne 'warn') { return $rule }`,
|
|
498
532
|
` if ($null -eq $warnHit) { $warnHit = $rule }`,
|
|
@@ -546,7 +580,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
546
580
|
` if ($global:FcdGuardMode -eq 'monitor') {`,
|
|
547
581
|
` Write-Host ''`,
|
|
548
582
|
` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
|
|
549
|
-
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision '
|
|
583
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
|
|
550
584
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
551
585
|
` return`,
|
|
552
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