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 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-first mode until an admin enables enforcement.
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
- BotGuard outbound diagnostic
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 AgentGuard runtime/action policies before they reach the real MCP server.
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
- - [BotGuard](https://fullcourtdefense.ai) — Automated red-teaming & real-time firewall for AI agents
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
 
@@ -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 an AgentGuard Action Policy for delete, payment, transfer, email, Slack, or other side-effecting actions');
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 an AgentGuard action policy requiring approval/block for this tool class, then route through the gateway.',
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('AgentGuard evidence upload: uploaded to AI Fleet / discovery posture inventory.');
402
+ console.error('FullCourtDefense evidence upload: uploaded to AI Fleet / discovery posture inventory.');
403
403
  }
404
404
  catch (error) {
405
- console.error(`AgentGuard evidence upload: failed (${error instanceof Error ? error.message : String(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('AgentGuard evidence upload: missing credentials. Run fullcourtdefense login or pass FULLCOURTDEFENSE_API_KEY.');
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==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
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);}`,
@@ -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 AgentGuard gateway was re-applied.',
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 = 'AgentGuard hooks, gateways, and protection configuration were repaired and verified.';
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: 'huggingface_token', label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
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
- const lower = normalizeForCommand(value).toLowerCase();
428
- if (/\bgh\s+auth\s+token\b/.test(lower) || (/\bgh\s+auth\s+status\b/.test(lower) && /(?:--show-token|\s-t\b)/.test(lower))) {
429
- return { itemId: 'gh_auth_token_cmd', reason: 'GitHub CLI token print (gh auth token)' };
430
- }
431
- if (/\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b/.test(lower)) {
432
- return { itemId: 'git_credential_fill', reason: 'git credential helper dump' };
433
- }
434
- if (/\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b/.test(lower) || /\bsecurity\s+dump-keychain\b/.test(lower)) {
435
- return { itemId: 'macos_keychain_dump', reason: 'macOS keychain password dump' };
436
- }
437
- if (/\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b/.test(lower)) {
438
- return { itemId: 'kubectl_config_raw', reason: 'raw kubeconfig dump (kubectl config view --raw)' };
439
- }
440
- if (/\baws\s+configure\s+export-credentials\b/.test(lower) || /\baws\s+configure\s+get\b[^\n;|&]*secret/.test(lower)) {
441
- return { itemId: 'aws_export_credentials', reason: 'AWS secret access key export' };
442
- }
443
- if (/\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b/.test(lower)) {
444
- return { itemId: 'gcloud_access_token_cmd', reason: 'gcloud access-token print' };
445
- }
446
- if (/\bgcloud\s+secrets\s+versions\s+access\b/.test(lower)) {
447
- return { itemId: 'gcloud_secret_access_cmd', reason: 'GCP Secret Manager read' };
448
- }
449
- if (/\baz\s+account\s+get-access-token\b/.test(lower)) {
450
- return { itemId: 'az_access_token_cmd', reason: 'Azure access-token print' };
451
- }
452
- if (/\baws\s+secretsmanager\s+get-secret-value\b/.test(lower) || /\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b/.test(lower)) {
453
- return { itemId: 'aws_secret_fetch_cmd', reason: 'AWS secret fetch' };
454
- }
455
- if (/\bvault\s+(?:kv\s+get|read)\b/.test(lower)) {
456
- return { itemId: 'vault_read_cmd', reason: 'Vault secret read' };
457
- }
458
- return undefined;
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
- const text = normalizeForCommand(value);
462
- const lower = text.toLowerCase();
463
- if (/\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?\/["']?|\*)(?:\s|$|[;&|])/.test(lower))
464
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
465
- if (/\bsudo\s+rm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?\/["']?|\*)(?:\s|$|[;&|])/.test(lower))
466
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
467
- if (/\b(?:del|erase)\s+\/[a-z]*s[a-z]*\s+\/[a-z]*q[a-z]*\s+[a-z]:\\(?:\s|$)/i.test(text))
468
- return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
469
- // Drive-ROOT Remove-Item only: the bare drive (C:\) must be an argument of
470
- // the SAME Remove-Item command (no ;|& between them). The previous loose
471
- // drive-letter match flagged ANY text containing Remove-Item -Recurse
472
- // -Force plus an unrelated "x: " pattern — e.g. our own installer's
473
- // legitimate cleanup script, blocking a customer's git command that merely
474
- // referenced it.
475
- if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
476
- && /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
477
- return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
478
- }
479
- if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
480
- return { itemId: 'fork_bomb', reason: 'fork bomb' };
481
- if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
482
- return { itemId: 'mkfs_device', reason: 'filesystem format command' };
483
- if (/\bdd\b(?=.*\bof=\/dev\/(?:sd|xvd|hd|nvme|disk))/i.test(text))
484
- return { itemId: 'dd_disk_overwrite', reason: 'raw disk overwrite command' };
485
- if (/\bchmod\s+-r\s+777\s+\/(?:\s|$)/i.test(text))
486
- return { itemId: 'chmod_root_777', reason: 'recursive permission change on root' };
487
- if (/\bgit\s+(?:reset\s+--hard|clean\s+-f(?:d|x|dx)?\b)/i.test(text))
488
- return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
489
- return undefined;
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
- const text = normalizeForCommand(value);
493
- if (/\bbash\s+-i\b.*\/dev\/tcp\//i.test(text))
494
- return { itemId: 'bash_dev_tcp', reason: 'bash reverse shell' };
495
- if (/\b(?:nc|ncat|netcat)\b.*\s-e\s+(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
496
- return { itemId: 'netcat_exec', reason: 'netcat reverse shell' };
497
- if (/\bsocat\b.*\bexec:(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
498
- return { itemId: 'socat_exec', reason: 'socat reverse shell' };
499
- if (/\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash)\b/i.test(text))
500
- return { itemId: 'curl_pipe_shell', reason: 'remote script piped to shell' };
501
- if (/\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)/i.test(text))
502
- return { itemId: 'python_socket_subprocess', reason: 'python reverse shell' };
503
- return undefined;
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
- const text = normalizeForCommand(value);
507
- if (/\bdrop\s+database\b/i.test(text))
508
- return { itemId: 'drop_database', reason: 'DROP DATABASE' };
509
- if (/\bdrop\s+schema\b/i.test(text))
510
- return { itemId: 'drop_schema', reason: 'DROP SCHEMA' };
511
- if (/\btruncate\s+table\b/i.test(text))
512
- return { itemId: 'truncate_table', reason: 'TRUNCATE TABLE' };
513
- return undefined;
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
- const text = normalizeForCommand(value);
517
- if (/\bterraform\s+destroy\b(?=.*--auto-approve)/i.test(text))
518
- return { itemId: 'terraform_destroy_auto', reason: 'terraform destroy with auto-approve' };
519
- if (/\bpulumi\s+destroy\b(?=.*(?:--yes|-y))/i.test(text))
520
- return { itemId: 'pulumi_destroy_yes', reason: 'pulumi destroy with auto-approve' };
521
- if (/\bkubectl\s+delete\s+namespace\b/i.test(text))
522
- return { itemId: 'kubectl_delete_namespace', reason: 'destructive kubectl namespace delete' };
523
- if (/\bkubectl\s+delete\s+(?:clusterrole|secret)\b/i.test(text))
524
- return { itemId: 'generic_kubectl_delete', reason: 'destructive kubectl delete' };
525
- if (/\bkubectl\s+delete\s+all\b(?=.*--all)/i.test(text))
526
- return { itemId: 'kubectl_delete_all_all', reason: 'broad kubectl delete all' };
527
- if (/\bgcloud\s+projects\s+delete\b/i.test(text))
528
- return { itemId: 'gcloud_project_delete', reason: 'GCP project deletion' };
529
- if (/\bgcloud\s+sql\s+instances\s+delete\b/i.test(text))
530
- return { itemId: 'gcloud_sql_delete', reason: 'GCP SQL instance deletion' };
531
- if (/\baws\s+s3\s+rm\s+s3:\/\/\S+\s+--recursive\b/i.test(text))
532
- return { itemId: 'aws_s3_recursive_rm', reason: 'recursive S3 deletion' };
533
- if (/\baws\s+cloudformation\s+delete-stack\b/i.test(text))
534
- return { itemId: 'aws_cloudformation_delete_stack', reason: 'CloudFormation stack deletion' };
535
- if (/\baws\s+iam\s+attach-user-policy\b(?=.*AdministratorAccess)/i.test(text))
536
- return { itemId: 'aws_iam_admin_policy', reason: 'IAM administrator privilege grant' };
537
- if (/\baws\s+iam\s+create-access-key\b/i.test(text))
538
- return { itemId: 'aws_iam_access_key', reason: 'IAM access key creation' };
539
- if (/\baz\s+group\s+delete\b(?=.*(?:--yes|-y))/i.test(text))
540
- return { itemId: 'az_group_delete_yes', reason: 'Azure resource group deletion' };
541
- return undefined;
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 ...)
@@ -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
- type AgentClient = 'cursor' | 'claude-code' | 'claude-desktop' | 'codex' | 'gemini-cli' | 'windsurf' | 'vscode';
3
- export type GatewayClient = AgentClient;
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 || `AgentGuard API error (${resp.status})` };
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 || `AgentGuard API error (${resp.status})` };
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: `[AgentGuard protected] ${tool.description || ''}`.trim(),
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
- let reportOnlyMode = false;
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(`AgentGuard warning (${warning.itemId}): ${warning.reason}\n`);
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
- 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.`);
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(`AgentGuard (monitor): ${reason}\n`);
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(`AgentGuard offline: policy service unreachable (${detail}); ${toolName} checked against locally cached org policies → ${blocking ? `${local.verdict} (report-only)` : 'allow'}.\n`);
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(`AgentGuard policy service unreachable and fail-closed mode is on (${detail}). Tool call blocked.`);
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(`AgentGuard degraded mode: policy service unreachable (${detail}); allowing ${toolName} per fail-open config.\n`);
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(`AgentGuard approval required for ${toolName}. Waiting for human review (${approvalActionId})... Approve at ${queueUrl}\n`);
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(`AgentGuard warning (${warning.itemId}): ${warning.reason}\n`);
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
- 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.`);
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. If the result
1067
- // shape cannot be rewritten (secret hidden in a non-text payload), fail
1068
- // strict and block a mask rule must never leak what it was set to hide.
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
- this.spoolLocalFinding(finding, toolName, operation);
1073
- throw new Error(`${finding.reason} (rule "${finding.itemId}" is set to mask, but this tool response could not be rewritten — blocked instead.)`);
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(`AgentGuard masked a ${finding.itemId} in the ${toolName} response.\n`);
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(`AgentGuard (monitor): ${reason}\n`);
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(`AgentGuard MCP Gateway running for ${gatewayConfig.agentName}. Downstream: ${downstreamLabel}\n`);
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(`AgentGuard MCP Gateway installed for Cursor (${projectScope ? 'project' : 'global'}).`);
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('AgentGuard gateway install summary:');
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 AgentGuard gateway. */
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
- const agentClient = CLIENT_KEY_TO_AGENT[file.clientKey] || 'cursor';
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(`AgentGuard MCP Gateway installed for Claude Code (${scope}).`);
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('AgentGuard MCP Gateway installed for Claude Desktop.');
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(`AgentGuard MCP Gateway installed for Codex (${projectScope ? 'project' : 'global'}).`);
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(`AgentGuard MCP Gateway installed for Gemini CLI (${projectScope ? 'project' : 'user'}).`);
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('AgentGuard MCP Gateway installed for Windsurf.');
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('AgentGuard MCP Gateway installed for VS Code.');
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}`);
@@ -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}\n`);
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=desktop');
658
+ const dashboard = (0, notify_1.consoleUrl)('/agent-security/users?view=machines');
627
659
  console.log('');
628
660
  if (requiredFailures.length === 0) {
629
- console.log(`${GREEN}${BOLD}This machine is protected.${RESET} It reports to your org's AI Fleet and enforces your policies in blocking mode — dangerous actions are stopped, not just logged.`);
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('Onboarding complete. Restart AI clients to load protected configurations.');
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==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
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
- return mode === 'monitor' || mode === 'shadow' ? 'monitor' : 'block';
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 'block';
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 = 'block'`,
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 'monitor') { $global:FcdGuardMode = 'monitor' }`,
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
- /** Whether taint tracking is enabled (opt-out via env). */
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
- /** Whether taint tracking is enabled (opt-out via env). */
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
- return process.env.FCD_TAINT_DISABLED !== 'true' && process.env.FCD_TAINT_DISABLED !== '1';
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} BotGuard Security Scan${RESET}`);
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} BotGuard Credits${RESET}`);
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}`);
@@ -179,7 +179,12 @@ async function getRuntimeBundle(input) {
179
179
  if (cached) {
180
180
  return cachedToEffective(cached);
181
181
  }
182
- return { mode: 'block', version: '', source: 'default' };
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.24.2"
2
+ "version": "1.25.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.24.2",
3
+ "version": "1.25.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {