fullcourtdefense-cli 1.25.4 → 1.25.6
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 +19 -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 +17 -4
- package/dist/commands/shellGuard.d.ts +16 -0
- package/dist/commands/shellGuard.js +69 -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,27 @@ function batQuote(value) {
|
|
|
112
112
|
function buildGuardJs(nodePath, cliEntry) {
|
|
113
113
|
return [
|
|
114
114
|
`'use strict';`,
|
|
115
|
-
|
|
115
|
+
`// ${(0, shellGuard_1.guardBuildStamp)()}`,
|
|
116
|
+
`const fs=require('fs');const os=require('os');const path=require('path');const{spawn,spawnSync}=require('child_process');const crypto=require('crypto');`,
|
|
116
117
|
`const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
|
|
117
118
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
118
119
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
119
120
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
120
121
|
`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
|
-
|
|
122
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
123
|
+
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
124
|
+
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
125
|
+
`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
126
|
`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
|
-
|
|
127
|
+
`// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
|
|
128
|
+
`// made the user's terminal wait for a full CLI process INCLUDING the network`,
|
|
129
|
+
`// flush after every finding. Monitor mode must never add synchronous latency.`,
|
|
130
|
+
`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
131
|
`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
132
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
128
133
|
`const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
129
134
|
`if(!hit)delegate(args);`,
|
|
130
|
-
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'
|
|
135
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
131
136
|
`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
137
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
133
138
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
@@ -142,6 +147,7 @@ function buildAutorunBat(nodePath) {
|
|
|
142
147
|
return [
|
|
143
148
|
'@echo off',
|
|
144
149
|
'REM FullCourtDefense cmd.exe guard (installed by fullcourtdefense-cli).',
|
|
150
|
+
`REM ${(0, shellGuard_1.guardBuildStamp)()}`,
|
|
145
151
|
'REM Disable for one session: set FCD_CMD_GUARD=off',
|
|
146
152
|
'REM Remove permanently: fullcourtdefense uninstall-cmd-guard',
|
|
147
153
|
'if defined FCD_CMD_AUTORUN_DONE goto :eof',
|
|
@@ -271,6 +277,13 @@ function refreshCmdGuardRules() {
|
|
|
271
277
|
if (!isCmdGuardInstalled())
|
|
272
278
|
return;
|
|
273
279
|
(0, shellGuard_1.writeShellGuardRules)();
|
|
280
|
+
// Self-heal after CLI updates: the planted checker/autorun carry the
|
|
281
|
+
// detection logic of the build that wrote them — rewrite when stale.
|
|
282
|
+
if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
|
|
283
|
+
const nodePath = process.execPath;
|
|
284
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, process.argv[1] || ''));
|
|
285
|
+
(0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
|
|
286
|
+
}
|
|
274
287
|
}
|
|
275
288
|
catch { /* best-effort */ }
|
|
276
289
|
}
|
|
@@ -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;
|
|
@@ -97,6 +97,7 @@ function shSingleQuote(value) {
|
|
|
97
97
|
function buildGuardJs(nodePath, cliEntry) {
|
|
98
98
|
return [
|
|
99
99
|
`'use strict';`,
|
|
100
|
+
`// ${(0, shellGuard_1.guardBuildStamp)()}`,
|
|
100
101
|
`const fs=require('fs');const crypto=require('crypto');const{spawn}=require('child_process');`,
|
|
101
102
|
`const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
|
|
102
103
|
`const SPOOL_PATH=${JSON.stringify(SPOOL_PATH)};`,
|
|
@@ -104,9 +105,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
104
105
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
106
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
106
107
|
`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
|
-
|
|
108
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
109
|
+
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
110
|
+
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
111
|
+
`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
112
|
`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
113
|
`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
114
|
`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 +117,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
115
117
|
`if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
|
|
116
118
|
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
117
119
|
`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,'
|
|
120
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
119
121
|
`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
122
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
121
123
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
@@ -127,6 +129,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
127
129
|
function buildGuardZsh(nodePath) {
|
|
128
130
|
return [
|
|
129
131
|
`# FullCourtDefense interactive shell guard for zsh (installed by fullcourtdefense-cli).`,
|
|
132
|
+
`# ${(0, shellGuard_1.guardBuildStamp)()}`,
|
|
130
133
|
`# Disable for one session: export FCD_SHELL_GUARD=off`,
|
|
131
134
|
`# Remove permanently: fullcourtdefense uninstall-shell-guard`,
|
|
132
135
|
``,
|
|
@@ -173,6 +176,7 @@ function buildGuardZsh(nodePath) {
|
|
|
173
176
|
function buildGuardBash(nodePath) {
|
|
174
177
|
return [
|
|
175
178
|
`# FullCourtDefense interactive shell guard for bash (installed by fullcourtdefense-cli).`,
|
|
179
|
+
`# ${(0, shellGuard_1.guardBuildStamp)()}`,
|
|
176
180
|
`# Disable for one session: export FCD_SHELL_GUARD=off`,
|
|
177
181
|
`# Remove permanently: fullcourtdefense uninstall-shell-guard`,
|
|
178
182
|
``,
|
|
@@ -392,6 +396,15 @@ function refreshPosixShellGuardRules() {
|
|
|
392
396
|
if (!isPosixShellGuardInstalled())
|
|
393
397
|
return;
|
|
394
398
|
(0, shellGuard_1.writeShellGuardRules)();
|
|
399
|
+
// Self-heal after CLI updates: planted guard scripts carry the detection
|
|
400
|
+
// logic of the build that wrote them — rewrite when the stamp is stale.
|
|
401
|
+
if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
|
|
402
|
+
const nodePath = process.execPath;
|
|
403
|
+
const cliEntry = process.argv[1] || '';
|
|
404
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 0o600);
|
|
405
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_ZSH_PATH, buildGuardZsh(nodePath), 0o600);
|
|
406
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_BASH_PATH, buildGuardBash(nodePath), 0o600);
|
|
407
|
+
}
|
|
395
408
|
}
|
|
396
409
|
catch { /* best-effort */ }
|
|
397
410
|
}
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build stamp — the CLI version that generated a planted guard artifact.
|
|
3
|
+
*
|
|
4
|
+
* The profile/rc/autorun guard scripts are COPIES of CLI detection logic
|
|
5
|
+
* written to disk at install time. Without a stamp, a fleet CLI self-update
|
|
6
|
+
* ships new detection logic but every machine keeps running the old planted
|
|
7
|
+
* script forever (rules JSON refreshes carry rules, never logic). Each
|
|
8
|
+
* builder embeds this stamp in a comment; the telemetry-cycle refresh
|
|
9
|
+
* rewrites any artifact whose stamp differs from the running build.
|
|
10
|
+
*/
|
|
11
|
+
export declare const GUARD_BUILD_MARKER = "fcd-guard-build:";
|
|
12
|
+
export declare function guardBuildStamp(): string;
|
|
13
|
+
/** True when a planted guard artifact was generated by a DIFFERENT CLI build. */
|
|
14
|
+
export declare function guardArtifactStale(filePath: string): boolean;
|
|
15
|
+
/** Atomic write — a torn guard script would break every new shell that sources it. */
|
|
16
|
+
export declare function writeGuardArtifact(filePath: string, content: string, mode?: number): void;
|
|
1
17
|
/** Blast-severity levels (shellfirm-style). Drives per-severity console policy. */
|
|
2
18
|
export type ShellGuardSeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
3
19
|
export interface ShellGuardRule {
|
|
@@ -33,6 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.GUARD_BUILD_MARKER = void 0;
|
|
37
|
+
exports.guardBuildStamp = guardBuildStamp;
|
|
38
|
+
exports.guardArtifactStale = guardArtifactStale;
|
|
39
|
+
exports.writeGuardArtifact = writeGuardArtifact;
|
|
36
40
|
exports.writeShellGuardRules = writeShellGuardRules;
|
|
37
41
|
exports.activeShellGuardRules = activeShellGuardRules;
|
|
38
42
|
exports.evaluateShellCommand = evaluateShellCommand;
|
|
@@ -81,6 +85,48 @@ const RUNTIME_CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.js
|
|
|
81
85
|
const SNAPSHOT_CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
|
|
82
86
|
const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
|
|
83
87
|
const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
|
|
88
|
+
/**
|
|
89
|
+
* Build stamp — the CLI version that generated a planted guard artifact.
|
|
90
|
+
*
|
|
91
|
+
* The profile/rc/autorun guard scripts are COPIES of CLI detection logic
|
|
92
|
+
* written to disk at install time. Without a stamp, a fleet CLI self-update
|
|
93
|
+
* ships new detection logic but every machine keeps running the old planted
|
|
94
|
+
* script forever (rules JSON refreshes carry rules, never logic). Each
|
|
95
|
+
* builder embeds this stamp in a comment; the telemetry-cycle refresh
|
|
96
|
+
* rewrites any artifact whose stamp differs from the running build.
|
|
97
|
+
*/
|
|
98
|
+
exports.GUARD_BUILD_MARKER = 'fcd-guard-build:';
|
|
99
|
+
function guardBuildVersion() {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'version.json'), 'utf8'));
|
|
102
|
+
return parsed.version || 'unknown';
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return 'unknown';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function guardBuildStamp() {
|
|
109
|
+
return `${exports.GUARD_BUILD_MARKER} ${guardBuildVersion()}`;
|
|
110
|
+
}
|
|
111
|
+
/** True when a planted guard artifact was generated by a DIFFERENT CLI build. */
|
|
112
|
+
function guardArtifactStale(filePath) {
|
|
113
|
+
const version = guardBuildVersion();
|
|
114
|
+
// Dev builds without version.json must not churn artifacts every 30s.
|
|
115
|
+
if (version === 'unknown')
|
|
116
|
+
return false;
|
|
117
|
+
try {
|
|
118
|
+
return !fs.readFileSync(filePath, 'utf8').includes(`${exports.GUARD_BUILD_MARKER} ${version}`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Atomic write — a torn guard script would break every new shell that sources it. */
|
|
125
|
+
function writeGuardArtifact(filePath, content, mode) {
|
|
126
|
+
const tmp = `${filePath}.tmp`;
|
|
127
|
+
fs.writeFileSync(tmp, content, mode !== undefined ? { encoding: 'utf8', mode } : 'utf8');
|
|
128
|
+
fs.renameSync(tmp, filePath);
|
|
129
|
+
}
|
|
84
130
|
/** Confine every lookahead to a single command segment (never span `;` `&` `|`). */
|
|
85
131
|
const SEG = String.raw `[^;&|]*`;
|
|
86
132
|
function compileSpec(spec) {
|
|
@@ -444,6 +490,12 @@ function refreshShellGuardRules() {
|
|
|
444
490
|
if (!fs.existsSync(GUARD_PS1_PATH))
|
|
445
491
|
return;
|
|
446
492
|
writeShellGuardRules();
|
|
493
|
+
// Self-heal after CLI updates: the planted profile script carries the
|
|
494
|
+
// detection logic of the build that WROTE it. Rewrite when stale so new
|
|
495
|
+
// shells load the current guard (rules refreshes alone never carry logic).
|
|
496
|
+
if (guardArtifactStale(GUARD_PS1_PATH)) {
|
|
497
|
+
writeGuardArtifact(GUARD_PS1_PATH, buildGuardPs1(process.execPath, process.argv[1] || ''));
|
|
498
|
+
}
|
|
447
499
|
}
|
|
448
500
|
catch { /* best-effort */ }
|
|
449
501
|
}
|
|
@@ -454,6 +506,7 @@ function psQuote(value) {
|
|
|
454
506
|
function buildGuardPs1(nodePath, cliEntry) {
|
|
455
507
|
const lines = [
|
|
456
508
|
`# FullCourtDefense interactive shell guard (installed by fullcourtdefense-cli).`,
|
|
509
|
+
`# ${guardBuildStamp()}`,
|
|
457
510
|
`# Blocks dangerous commands typed in PowerShell using the org's cached rules.`,
|
|
458
511
|
`# Disable for one session: $env:FCD_SHELL_GUARD = 'off'`,
|
|
459
512
|
`# Remove permanently: fullcourtdefense uninstall-shell-guard`,
|
|
@@ -485,22 +538,28 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
485
538
|
` }`,
|
|
486
539
|
`} catch { $global:FcdGuardRules = @() }`,
|
|
487
540
|
``,
|
|
488
|
-
`# Execution view (aligned with the CLI's stripInertDataSegments):
|
|
489
|
-
`#
|
|
490
|
-
`#
|
|
491
|
-
`#
|
|
492
|
-
`#
|
|
541
|
+
`# Execution view (aligned with the CLI's stripInertDataSegments): quoted`,
|
|
542
|
+
`# spans containing whitespace are prose — inert BY DEFAULT — unless in a`,
|
|
543
|
+
`# code position: interpreter -c/-Command flags, remote executors (ssh/wsl/`,
|
|
544
|
+
`# docker exec), $( ) substitution, or real backtick command substitution`,
|
|
545
|
+
`# (>=2 chars of content; a 1-char pair like \`n is a PS ESCAPE, not code).`,
|
|
546
|
+
`# Whole-line bailout on any interpreter/executor construct keeps detection`,
|
|
547
|
+
`# false-negative-safe. Single-token quoted args stay: argv (rm -rf "/").`,
|
|
493
548
|
`function global:Get-FcdExecView {`,
|
|
494
549
|
` 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 }`,
|
|
550
|
+
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
551
|
` $out = $CommandLine`,
|
|
497
|
-
" $
|
|
552
|
+
" $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
|
|
553
|
+
" $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
|
|
498
554
|
String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
|
|
499
555
|
` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
|
|
500
556
|
String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
|
|
501
557
|
String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
|
|
502
|
-
String.raw ` $
|
|
503
|
-
|
|
558
|
+
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)$'`,
|
|
559
|
+
String.raw ` $quotedRe = '"((?:[^"\\]|\\.)*)"|''((?:[^''\\]|\\.)*)'''`,
|
|
560
|
+
` $src = $out`,
|
|
561
|
+
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 ' }`,
|
|
562
|
+
` $out = [regex]::Replace($src, $quotedRe, $spanEval)`,
|
|
504
563
|
` return $out`,
|
|
505
564
|
`}`,
|
|
506
565
|
``,
|
|
@@ -574,7 +633,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
574
633
|
` if ($global:FcdGuardMode -eq 'monitor') {`,
|
|
575
634
|
` Write-Host ''`,
|
|
576
635
|
` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
|
|
577
|
-
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision '
|
|
636
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
|
|
578
637
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
579
638
|
` return`,
|
|
580
639
|
` }`,
|
|
@@ -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