fullcourtdefense-cli 1.24.2 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/commands/agentCi.js +5 -5
- package/dist/commands/cmdGuard.js +1 -1
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/deterministicGuard.js +289 -108
- package/dist/commands/hook.js +7 -0
- package/dist/commands/mcpGateway.d.ts +2 -3
- package/dist/commands/mcpGateway.js +77 -34
- package/dist/commands/onboard.js +52 -5
- package/dist/commands/posixShellGuard.js +1 -1
- package/dist/commands/shellGuard.js +7 -4
- package/dist/commands/taintLedger.d.ts +7 -1
- package/dist/commands/taintLedger.js +10 -2
- package/dist/output.js +2 -2
- package/dist/runtimeConfig.js +6 -1
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ fullcourtdefense onboard --token <fleet-enrollment-token>
|
|
|
24
24
|
# or: set FCD_ENROLL_TOKEN and run `fullcourtdefense onboard`
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Then restart your AI clients (Cursor, Claude, VS Code, ...) so they pick up the wrapped configs. The machine reports to your org's AI Fleet in monitor
|
|
27
|
+
Then restart your AI clients (Cursor, Claude, VS Code, ...) so they pick up the wrapped configs. The machine reports to your org's AI Fleet in **monitor mode** first — every agent action is recorded and analyzed (including what *would* have been blocked), but nothing breaks the developer's workflow while the fleet baselines the machine. An admin promotes the machine to **blocking mode** from the fleet console once the baseline looks clean.
|
|
28
28
|
|
|
29
29
|
Onboarding is a resumable local transaction. Its non-secret status journal is
|
|
30
30
|
stored at `~/.fullcourtdefense/onboarding.json`; use `--resume` after an
|
|
@@ -266,7 +266,7 @@ fullcourtdefense scan --local
|
|
|
266
266
|
Expected `doctor` output:
|
|
267
267
|
|
|
268
268
|
```text
|
|
269
|
-
|
|
269
|
+
FullCourtDefense outbound diagnostic
|
|
270
270
|
Target: https://api.fullcourtdefense.ai
|
|
271
271
|
|
|
272
272
|
PASS outbound HTTPS open (200, 487ms)
|
|
@@ -368,7 +368,7 @@ fullcourtdefense scan --local --type mcp --mcp-url "https://mcp.internal.company
|
|
|
368
368
|
|
|
369
369
|
### MCP Gateway Install Examples
|
|
370
370
|
|
|
371
|
-
Use the MCP gateway when you want Cursor, Claude Code, or Claude Desktop tool calls checked against
|
|
371
|
+
Use the MCP gateway when you want Cursor, Claude Code, or Claude Desktop tool calls checked against FullCourtDefense runtime/action policies before they reach the real MCP server.
|
|
372
372
|
|
|
373
373
|
Cursor project install:
|
|
374
374
|
|
|
@@ -712,7 +712,7 @@ For GitHub Actions, use [botguardai/security-scan](https://github.com/botguardai
|
|
|
712
712
|
|
|
713
713
|
## Related
|
|
714
714
|
|
|
715
|
-
- [
|
|
715
|
+
- [FullCourtDefense](https://fullcourtdefense.ai) — Automated red-teaming & real-time firewall for AI agents
|
|
716
716
|
- [GitHub Action](https://github.com/botguardai/security-scan) — CI/CD security scanning
|
|
717
717
|
- [Attack Library](https://github.com/botguardai/llm-attacks) — 229+ open-source LLM attack templates
|
|
718
718
|
|
package/dist/commands/agentCi.js
CHANGED
|
@@ -213,7 +213,7 @@ function agentFileRemediation(tags) {
|
|
|
213
213
|
if (tags.includes('broad_filesystem'))
|
|
214
214
|
fixes.push('limit file access to approved project paths and deny secrets/home/system paths');
|
|
215
215
|
if (tags.includes('destructive_actions'))
|
|
216
|
-
fixes.push('require
|
|
216
|
+
fixes.push('require a FullCourtDefense Action Policy for delete, payment, transfer, email, Slack, or other side-effecting actions');
|
|
217
217
|
if (tags.includes('weak_guardrails'))
|
|
218
218
|
fixes.push('delete bypass language such as always allow, never block, disable security, or skip validation');
|
|
219
219
|
if (tags.includes('embedded_secret_ref'))
|
|
@@ -258,7 +258,7 @@ function evaluateAgentGate(args) {
|
|
|
258
258
|
title: `Missing approval policy evidence for ${server.name}`,
|
|
259
259
|
detail: `Risky tool signals need approval/block policy before merge: ${missingControls.join(', ')}.`,
|
|
260
260
|
filePath: server.filePath,
|
|
261
|
-
remediation: 'Add
|
|
261
|
+
remediation: 'Add a FullCourtDefense action policy requiring approval/block for this tool class, then route through the gateway.',
|
|
262
262
|
}));
|
|
263
263
|
}
|
|
264
264
|
}
|
|
@@ -399,16 +399,16 @@ async function agentCiCommand(args, config) {
|
|
|
399
399
|
ci: buildCiMetadata(),
|
|
400
400
|
},
|
|
401
401
|
}, config);
|
|
402
|
-
console.error('
|
|
402
|
+
console.error('FullCourtDefense evidence upload: uploaded to AI Fleet / discovery posture inventory.');
|
|
403
403
|
}
|
|
404
404
|
catch (error) {
|
|
405
|
-
console.error(`
|
|
405
|
+
console.error(`FullCourtDefense evidence upload: failed (${error instanceof Error ? error.message : String(error)})`);
|
|
406
406
|
if (args.requireUpload === 'true')
|
|
407
407
|
process.exit(1);
|
|
408
408
|
}
|
|
409
409
|
}
|
|
410
410
|
else {
|
|
411
|
-
console.error('
|
|
411
|
+
console.error('FullCourtDefense evidence upload: missing credentials. Run fullcourtdefense login or pass FULLCOURTDEFENSE_API_KEY.');
|
|
412
412
|
if (args.requireUpload === 'true')
|
|
413
413
|
process.exit(1);
|
|
414
414
|
}
|
|
@@ -117,7 +117,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
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
|
-
`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==='
|
|
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
121
|
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
122
122
|
`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)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
|
|
123
123
|
`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);}`,
|
package/dist/commands/daemon.js
CHANGED
|
@@ -472,7 +472,7 @@ async function runDaemon(args, config) {
|
|
|
472
472
|
if (!quiet) {
|
|
473
473
|
(0, notify_1.notifyOs)({
|
|
474
474
|
title: 'FullCourtDefense re-protected this machine',
|
|
475
|
-
message: 'An MCP or hook config changed; the
|
|
475
|
+
message: 'An MCP or hook config changed; the FullCourtDefense gateway was re-applied.',
|
|
476
476
|
url: (0, notify_1.consoleUrl)('/agent-security/users?view=desktop'),
|
|
477
477
|
});
|
|
478
478
|
}
|
|
@@ -851,7 +851,7 @@ async function runDaemon(args, config) {
|
|
|
851
851
|
throw new Error(`Repair completed but verification still reports: ${verification.reasons.join(', ')}`);
|
|
852
852
|
}
|
|
853
853
|
log(`Repair protection: verified (${verification.protectedMcpConfigs}/${verification.discoveredMcpConfigs} MCP configs wrapped).`);
|
|
854
|
-
resultSummary = '
|
|
854
|
+
resultSummary = 'FullCourtDefense hooks, gateways, and protection configuration were repaired and verified.';
|
|
855
855
|
}
|
|
856
856
|
else if (action.type === 'upgrade_cli') {
|
|
857
857
|
// Explicit version in the action reason wins; otherwise the org
|
|
@@ -52,7 +52,7 @@ const SECRET_PATTERNS = [
|
|
|
52
52
|
{ itemId: 'slack_token', label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/ },
|
|
53
53
|
{ itemId: 'stripe_live_key', label: 'Stripe live key', re: /\b[rs]k_live_[0-9a-zA-Z]{24,}\b/ },
|
|
54
54
|
{ itemId: 'google_api_key', label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
55
|
-
{ itemId: '
|
|
55
|
+
{ itemId: 'huggingface_token_secret', label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
|
|
56
56
|
{ itemId: 'database_url_password', label: 'database URL with password', re: /\b(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql):\/\/[^/\s'":]+:[^@\s'"]+@/i },
|
|
57
57
|
];
|
|
58
58
|
// Contextual secret detection: bare high-entropy strings (hex app secrets,
|
|
@@ -230,6 +230,160 @@ function normalizeForPath(value) {
|
|
|
230
230
|
function normalizeForCommand(value) {
|
|
231
231
|
return decodeLoosely(value).replace(/\s+/g, ' ').trim();
|
|
232
232
|
}
|
|
233
|
+
/** Strip shell quoting/escaping that carries no semantic weight but defeats a
|
|
234
|
+
* naive substring/regex scan: de"l" /s /q, "C:\", 'rm' -rf /, i`e`x. Only used
|
|
235
|
+
* to build alternate COMMAND-matching variants — never for secret/path rules,
|
|
236
|
+
* where quotes can be meaningful. */
|
|
237
|
+
function stripShellQuoting(text) {
|
|
238
|
+
return text.replace(/["'`]/g, '').replace(/\s+/g, ' ').trim();
|
|
239
|
+
}
|
|
240
|
+
/** Drop Windows executable suffixes so `gh.exe`, `del.exe`, `powershell.exe`,
|
|
241
|
+
* `foo.cmd` match the same rules as their bare command word. Scoped to real
|
|
242
|
+
* script-host extensions so it cannot maul URLs/filenames (`evil.com` is left
|
|
243
|
+
* intact). */
|
|
244
|
+
function stripExeSuffix(text) {
|
|
245
|
+
return text.replace(/\.(?:exe|cmd|bat|ps1)\b/gi, '');
|
|
246
|
+
}
|
|
247
|
+
/** cmd.exe treats `^` as an escape for the NEXT character, so `r^m -rf /` runs
|
|
248
|
+
* `rm -rf /` and `powershe^ll` runs powershell. `^^` is a literal caret —
|
|
249
|
+
* protect it before stripping so we don't delete both. Variant-only transform. */
|
|
250
|
+
function stripCmdCarets(text) {
|
|
251
|
+
return text.replace(/\^\^/g, '\u0000').replace(/\^/g, '').replace(/\u0000/g, '^');
|
|
252
|
+
}
|
|
253
|
+
/** POSIX shell token evasions (PayloadsAllTheThings "Filter Bypasses"):
|
|
254
|
+
* - `${IFS}` / `$IFS` / `${IFS%??}` whitespace substitution: `rm${IFS}-rf${IFS}/`
|
|
255
|
+
* - brace expansion word-splitting: `{rm,-rf,/}` -> `rm -rf /`
|
|
256
|
+
* - backslash escaping of ordinary chars: `r\m -rf /` -> `rm -rf /`
|
|
257
|
+
* Variant-only (raw is always kept), so a benign command whose de-obfuscated
|
|
258
|
+
* form is harmless simply yields a non-matching extra surface. */
|
|
259
|
+
function deobfuscateShellTokens(text) {
|
|
260
|
+
let out = text.replace(/\$\{IFS[^}]*\}/g, ' ').replace(/\$IFS\b/g, ' ');
|
|
261
|
+
for (let i = 0; i < 3 && /\{[^{}]*,[^{}]*\}/.test(out); i++) {
|
|
262
|
+
out = out.replace(/\{([^{}]*,[^{}]*)\}/g, (_m, body) => body.split(',').join(' '));
|
|
263
|
+
}
|
|
264
|
+
out = out.replace(/\\(?=[A-Za-z0-9])/g, '');
|
|
265
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
266
|
+
}
|
|
267
|
+
/** bash/zsh ANSI-C quoting: `$'\x72\x6d'` IS `rm`. Decode `\xHH`, `\uHHHH`,
|
|
268
|
+
* octal, and single-char escapes, splicing plain text back into the command. */
|
|
269
|
+
function decodeAnsiCQuoting(text) {
|
|
270
|
+
return text.replace(/\$'((?:[^'\\]|\\.)*)'/g, (_m, body) => body.replace(/\\(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{1,4}|[0-7]{1,3}|.)/g, (_e, esc) => {
|
|
271
|
+
if (esc[0] === 'x' || esc[0] === 'u')
|
|
272
|
+
return String.fromCharCode(parseInt(esc.slice(1), 16));
|
|
273
|
+
if (/^[0-7]/.test(esc))
|
|
274
|
+
return String.fromCharCode(parseInt(esc, 8));
|
|
275
|
+
const map = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b', '\\': '\\', "'": "'" };
|
|
276
|
+
return map[esc] ?? esc;
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
/** Decode a base64 blob to printable text, UTF-16LE (PowerShell) then UTF-8.
|
|
280
|
+
* Returns nothing for binary garbage so we never feed noise into the rules. */
|
|
281
|
+
function decodeBase64Blob(b64) {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const enc of ['utf16le', 'utf8']) {
|
|
284
|
+
try {
|
|
285
|
+
const decoded = Buffer.from(b64, 'base64').toString(enc);
|
|
286
|
+
if (decoded && /[\x20-\x7e]/.test(decoded) && !/\u0000/.test(decoded))
|
|
287
|
+
out.push(decoded);
|
|
288
|
+
}
|
|
289
|
+
catch { /* not valid base64 for this encoding */ }
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
/** Extract hidden script payloads from one command string: PowerShell
|
|
294
|
+
* `-EncodedCommand <b64>`, `FromBase64String('<b64>')` cradles, and
|
|
295
|
+
* `echo <b64> | base64 -d | sh` pipelines. Short flags `-e`/`-ec`/`-en` are
|
|
296
|
+
* ambiguous (`docker run -e`, `node -e`) — only decoded in a real PowerShell
|
|
297
|
+
* context; `-enc` and longer are accepted anywhere. */
|
|
298
|
+
function decodeEncodedPayloads(text) {
|
|
299
|
+
const out = [];
|
|
300
|
+
const push = (b64) => { for (const d of decodeBase64Blob(b64))
|
|
301
|
+
if (out.length < 8)
|
|
302
|
+
out.push(d); };
|
|
303
|
+
const hasPwsh = /\b(?:powershell|pwsh)\b/i.test(text);
|
|
304
|
+
const psEnc = /-(e(?:c|n(?:c(?:o(?:d(?:e(?:d(?:c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?)?)?)?)?)?)?)?)\s+([A-Za-z0-9+/=]{16,})/gi;
|
|
305
|
+
let match;
|
|
306
|
+
while ((match = psEnc.exec(text)) && out.length < 8) {
|
|
307
|
+
if (match[1].toLowerCase().length < 3 && !hasPwsh)
|
|
308
|
+
continue;
|
|
309
|
+
push(match[2]);
|
|
310
|
+
}
|
|
311
|
+
const fromB64 = /frombase64string\(\s*["']([A-Za-z0-9+/=]{16,})["']/gi;
|
|
312
|
+
while ((match = fromB64.exec(text)) && out.length < 8)
|
|
313
|
+
push(match[1]);
|
|
314
|
+
if (/\bbase64\s+(?:-d|-D|--decode)\b/.test(text) || /\bopenssl\s+(?:enc\s+.*-d|base64\s+-d)/.test(text)) {
|
|
315
|
+
// Decoder present => intentional payload; >=8 covers short commands like
|
|
316
|
+
// `rm -rf /` (12 b64 chars) that the speculative >=16 floor missed.
|
|
317
|
+
const loose = /(?:^|["'\s|;&=])([A-Za-z0-9+/]{8,}={0,2})(?=["'\s|;&]|$)/g;
|
|
318
|
+
while ((match = loose.exec(text)) && out.length < 8)
|
|
319
|
+
push(match[1]);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Obfuscation-resistant command expansion. A single-string regex scan is blind
|
|
325
|
+
* to the standard evasions attackers (and prompt-injected agents) use: base64
|
|
326
|
+
* `-EncodedCommand` / `FromBase64String` / `base64 -d` payloads (nested up to
|
|
327
|
+
* two levels), wrapper quoting (`de"l"`, `'rm' -rf`), cmd caret escapes
|
|
328
|
+
* (`r^m`), bash ANSI-C quoting (`$'\x72\x6d'`), POSIX token evasions
|
|
329
|
+
* (`${IFS}`, `{rm,-rf,/}`, `r\m`), and executable-suffix aliasing (`gh.exe`).
|
|
330
|
+
* Wrapper commands (wsl, docker exec, bash -c, ssh) need no special handling —
|
|
331
|
+
* the inner command survives as a substring of the dequoted variant.
|
|
332
|
+
*
|
|
333
|
+
* Bounded by design (decode depth 2, capped variant count, length guard).
|
|
334
|
+
* Known static limits (documented, covered by taint/approval layers instead):
|
|
335
|
+
* runtime-only values such as `%VAR%` / `$VAR` concatenation, `${PATH:0:1}`
|
|
336
|
+
* substrings, and `eval` of computed strings cannot be resolved statically.
|
|
337
|
+
*/
|
|
338
|
+
function commandVariants(value) {
|
|
339
|
+
const variants = new Set();
|
|
340
|
+
const add = (s) => { if (s && s.length <= 8000 && variants.size < 48)
|
|
341
|
+
variants.add(s); };
|
|
342
|
+
const addSurfaces = (seed) => {
|
|
343
|
+
const s = normalizeForCommand(seed);
|
|
344
|
+
add(s);
|
|
345
|
+
const dequoted = stripShellQuoting(s);
|
|
346
|
+
add(dequoted);
|
|
347
|
+
add(stripExeSuffix(s));
|
|
348
|
+
add(stripExeSuffix(dequoted));
|
|
349
|
+
const deob = deobfuscateShellTokens(s);
|
|
350
|
+
if (deob !== s) {
|
|
351
|
+
add(deob);
|
|
352
|
+
add(stripShellQuoting(deob));
|
|
353
|
+
}
|
|
354
|
+
// ANSI-C decode MUST run before token de-obfuscation: the backslash-strip in
|
|
355
|
+
// deobfuscateShellTokens would otherwise eat the `\xHH` escapes.
|
|
356
|
+
add(normalizeForCommand(stripExeSuffix(stripShellQuoting(deobfuscateShellTokens(stripCmdCarets(decodeAnsiCQuoting(s)))))));
|
|
357
|
+
};
|
|
358
|
+
const base = normalizeForCommand(value);
|
|
359
|
+
addSurfaces(base);
|
|
360
|
+
// Nested encodes: decode payloads of the base, then decode payloads of what
|
|
361
|
+
// came out (powershell -enc wrapping another -enc / FromBase64String).
|
|
362
|
+
let frontier = [base];
|
|
363
|
+
for (let depth = 0; depth < 2 && frontier.length > 0 && variants.size < 48; depth++) {
|
|
364
|
+
const next = [];
|
|
365
|
+
for (const source of frontier) {
|
|
366
|
+
for (const decoded of decodeEncodedPayloads(source)) {
|
|
367
|
+
addSurfaces(decoded);
|
|
368
|
+
next.push(normalizeForCommand(decoded));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
frontier = next;
|
|
372
|
+
}
|
|
373
|
+
return Array.from(variants);
|
|
374
|
+
}
|
|
375
|
+
/** Run a command matcher across every de-obfuscated variant, returning the
|
|
376
|
+
* first hit. Callers keep passing the RAW value — variant expansion is the
|
|
377
|
+
* single seam every command rule shares, so a new evasion is defeated in one
|
|
378
|
+
* place instead of per-rule. */
|
|
379
|
+
function matchCommand(value, test) {
|
|
380
|
+
for (const variant of commandVariants(value)) {
|
|
381
|
+
const hit = test(variant, variant.toLowerCase());
|
|
382
|
+
if (hit)
|
|
383
|
+
return hit;
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
233
387
|
function collectStrings(value, keyPath = 'args', out = [], depth = 0) {
|
|
234
388
|
if (depth > 6 || value === null || value === undefined)
|
|
235
389
|
return out;
|
|
@@ -424,121 +578,148 @@ function containsSensitiveCredentialPath(value) {
|
|
|
424
578
|
* that legitimate deploy tooling uses constantly are strict (off by default).
|
|
425
579
|
*/
|
|
426
580
|
function credentialCommandReason(value) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
581
|
+
return matchCommand(value, (_text, lower) => {
|
|
582
|
+
if (/\bgh\s+auth\s+token\b/.test(lower) || (/\bgh\s+auth\s+status\b/.test(lower) && /(?:--show-token|\s-t\b)/.test(lower))) {
|
|
583
|
+
return { itemId: 'gh_auth_token_cmd', reason: 'GitHub CLI token print (gh auth token)' };
|
|
584
|
+
}
|
|
585
|
+
if (/\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b/.test(lower)) {
|
|
586
|
+
return { itemId: 'git_credential_fill', reason: 'git credential helper dump' };
|
|
587
|
+
}
|
|
588
|
+
if (/\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b/.test(lower) || /\bsecurity\s+dump-keychain\b/.test(lower)) {
|
|
589
|
+
return { itemId: 'macos_keychain_dump', reason: 'macOS keychain password dump' };
|
|
590
|
+
}
|
|
591
|
+
if (/\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b/.test(lower)) {
|
|
592
|
+
return { itemId: 'kubectl_config_raw', reason: 'raw kubeconfig dump (kubectl config view --raw)' };
|
|
593
|
+
}
|
|
594
|
+
if (/\baws\s+configure\s+export-credentials\b/.test(lower) || /\baws\s+configure\s+get\b[^\n;|&]*secret/.test(lower)) {
|
|
595
|
+
return { itemId: 'aws_export_credentials', reason: 'AWS secret access key export' };
|
|
596
|
+
}
|
|
597
|
+
if (/\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b/.test(lower)) {
|
|
598
|
+
return { itemId: 'gcloud_access_token_cmd', reason: 'gcloud access-token print' };
|
|
599
|
+
}
|
|
600
|
+
if (/\bgcloud\s+secrets\s+versions\s+access\b/.test(lower)) {
|
|
601
|
+
return { itemId: 'gcloud_secret_access_cmd', reason: 'GCP Secret Manager read' };
|
|
602
|
+
}
|
|
603
|
+
if (/\baz\s+account\s+get-access-token\b/.test(lower)) {
|
|
604
|
+
return { itemId: 'az_access_token_cmd', reason: 'Azure access-token print' };
|
|
605
|
+
}
|
|
606
|
+
if (/\baws\s+secretsmanager\s+get-secret-value\b/.test(lower) || /\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b/.test(lower)) {
|
|
607
|
+
return { itemId: 'aws_secret_fetch_cmd', reason: 'AWS secret fetch' };
|
|
608
|
+
}
|
|
609
|
+
if (/\bvault\s+(?:kv\s+get|read)\b/.test(lower)) {
|
|
610
|
+
return { itemId: 'vault_read_cmd', reason: 'Vault secret read' };
|
|
611
|
+
}
|
|
612
|
+
return undefined;
|
|
613
|
+
});
|
|
459
614
|
}
|
|
460
615
|
function destructiveCommandReason(value) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
616
|
+
return matchCommand(value, (text, lower) => {
|
|
617
|
+
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
|
|
618
|
+
// may be the root `/`, the root wildcard `/*`, or a bare `*`.
|
|
619
|
+
if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
|
|
620
|
+
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))
|
|
622
|
+
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
623
|
+
// Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
|
|
624
|
+
// is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
|
|
625
|
+
// handles `"C:\"`. rd/rmdir are the same wipe under a different verb — the
|
|
626
|
+
// old rule only knew del/erase in a fixed flag order.
|
|
627
|
+
if (/\b(?:del|erase|rd|rmdir)\b(?=[^\n;|&]*\s\/[a-z]*s\b)(?=[^\n;|&]*\s\/[a-z]*q\b)[^\n;|&]*\s[a-z]:(?:\\|"|\s|$)/i.test(text)) {
|
|
628
|
+
return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
|
|
629
|
+
}
|
|
630
|
+
// Drive-ROOT Remove-Item only: the bare drive (C:\) must be an argument of
|
|
631
|
+
// the SAME Remove-Item command (no ;|& between them). The previous loose
|
|
632
|
+
// drive-letter match flagged ANY text containing Remove-Item -Recurse
|
|
633
|
+
// -Force plus an unrelated "x: " pattern — e.g. our own installer's
|
|
634
|
+
// legitimate cleanup script, blocking a customer's git command that merely
|
|
635
|
+
// referenced it.
|
|
636
|
+
if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
|
|
637
|
+
&& /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
|
|
638
|
+
return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
|
|
639
|
+
}
|
|
640
|
+
if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
|
|
641
|
+
return { itemId: 'fork_bomb', reason: 'fork bomb' };
|
|
642
|
+
if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
|
|
643
|
+
return { itemId: 'mkfs_device', reason: 'filesystem format command' };
|
|
644
|
+
if (/\bdd\b(?=.*\bof=\/dev\/(?:sd|xvd|hd|nvme|disk))/i.test(text))
|
|
645
|
+
return { itemId: 'dd_disk_overwrite', reason: 'raw disk overwrite command' };
|
|
646
|
+
if (/\bchmod\s+-r\s+777\s+\/(?:\s|$)/i.test(text))
|
|
647
|
+
return { itemId: 'chmod_root_777', reason: 'recursive permission change on root' };
|
|
648
|
+
if (/\bgit\s+(?:reset\s+--hard|clean\s+-f(?:d|x|dx)?\b)/i.test(text))
|
|
649
|
+
return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
|
|
650
|
+
return undefined;
|
|
651
|
+
});
|
|
490
652
|
}
|
|
491
653
|
function reverseShellReason(value) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
654
|
+
return matchCommand(value, (text) => {
|
|
655
|
+
if (/\bbash\s+-i\b.*\/dev\/tcp\//i.test(text))
|
|
656
|
+
return { itemId: 'bash_dev_tcp', reason: 'bash reverse shell' };
|
|
657
|
+
if (/\b(?:nc|ncat|netcat)\b.*\s-e\s+(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
|
|
658
|
+
return { itemId: 'netcat_exec', reason: 'netcat reverse shell' };
|
|
659
|
+
if (/\bsocat\b.*\bexec:(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
|
|
660
|
+
return { itemId: 'socat_exec', reason: 'socat reverse shell' };
|
|
661
|
+
if (/\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash)\b/i.test(text))
|
|
662
|
+
return { itemId: 'curl_pipe_shell', reason: 'remote script piped to shell' };
|
|
663
|
+
// PowerShell download-and-execute cradle: Invoke-Expression / iex fed by a
|
|
664
|
+
// downloader (iwr/irm/Invoke-WebRequest/Invoke-RestMethod/DownloadString/
|
|
665
|
+
// DownloadFile/Net.WebClient/Start-BitsTransfer, or a bare `... | iex`).
|
|
666
|
+
// This is the Windows equivalent of `curl … | sh`, so it rides the same
|
|
667
|
+
// catalog toggle (curl_pipe_shell).
|
|
668
|
+
if (/\b(?:iex|invoke-expression)\b/i.test(text)
|
|
669
|
+
&& /\b(?:iwr|irm|invoke-webrequest|invoke-restmethod|curl|wget|downloadstring|downloadfile|net\.webclient|start-bitstransfer)\b/i.test(text)) {
|
|
670
|
+
return { itemId: 'curl_pipe_shell', reason: 'remote script downloaded and executed (PowerShell cradle)' };
|
|
671
|
+
}
|
|
672
|
+
if (/\|\s*(?:iex|invoke-expression)\b/i.test(text)) {
|
|
673
|
+
return { itemId: 'curl_pipe_shell', reason: 'remote script piped to shell (PowerShell iex)' };
|
|
674
|
+
}
|
|
675
|
+
if (/\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)/i.test(text))
|
|
676
|
+
return { itemId: 'python_socket_subprocess', reason: 'python reverse shell' };
|
|
677
|
+
return undefined;
|
|
678
|
+
});
|
|
504
679
|
}
|
|
505
680
|
function destructiveSqlReason(value) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
681
|
+
return matchCommand(value, (text) => {
|
|
682
|
+
if (/\bdrop\s+database\b/i.test(text))
|
|
683
|
+
return { itemId: 'drop_database', reason: 'DROP DATABASE' };
|
|
684
|
+
if (/\bdrop\s+schema\b/i.test(text))
|
|
685
|
+
return { itemId: 'drop_schema', reason: 'DROP SCHEMA' };
|
|
686
|
+
if (/\btruncate\s+table\b/i.test(text))
|
|
687
|
+
return { itemId: 'truncate_table', reason: 'TRUNCATE TABLE' };
|
|
688
|
+
return undefined;
|
|
689
|
+
});
|
|
514
690
|
}
|
|
515
691
|
function infraDestroyReason(value) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
692
|
+
return matchCommand(value, (text) => {
|
|
693
|
+
if (/\bterraform\s+destroy\b(?=.*-{1,2}auto-approve)/i.test(text))
|
|
694
|
+
return { itemId: 'terraform_destroy_auto', reason: 'terraform destroy with auto-approve' };
|
|
695
|
+
if (/\bpulumi\s+destroy\b(?=.*(?:--yes|-y))/i.test(text))
|
|
696
|
+
return { itemId: 'pulumi_destroy_yes', reason: 'pulumi destroy with auto-approve' };
|
|
697
|
+
// Specific kubectl deletes first (default-on), then the broad catch-all
|
|
698
|
+
// (generic_kubectl_delete, default-OFF). The generic rule now matches any
|
|
699
|
+
// `kubectl delete` as its catalog description advertises — previously it
|
|
700
|
+
// only fired on clusterrole|secret, silently under-delivering the toggle.
|
|
701
|
+
if (/\bkubectl\s+delete\s+namespace\b/i.test(text))
|
|
702
|
+
return { itemId: 'kubectl_delete_namespace', reason: 'destructive kubectl namespace delete' };
|
|
703
|
+
if (/\bkubectl\s+delete\s+all\b(?=.*--all)/i.test(text))
|
|
704
|
+
return { itemId: 'kubectl_delete_all_all', reason: 'broad kubectl delete all' };
|
|
705
|
+
if (/\bkubectl\s+delete\b/i.test(text))
|
|
706
|
+
return { itemId: 'generic_kubectl_delete', reason: 'destructive kubectl delete' };
|
|
707
|
+
if (/\bgcloud\s+projects\s+delete\b/i.test(text))
|
|
708
|
+
return { itemId: 'gcloud_project_delete', reason: 'GCP project deletion' };
|
|
709
|
+
if (/\bgcloud\s+sql\s+instances\s+delete\b/i.test(text))
|
|
710
|
+
return { itemId: 'gcloud_sql_delete', reason: 'GCP SQL instance deletion' };
|
|
711
|
+
if (/\baws\s+s3\s+rm\s+s3:\/\/\S+\s+--recursive\b/i.test(text))
|
|
712
|
+
return { itemId: 'aws_s3_recursive_rm', reason: 'recursive S3 deletion' };
|
|
713
|
+
if (/\baws\s+cloudformation\s+delete-stack\b/i.test(text))
|
|
714
|
+
return { itemId: 'aws_cloudformation_delete_stack', reason: 'CloudFormation stack deletion' };
|
|
715
|
+
if (/\baws\s+iam\s+attach-user-policy\b(?=.*AdministratorAccess)/i.test(text))
|
|
716
|
+
return { itemId: 'aws_iam_admin_policy', reason: 'IAM administrator privilege grant' };
|
|
717
|
+
if (/\baws\s+iam\s+create-access-key\b/i.test(text))
|
|
718
|
+
return { itemId: 'aws_iam_access_key', reason: 'IAM access key creation' };
|
|
719
|
+
if (/\baz\s+group\s+delete\b(?=.*(?:--yes|-y))/i.test(text))
|
|
720
|
+
return { itemId: 'az_group_delete_yes', reason: 'Azure resource group deletion' };
|
|
721
|
+
return undefined;
|
|
722
|
+
});
|
|
542
723
|
}
|
|
543
724
|
/**
|
|
544
725
|
* Local file editors (Write / StrReplace / edit_file / EditNotebook / apply_patch ...)
|
package/dist/commands/hook.js
CHANGED
|
@@ -621,11 +621,13 @@ async function hookCommand(args, config) {
|
|
|
621
621
|
// machine applies it on the next poll. Cached locally (stale-while-error), so
|
|
622
622
|
// this is instant when fresh and bounded (~1.5s) only when stale.
|
|
623
623
|
let machineSuspended = false;
|
|
624
|
+
let modeResolved = false; // add this line
|
|
624
625
|
if (!forceMonitor && !forceEnforce && shieldId) {
|
|
625
626
|
try {
|
|
626
627
|
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname() });
|
|
627
628
|
if (bundle.source !== 'default') {
|
|
628
629
|
shadow = bundle.mode !== 'block'; // 'monitor'/'shadow' => report-only
|
|
630
|
+
modeResolved = true; // add this line
|
|
629
631
|
effectivePolicyHash = bundle.policyHash || bundle.version;
|
|
630
632
|
// Cached org policies: hash-covered by the bundle ETag, evaluated
|
|
631
633
|
// locally when the policy gate is unreachable (offline enforcement).
|
|
@@ -642,6 +644,11 @@ async function hookCommand(args, config) {
|
|
|
642
644
|
}
|
|
643
645
|
catch { /* keep the local default */ }
|
|
644
646
|
}
|
|
647
|
+
// Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
|
|
648
|
+
if (!modeResolved && !forceEnforce) {
|
|
649
|
+
shadow = true;
|
|
650
|
+
dbg({ phase: 'mode_default_monitor_first', shadow });
|
|
651
|
+
}
|
|
645
652
|
if (forceEnforce)
|
|
646
653
|
shadow = false;
|
|
647
654
|
// Keep device liveness fresh during active use (throttled, detached — never blocks).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BotGuardConfig } from '../config';
|
|
2
|
-
|
|
3
|
-
export type GatewayClient =
|
|
2
|
+
/** Clients with a dedicated install-*-mcp-gateway command. */
|
|
3
|
+
export type GatewayClient = 'cursor' | 'claude-code' | 'claude-desktop' | 'codex' | 'gemini-cli' | 'windsurf' | 'vscode';
|
|
4
4
|
export declare const ALL_GATEWAY_CLIENTS: GatewayClient[];
|
|
5
5
|
export interface McpGatewayArgs {
|
|
6
6
|
mcpCommand?: string;
|
|
@@ -117,4 +117,3 @@ export declare function installCodexMcpGatewayCommand(args: InstallCodexMcpGatew
|
|
|
117
117
|
export declare function installGeminiMcpGatewayCommand(args: InstallGeminiMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
118
118
|
export declare function installWindsurfMcpGatewayCommand(args: InstallWindsurfMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
119
119
|
export declare function installVscodeMcpGatewayCommand(args: InstallVscodeMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
120
|
-
export {};
|
|
@@ -86,6 +86,8 @@ const CLIENT_LABELS = {
|
|
|
86
86
|
'gemini-cli': 'Gemini CLI',
|
|
87
87
|
windsurf: 'Windsurf',
|
|
88
88
|
vscode: 'VS Code',
|
|
89
|
+
kiro: 'Kiro',
|
|
90
|
+
other: 'an MCP client',
|
|
89
91
|
};
|
|
90
92
|
/**
|
|
91
93
|
* Default configured-context template per client. Single source of truth —
|
|
@@ -148,6 +150,8 @@ function normalizeAgentClient(value, fallback) {
|
|
|
148
150
|
gemini: 'gemini-cli',
|
|
149
151
|
windsurf: 'windsurf',
|
|
150
152
|
vscode: 'vscode',
|
|
153
|
+
kiro: 'kiro',
|
|
154
|
+
other: 'other',
|
|
151
155
|
};
|
|
152
156
|
if (value && map[value])
|
|
153
157
|
return map[value];
|
|
@@ -731,7 +735,7 @@ class AgentGuardApi {
|
|
|
731
735
|
});
|
|
732
736
|
const data = await resp.json().catch(() => ({}));
|
|
733
737
|
if (!resp.ok)
|
|
734
|
-
return { success: false, status: resp.status, error: data.error || `
|
|
738
|
+
return { success: false, status: resp.status, error: data.error || `FullCourtDefense API error (${resp.status})` };
|
|
735
739
|
return data;
|
|
736
740
|
}
|
|
737
741
|
async get(pathValue, timeoutMs = 10_000) {
|
|
@@ -742,7 +746,7 @@ class AgentGuardApi {
|
|
|
742
746
|
});
|
|
743
747
|
const data = await resp.json().catch(() => ({}));
|
|
744
748
|
if (!resp.ok)
|
|
745
|
-
return { success: false, status: resp.status, error: data.error || `
|
|
749
|
+
return { success: false, status: resp.status, error: data.error || `FullCourtDefense API error (${resp.status})` };
|
|
746
750
|
return data;
|
|
747
751
|
}
|
|
748
752
|
headers() {
|
|
@@ -800,7 +804,7 @@ class McpGatewayServer {
|
|
|
800
804
|
this.respond(message.id, {
|
|
801
805
|
tools: tools.map(tool => ({
|
|
802
806
|
...tool,
|
|
803
|
-
description: `[
|
|
807
|
+
description: `[FullCourtDefense protected] ${tool.description || ''}`.trim(),
|
|
804
808
|
})),
|
|
805
809
|
});
|
|
806
810
|
return;
|
|
@@ -864,7 +868,9 @@ class McpGatewayServer {
|
|
|
864
868
|
// fallback: when the policy service is unreachable, tool calls are checked
|
|
865
869
|
// locally with the same engine the server runs (actionPolicyEngine.ts).
|
|
866
870
|
let cachedPolicies;
|
|
867
|
-
|
|
871
|
+
// Monitor unless a server/cache bundle explicitly says 'block': a machine
|
|
872
|
+
// with no signal (fresh install + offline, wiped cache) must never block.
|
|
873
|
+
let reportOnlyMode = true;
|
|
868
874
|
let machineRole;
|
|
869
875
|
try {
|
|
870
876
|
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
|
|
@@ -916,13 +922,25 @@ class McpGatewayServer {
|
|
|
916
922
|
const requestOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
917
923
|
for (const warning of requestOutcome.warnings) {
|
|
918
924
|
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
|
919
|
-
process.stderr.write(`
|
|
925
|
+
process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
|
|
920
926
|
}
|
|
921
927
|
const localBlock = requestOutcome.blockingFinding;
|
|
922
928
|
if (localBlock) {
|
|
923
|
-
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
924
929
|
const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
925
|
-
|
|
930
|
+
if (reportOnlyMode) {
|
|
931
|
+
// MONITOR CONTRACT: a monitor/shadow machine (or one with no bundle
|
|
932
|
+
// signal at all) must NEVER hard-block. Online, the server already
|
|
933
|
+
// serves warn-downgraded rules to monitor machines — this branch is
|
|
934
|
+
// the client-side belt for offline/no-cache/stale-cache states,
|
|
935
|
+
// where the scan can still yield block-action findings. Record the
|
|
936
|
+
// would-block and let the call through.
|
|
937
|
+
this.spoolLocalFinding({ ...localBlock, reason: `[monitor] ${localBlock.reason}` }, toolName, operation, 'warn');
|
|
938
|
+
process.stderr.write(`FullCourtDefense (monitor) would block ${toolName}: ${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
942
|
+
throw new Error(`${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId}: ${localBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
|
|
943
|
+
}
|
|
926
944
|
}
|
|
927
945
|
// --- Machine-role session amount limits (pre-call: operation counts) ---
|
|
928
946
|
// The role's verb rules ride actionPolicies; this enforces the AMOUNTS.
|
|
@@ -945,7 +963,7 @@ class McpGatewayServer {
|
|
|
945
963
|
if (machineRole.stage === 'monitor' || reportOnlyMode) {
|
|
946
964
|
(0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation: inferredOp, reason: `[monitor] ${reason}`, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
|
|
947
965
|
(0, telemetry_1.triggerFlush)(false);
|
|
948
|
-
process.stderr.write(`
|
|
966
|
+
process.stderr.write(`FullCourtDefense (monitor): ${reason}\n`);
|
|
949
967
|
}
|
|
950
968
|
else {
|
|
951
969
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: inferredOp, reason, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
|
|
@@ -991,18 +1009,22 @@ class McpGatewayServer {
|
|
|
991
1009
|
: `local policy allow (offline): ${detail}`;
|
|
992
1010
|
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: localReason, offlineEnforced: true });
|
|
993
1011
|
(0, telemetry_1.triggerFlush)(false);
|
|
994
|
-
process.stderr.write(`
|
|
1012
|
+
process.stderr.write(`FullCourtDefense offline: policy service unreachable (${detail}); ${toolName} checked against locally cached org policies → ${blocking ? `${local.verdict} (report-only)` : 'allow'}.\n`);
|
|
995
1013
|
preflight = { allowed: true, decision: 'allow', operation: localOperation };
|
|
996
1014
|
}
|
|
997
|
-
else if (this.gatewayConfig.failClosed) {
|
|
1015
|
+
else if (this.gatewayConfig.failClosed && (!reportOnlyMode || this.gatewayConfig.failClosedExplicit)) {
|
|
1016
|
+
// Fail-closed only bites when the machine is actually in block mode
|
|
1017
|
+
// (or the operator passed --fail-closed explicitly at install time).
|
|
1018
|
+
// A monitor machine must never hard-block — not even during an
|
|
1019
|
+
// outage with a bundle-supplied failClosed=true left over.
|
|
998
1020
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason: `fail-closed: ${detail}`, offlineEnforced: true });
|
|
999
1021
|
(0, telemetry_1.triggerFlush)(true);
|
|
1000
|
-
throw new Error(`
|
|
1022
|
+
throw new Error(`FullCourtDefense policy service unreachable and fail-closed mode is on (${detail}). Tool call blocked.`);
|
|
1001
1023
|
}
|
|
1002
1024
|
else {
|
|
1003
1025
|
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation, reason: `degraded: ${detail}`, offlineEnforced: true });
|
|
1004
1026
|
(0, telemetry_1.triggerFlush)(false);
|
|
1005
|
-
process.stderr.write(`
|
|
1027
|
+
process.stderr.write(`FullCourtDefense degraded mode: policy service unreachable (${detail}); allowing ${toolName} per fail-open config.\n`);
|
|
1006
1028
|
preflight = { allowed: true, decision: 'allow', operation: operation || '' };
|
|
1007
1029
|
}
|
|
1008
1030
|
}
|
|
@@ -1013,7 +1035,7 @@ class McpGatewayServer {
|
|
|
1013
1035
|
if (!approvalActionId)
|
|
1014
1036
|
throw new Error('Policy requires approval, but no approval request id was returned.');
|
|
1015
1037
|
const queueUrl = (0, notify_1.approvalsConsoleUrl)();
|
|
1016
|
-
process.stderr.write(`
|
|
1038
|
+
process.stderr.write(`FullCourtDefense approval required for ${toolName}. Waiting for human review (${approvalActionId})... Approve at ${queueUrl}\n`);
|
|
1017
1039
|
// Gateway stderr is invisible inside most MCP clients — surface the
|
|
1018
1040
|
// wait with a native OS toast so a human knows to open the console.
|
|
1019
1041
|
(0, notify_1.notifyOs)({
|
|
@@ -1054,28 +1076,45 @@ class McpGatewayServer {
|
|
|
1054
1076
|
const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
1055
1077
|
for (const warning of responseOutcome.warnings) {
|
|
1056
1078
|
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
|
1057
|
-
process.stderr.write(`
|
|
1079
|
+
process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
|
|
1058
1080
|
}
|
|
1059
1081
|
const localResponseBlock = responseOutcome.blockingFinding;
|
|
1060
1082
|
if (localResponseBlock) {
|
|
1061
|
-
this.spoolLocalFinding(localResponseBlock, toolName, operation);
|
|
1062
1083
|
const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
1063
|
-
|
|
1084
|
+
if (reportOnlyMode) {
|
|
1085
|
+
// MONITOR CONTRACT (see request-side block above): record, never block.
|
|
1086
|
+
this.spoolLocalFinding({ ...localResponseBlock, reason: `[monitor] ${localResponseBlock.reason}` }, toolName, operation, 'warn');
|
|
1087
|
+
process.stderr.write(`FullCourtDefense (monitor) would block the ${toolName} response: ${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId})\n`);
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
this.spoolLocalFinding(localResponseBlock, toolName, operation);
|
|
1091
|
+
throw new Error(`${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId}: ${localResponseBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
|
|
1092
|
+
}
|
|
1064
1093
|
}
|
|
1065
1094
|
if (responseOutcome.maskFindings.length > 0) {
|
|
1066
|
-
// mask-action rules: redact the matched secrets in place.
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1095
|
+
// mask-action rules: redact the matched secrets in place. Masking is
|
|
1096
|
+
// monitor-compatible (a redaction is not a block), so it applies in
|
|
1097
|
+
// every mode. If the result shape cannot be rewritten (secret hidden
|
|
1098
|
+
// in a non-text payload), fail strict and block — a mask rule must
|
|
1099
|
+
// never leak what it was set to hide — except under monitor, where
|
|
1100
|
+
// the contract is observe-only: record the would-block and pass the
|
|
1101
|
+
// response through with whatever partial masking was possible.
|
|
1069
1102
|
const { masked, fullyMasked } = maskToolResult(rawResult, responseOutcome.maskFindings);
|
|
1070
1103
|
if (!fullyMasked) {
|
|
1071
1104
|
const finding = responseOutcome.maskFindings[0];
|
|
1072
|
-
|
|
1073
|
-
|
|
1105
|
+
if (reportOnlyMode) {
|
|
1106
|
+
this.spoolLocalFinding({ ...finding, reason: `[monitor] ${finding.reason}` }, toolName, operation, 'warn');
|
|
1107
|
+
process.stderr.write(`FullCourtDefense (monitor) would block the ${toolName} response: mask rule "${finding.itemId}" could not rewrite this payload.\n`);
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
this.spoolLocalFinding(finding, toolName, operation);
|
|
1111
|
+
throw new Error(`${finding.reason} (rule "${finding.itemId}" is set to mask, but this tool response could not be rewritten — blocked instead.)`);
|
|
1112
|
+
}
|
|
1074
1113
|
}
|
|
1075
1114
|
rawResult = masked;
|
|
1076
1115
|
for (const finding of responseOutcome.maskFindings) {
|
|
1077
1116
|
this.spoolLocalFinding(finding, toolName, operation, 'mask');
|
|
1078
|
-
process.stderr.write(`
|
|
1117
|
+
process.stderr.write(`FullCourtDefense masked a ${finding.itemId} in the ${toolName} response.\n`);
|
|
1079
1118
|
}
|
|
1080
1119
|
}
|
|
1081
1120
|
// --- Machine-role session amount limits (post-call: response volume) ---
|
|
@@ -1097,7 +1136,7 @@ class McpGatewayServer {
|
|
|
1097
1136
|
if (machineRole.stage === 'monitor' || reportOnlyMode) {
|
|
1098
1137
|
(0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] ${reason}`, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
|
|
1099
1138
|
(0, telemetry_1.triggerFlush)(false);
|
|
1100
|
-
process.stderr.write(`
|
|
1139
|
+
process.stderr.write(`FullCourtDefense (monitor): ${reason}\n`);
|
|
1101
1140
|
}
|
|
1102
1141
|
else {
|
|
1103
1142
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
|
|
@@ -1157,7 +1196,7 @@ async function mcpGatewayCommand(args, config) {
|
|
|
1157
1196
|
};
|
|
1158
1197
|
const server = new McpGatewayServer(gatewayConfig, spec);
|
|
1159
1198
|
const downstreamLabel = spec.kind === 'http' ? spec.url : `${spec.command} ${spec.args.join(' ')}`;
|
|
1160
|
-
process.stderr.write(`
|
|
1199
|
+
process.stderr.write(`FullCourtDefense MCP Gateway running for ${gatewayConfig.agentName}. Downstream: ${downstreamLabel}\n`);
|
|
1161
1200
|
server.start();
|
|
1162
1201
|
}
|
|
1163
1202
|
function cursorMcpPath(projectScope) {
|
|
@@ -1465,7 +1504,7 @@ async function installCursorMcpGatewayCommand(args, config) {
|
|
|
1465
1504
|
const downstream = resolveDownstreamSpec(args);
|
|
1466
1505
|
const commandArgs = buildGatewayCommandArgs(gatewayConfig, downstream, INSTALL_GATEWAY_ARGS);
|
|
1467
1506
|
writeMcpServerConfig(file, serverName, nodeExe, commandArgs);
|
|
1468
|
-
console.log(`
|
|
1507
|
+
console.log(`FullCourtDefense MCP Gateway installed for Cursor (${projectScope ? 'project' : 'global'}).`);
|
|
1469
1508
|
console.log(`Config: ${file}`);
|
|
1470
1509
|
console.log(`Server: ${serverName}`);
|
|
1471
1510
|
console.log(`Downstream: ${downstreamSpecLabel(downstream)}`);
|
|
@@ -1501,7 +1540,7 @@ async function installMcpGatewayCommand(args, config) {
|
|
|
1501
1540
|
if (clients.length > 1)
|
|
1502
1541
|
console.log('');
|
|
1503
1542
|
}
|
|
1504
|
-
console.log('
|
|
1543
|
+
console.log('FullCourtDefense gateway install summary:');
|
|
1505
1544
|
console.log(` ✓ ${successes.length > 0 ? successes.join(', ') : 'none'}`);
|
|
1506
1545
|
if (failures.length > 0) {
|
|
1507
1546
|
console.log(' ✗ Failed:');
|
|
@@ -1550,9 +1589,11 @@ const CLIENT_KEY_TO_AGENT = {
|
|
|
1550
1589
|
gemini_cli: 'gemini-cli',
|
|
1551
1590
|
windsurf: 'windsurf',
|
|
1552
1591
|
vscode: 'vscode',
|
|
1592
|
+
kiro: 'kiro',
|
|
1593
|
+
other: 'other',
|
|
1553
1594
|
};
|
|
1554
1595
|
function newWrapStats() { return { wrapped: [], healed: [], skippedManaged: [], skippedRemote: [] }; }
|
|
1555
|
-
/** True when an MCP server entry already runs through the
|
|
1596
|
+
/** True when an MCP server entry already runs through the FullCourtDefense gateway. */
|
|
1556
1597
|
function isGatewayWrappedEntry(entry) {
|
|
1557
1598
|
if (!entry || typeof entry !== 'object')
|
|
1558
1599
|
return false;
|
|
@@ -2230,7 +2271,9 @@ async function protectAllCommand(args, config) {
|
|
|
2230
2271
|
const presentClientKeys = new Set();
|
|
2231
2272
|
for (const file of files) {
|
|
2232
2273
|
presentClientKeys.add(file.clientKey);
|
|
2233
|
-
|
|
2274
|
+
// Unknown clientKeys stamp `other`, never Cursor — the log must not lie
|
|
2275
|
+
// about which app a wrapped config belongs to.
|
|
2276
|
+
const agentClient = CLIENT_KEY_TO_AGENT[file.clientKey] || 'other';
|
|
2234
2277
|
const isToml = /\.toml$/i.test(file.path);
|
|
2235
2278
|
const stats = isToml
|
|
2236
2279
|
? transformCodexToml(file.path, 'wrap', gatewayConfig, dryRun)
|
|
@@ -2344,7 +2387,7 @@ async function installClaudeCodeMcpGatewayCommand(args, config) {
|
|
|
2344
2387
|
}
|
|
2345
2388
|
}
|
|
2346
2389
|
if (result.status === 0) {
|
|
2347
|
-
console.log(`
|
|
2390
|
+
console.log(`FullCourtDefense MCP Gateway installed for Claude Code (${scope}).`);
|
|
2348
2391
|
console.log(`Server: ${serverName}`);
|
|
2349
2392
|
console.log(`Downstream: ${downstreamSpecLabel(downstream)}`);
|
|
2350
2393
|
if (!includeShieldKey && gatewayConfig.shieldKey) {
|
|
@@ -2382,7 +2425,7 @@ async function installClaudeDesktopMcpGatewayCommand(args, config) {
|
|
|
2382
2425
|
for (const target of targets) {
|
|
2383
2426
|
writeMcpServerConfig(target.file, serverName, nodeExe, commandArgs);
|
|
2384
2427
|
}
|
|
2385
|
-
console.log('
|
|
2428
|
+
console.log('FullCourtDefense MCP Gateway installed for Claude Desktop.');
|
|
2386
2429
|
for (const target of targets) {
|
|
2387
2430
|
console.log(`Config: ${target.file} (${target.source})`);
|
|
2388
2431
|
}
|
|
@@ -2403,7 +2446,7 @@ async function installCodexMcpGatewayCommand(args, config) {
|
|
|
2403
2446
|
const downstream = resolveDownstreamSpec(args);
|
|
2404
2447
|
const commandArgs = buildGatewayCommandArgs(gatewayConfig, downstream, INSTALL_GATEWAY_ARGS);
|
|
2405
2448
|
writeCodexMcpServer(file, serverName, nodeExe, commandArgs);
|
|
2406
|
-
console.log(`
|
|
2449
|
+
console.log(`FullCourtDefense MCP Gateway installed for Codex (${projectScope ? 'project' : 'global'}).`);
|
|
2407
2450
|
console.log(`Config: ${file}`);
|
|
2408
2451
|
console.log(`Server: ${serverName}`);
|
|
2409
2452
|
console.log('Restart Codex or reload MCP servers. Project config requires a trusted project.');
|
|
@@ -2421,7 +2464,7 @@ async function installGeminiMcpGatewayCommand(args, config) {
|
|
|
2421
2464
|
const downstream = resolveDownstreamSpec(args);
|
|
2422
2465
|
const commandArgs = buildGatewayCommandArgs(gatewayConfig, downstream, INSTALL_GATEWAY_ARGS);
|
|
2423
2466
|
writeJsonMcpServer(file, serverName, nodeExe, commandArgs);
|
|
2424
|
-
console.log(`
|
|
2467
|
+
console.log(`FullCourtDefense MCP Gateway installed for Gemini CLI (${projectScope ? 'project' : 'user'}).`);
|
|
2425
2468
|
console.log(`Config: ${file}`);
|
|
2426
2469
|
console.log(`Server: ${serverName}`);
|
|
2427
2470
|
console.log('Restart Gemini CLI to load the protected tools.');
|
|
@@ -2438,7 +2481,7 @@ async function installWindsurfMcpGatewayCommand(args, config) {
|
|
|
2438
2481
|
const downstream = resolveDownstreamSpec(args);
|
|
2439
2482
|
const commandArgs = buildGatewayCommandArgs(gatewayConfig, downstream, INSTALL_GATEWAY_ARGS);
|
|
2440
2483
|
writeJsonMcpServer(file, serverName, nodeExe, commandArgs);
|
|
2441
|
-
console.log('
|
|
2484
|
+
console.log('FullCourtDefense MCP Gateway installed for Windsurf.');
|
|
2442
2485
|
console.log(`Config: ${file}`);
|
|
2443
2486
|
console.log(`Server: ${serverName}`);
|
|
2444
2487
|
console.log('Restart Windsurf to load the protected tools.');
|
|
@@ -2465,7 +2508,7 @@ async function installVscodeMcpGatewayCommand(args, config) {
|
|
|
2465
2508
|
for (const file of targets) {
|
|
2466
2509
|
writeVscodeMcpServer(file, serverName, nodeExe, commandArgs);
|
|
2467
2510
|
}
|
|
2468
|
-
console.log('
|
|
2511
|
+
console.log('FullCourtDefense MCP Gateway installed for VS Code.');
|
|
2469
2512
|
for (const file of targets)
|
|
2470
2513
|
console.log(`Config: ${file}`);
|
|
2471
2514
|
console.log(`Server: ${serverName}`);
|
package/dist/commands/onboard.js
CHANGED
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.onboardCommand = onboardCommand;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
38
39
|
const os = __importStar(require("os"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
@@ -57,6 +58,33 @@ const envDiagnostics_1 = require("../envDiagnostics");
|
|
|
57
58
|
const runtimeConfig_1 = require("../runtimeConfig");
|
|
58
59
|
const daemonForensics_1 = require("../daemonForensics");
|
|
59
60
|
const onboardingJournal_1 = require("./onboardingJournal");
|
|
61
|
+
/**
|
|
62
|
+
* Best-effort: which AI client apps are running RIGHT NOW. Apps launched before
|
|
63
|
+
* onboarding keep their old, unwrapped config in memory until restarted — the
|
|
64
|
+
* single biggest "it says protected but isn't" trust gap, so the final message
|
|
65
|
+
* names the exact apps that still need a restart. Failure returns [] silently.
|
|
66
|
+
*/
|
|
67
|
+
function detectRunningAiClients() {
|
|
68
|
+
const targets = [
|
|
69
|
+
{ names: ['cursor.exe', 'cursor'], label: 'Cursor' },
|
|
70
|
+
{ names: ['code.exe', 'code'], label: 'VS Code' },
|
|
71
|
+
{ names: ['claude.exe', 'claude'], label: 'Claude Desktop' },
|
|
72
|
+
{ names: ['windsurf.exe', 'windsurf'], label: 'Windsurf' },
|
|
73
|
+
];
|
|
74
|
+
try {
|
|
75
|
+
const raw = process.platform === 'win32'
|
|
76
|
+
? (0, child_process_1.execSync)('tasklist /FO CSV /NH', { encoding: 'utf8', timeout: 10_000, windowsHide: true })
|
|
77
|
+
: (0, child_process_1.execSync)('ps -A -o comm=', { encoding: 'utf8', timeout: 10_000 });
|
|
78
|
+
const running = new Set(raw.split(/\r?\n/).map(line => {
|
|
79
|
+
const name = process.platform === 'win32' ? (line.match(/^"([^"]+)"/)?.[1] ?? '') : line.trim();
|
|
80
|
+
return path.basename(name).toLowerCase();
|
|
81
|
+
}).filter(Boolean));
|
|
82
|
+
return targets.filter(target => target.names.some(name => running.has(name))).map(target => target.label);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
60
88
|
const GREEN = '\x1b[32m';
|
|
61
89
|
const RED = '\x1b[31m';
|
|
62
90
|
const YELLOW = '\x1b[33m';
|
|
@@ -277,7 +305,11 @@ async function onboardCommand(args, config) {
|
|
|
277
305
|
console.log(JSON.stringify({ ok: !Object.values(journal.steps).some(step => step.critical && step.status === 'failed'), journalPath: journalFile, ...journal }));
|
|
278
306
|
};
|
|
279
307
|
console.log(`\n${BOLD}\x1b[36mFullCourtDefense — machine onboarding${RESET}`);
|
|
280
|
-
console.log(`${DIM}Connectivity -> enrollment -> protection -> verification. One command.${RESET}
|
|
308
|
+
console.log(`${DIM}Connectivity -> enrollment -> protection -> verification. One command.${RESET}`);
|
|
309
|
+
// Stated up front, before consent: enrollment starts in monitor (detect-only)
|
|
310
|
+
// mode so nothing in the developer's workflow breaks while the fleet baselines
|
|
311
|
+
// this machine. Admins promote it to blocking mode from the fleet console.
|
|
312
|
+
console.log(`${DIM}New machines start in ${RESET}${BOLD}monitor mode${RESET}${DIM} — every agent action is recorded and analyzed, nothing is blocked yet. Your admin promotes the machine to blocking mode from the fleet console once its workflow is baselined.${RESET}\n`);
|
|
281
313
|
// Trust disclosure first — nothing touches the machine before consent.
|
|
282
314
|
// Dry runs report the plan without changing anything, so no prompt there.
|
|
283
315
|
if (!dryRun && !(await confirmTrustDisclosure(args))) {
|
|
@@ -623,14 +655,29 @@ async function onboardCommand(args, config) {
|
|
|
623
655
|
for (const check of checks)
|
|
624
656
|
printCheck(check);
|
|
625
657
|
const requiredFailures = checks.filter(check => !check.ok && !check.optional);
|
|
626
|
-
const dashboard = (0, notify_1.consoleUrl)('/agent-security/users?view=
|
|
658
|
+
const dashboard = (0, notify_1.consoleUrl)('/agent-security/users?view=machines');
|
|
627
659
|
console.log('');
|
|
628
660
|
if (requiredFailures.length === 0) {
|
|
629
|
-
|
|
661
|
+
// Honest final state: installation is verified, but AI apps that were
|
|
662
|
+
// already open still run their OLD (unwrapped) configuration until they
|
|
663
|
+
// restart. Never print an unconditional green "protected" before that.
|
|
664
|
+
const runningClients = dryRun ? [] : detectRunningAiClients();
|
|
665
|
+
console.log(`${GREEN}${BOLD}Protection is installed and verified.${RESET} This machine reports to your org's AI Fleet in monitor mode — actions are recorded and analyzed while the fleet baselines this machine. An admin can promote it to blocking mode from the console.`);
|
|
666
|
+
if (runningClients.length > 0) {
|
|
667
|
+
console.log('');
|
|
668
|
+
console.log(`${YELLOW}${BOLD} ! ACTION REQUIRED — restart ${runningClients.join(', ')}.${RESET}`);
|
|
669
|
+
console.log(`${YELLOW} ${runningClients.length === 1 ? 'This app is' : 'These apps are'} running with the configuration from before onboarding — agent actions inside ${runningClients.length === 1 ? 'it are' : 'them are'} NOT protected until restarted. Anything launched from now on is protected automatically.${RESET}`);
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
console.log('');
|
|
673
|
+
console.log(`${YELLOW}${BOLD} ! One step left:${RESET} ${YELLOW}restart any AI apps that were open during onboarding (Cursor, Claude, VS Code, …) so they load the protected configuration. Apps launched from now on are protected automatically.${RESET}`);
|
|
674
|
+
}
|
|
675
|
+
console.log('');
|
|
630
676
|
console.log(`${DIM}Fleet view: ${dashboard}${RESET}`);
|
|
631
|
-
console.log(`${DIM}Restart your AI clients (Cursor, Claude, VS Code, …) so they pick up the wrapped configs.${RESET}`);
|
|
632
677
|
mark('verification', 'completed');
|
|
633
|
-
report(
|
|
678
|
+
report(runningClients.length > 0
|
|
679
|
+
? `Onboarding complete. Restart required: ${runningClients.join(', ')} still run the pre-onboarding configuration.`
|
|
680
|
+
: 'Onboarding complete. Restart AI clients to load protected configurations.');
|
|
634
681
|
}
|
|
635
682
|
else {
|
|
636
683
|
console.log(`${YELLOW}${BOLD}Onboarding finished with ${requiredFailures.length} unresolved surface(s).${RESET} Fix the ✗ lines above and re-run ${BOLD}fullcourtdefense onboard${RESET}.`);
|
|
@@ -103,7 +103,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
103
103
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
104
104
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
105
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
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==='
|
|
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
|
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
108
108
|
`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
109
|
`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{}}`,
|
|
@@ -254,10 +254,13 @@ function cachedMode() {
|
|
|
254
254
|
const entries = Object.values(parsed).filter(e => e && typeof e === 'object');
|
|
255
255
|
entries.sort((a, b) => (b.fetchedAt || 0) - (a.fetchedAt || 0));
|
|
256
256
|
const mode = entries[0]?.mode;
|
|
257
|
-
|
|
257
|
+
// Enforce only on an explicit cached 'block'; missing/unknown = monitor.
|
|
258
|
+
// A machine that has never been told to enforce must not block typed
|
|
259
|
+
// commands (fresh install before the first bundle fetch, wiped cache).
|
|
260
|
+
return mode === 'block' ? 'block' : 'monitor';
|
|
258
261
|
}
|
|
259
262
|
catch {
|
|
260
|
-
return '
|
|
263
|
+
return 'monitor';
|
|
261
264
|
}
|
|
262
265
|
}
|
|
263
266
|
/**
|
|
@@ -459,13 +462,13 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
459
462
|
`$global:FcdSpoolPath = ${psQuote(SPOOL_PATH)}`,
|
|
460
463
|
`$global:FcdNodePath = ${psQuote(nodePath)}`,
|
|
461
464
|
`$global:FcdCliEntry = ${psQuote(cliEntry)}`,
|
|
462
|
-
`$global:FcdGuardMode = '
|
|
465
|
+
`$global:FcdGuardMode = 'monitor'`,
|
|
463
466
|
`$global:FcdGuardRules = @()`,
|
|
464
467
|
``,
|
|
465
468
|
`try {`,
|
|
466
469
|
` if (Test-Path $global:FcdRulesPath) {`,
|
|
467
470
|
` $fcdRaw = Get-Content -Raw -Path $global:FcdRulesPath | ConvertFrom-Json`,
|
|
468
|
-
` if ($fcdRaw.mode -eq '
|
|
471
|
+
` if ($fcdRaw.mode -eq 'block') { $global:FcdGuardMode = 'block' }`,
|
|
469
472
|
` $global:FcdGuardRules = @($fcdRaw.rules | ForEach-Object {`,
|
|
470
473
|
` try {`,
|
|
471
474
|
` [pscustomobject]@{`,
|
|
@@ -26,7 +26,13 @@ export interface TaintFinding {
|
|
|
26
26
|
source: TaintSource;
|
|
27
27
|
sink: TaintSink;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Whether taint tracking is enabled. OPT-IN for now (FCD_TAINT_ENFORCED=1):
|
|
31
|
+
* the session taint guard held benign commands in invisible approve-once waits
|
|
32
|
+
* (e.g. a health ping to the org's own API), which reads as a frozen terminal.
|
|
33
|
+
* Default OFF until it understands trusted destinations and the hold is visible.
|
|
34
|
+
* FCD_TAINT_DISABLED=1 still force-disables (back-compat).
|
|
35
|
+
*/
|
|
30
36
|
export declare function taintEnabled(): boolean;
|
|
31
37
|
export declare function loadLedger(sessionId: string): TaintLedger;
|
|
32
38
|
/** Record the developer's prompt text so sink actions can be checked against it. */
|
|
@@ -74,9 +74,17 @@ const path = __importStar(require("path"));
|
|
|
74
74
|
const LEDGER_TTL_MS = 24 * 60 * 60 * 1000; // sessions older than 24h are pruned
|
|
75
75
|
const MAX_PROMPTS = 30;
|
|
76
76
|
const MAX_SOURCES = 50;
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Whether taint tracking is enabled. OPT-IN for now (FCD_TAINT_ENFORCED=1):
|
|
79
|
+
* the session taint guard held benign commands in invisible approve-once waits
|
|
80
|
+
* (e.g. a health ping to the org's own API), which reads as a frozen terminal.
|
|
81
|
+
* Default OFF until it understands trusted destinations and the hold is visible.
|
|
82
|
+
* FCD_TAINT_DISABLED=1 still force-disables (back-compat).
|
|
83
|
+
*/
|
|
78
84
|
function taintEnabled() {
|
|
79
|
-
|
|
85
|
+
if (process.env.FCD_TAINT_DISABLED === 'true' || process.env.FCD_TAINT_DISABLED === '1')
|
|
86
|
+
return false;
|
|
87
|
+
return process.env.FCD_TAINT_ENFORCED === 'true' || process.env.FCD_TAINT_ENFORCED === '1';
|
|
80
88
|
}
|
|
81
89
|
function taintDir() {
|
|
82
90
|
return path.join(os.homedir(), '.fullcourtdefense', 'taint');
|
package/dist/output.js
CHANGED
|
@@ -63,7 +63,7 @@ function formatTable(result) {
|
|
|
63
63
|
const failed = attacks.length - passed;
|
|
64
64
|
const lines = [];
|
|
65
65
|
lines.push('');
|
|
66
|
-
lines.push(`${BOLD}${CYAN}
|
|
66
|
+
lines.push(`${BOLD}${CYAN} FullCourtDefense Security Scan${RESET}`);
|
|
67
67
|
lines.push(` ${DIM}${line('─', 50)}${RESET}`);
|
|
68
68
|
lines.push('');
|
|
69
69
|
// Score
|
|
@@ -256,7 +256,7 @@ function formatJson(result) {
|
|
|
256
256
|
function formatCredits(credits) {
|
|
257
257
|
const lines = [];
|
|
258
258
|
lines.push('');
|
|
259
|
-
lines.push(`${BOLD}${CYAN}
|
|
259
|
+
lines.push(`${BOLD}${CYAN} FullCourtDefense Credits${RESET}`);
|
|
260
260
|
lines.push(` ${DIM}${line('─', 40)}${RESET}`);
|
|
261
261
|
lines.push(` Plan: ${BOLD}${credits.plan}${RESET}`);
|
|
262
262
|
lines.push(` Used: ${credits.creditsUsed} / ${credits.monthlyCredits}`);
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -179,7 +179,12 @@ async function getRuntimeBundle(input) {
|
|
|
179
179
|
if (cached) {
|
|
180
180
|
return cachedToEffective(cached);
|
|
181
181
|
}
|
|
182
|
-
|
|
182
|
+
// No cache and no server: there is NO evidence this machine was ever told to
|
|
183
|
+
// enforce. Default to monitor — a fresh install with a network hiccup must
|
|
184
|
+
// not hard-block the developer. Machines that WERE told to enforce keep
|
|
185
|
+
// enforcement through the stale-while-error cache above, and explicit
|
|
186
|
+
// install-time --enforce/--fail-closed flags still win in the callers.
|
|
187
|
+
return { mode: 'monitor', version: '', source: 'default' };
|
|
183
188
|
}
|
|
184
189
|
/**
|
|
185
190
|
* Prove the machine's credentials actually WORK against the backend — a
|
package/dist/version.json
CHANGED