fullcourtdefense-cli 1.30.0 → 1.32.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.
@@ -0,0 +1,47 @@
1
+ export interface AgentTerminalMarker {
2
+ /** Environment variable name. */
3
+ name: string;
4
+ /** When set, the variable must equal this value; otherwise any non-empty value counts. */
5
+ equals?: string;
6
+ /** Which agent sets it (for messages / docs). */
7
+ agent: string;
8
+ }
9
+ /** Environment markers that an AI agent, not a person, is driving this terminal. */
10
+ export declare const AGENT_TERMINAL_MARKERS: readonly AgentTerminalMarker[];
11
+ export declare function isAgentDrivenTerminal(env?: NodeJS.ProcessEnv): boolean;
12
+ /** Shown (with the rule name) when an ask lands in an agent-driven terminal with no IDE answer behind it. */
13
+ export declare const AGENT_TERMINAL_ASK_BLOCKED: string;
14
+ /** Shown when the IDE prompt already answered for this exact command. */
15
+ export declare const AGENT_TERMINAL_IDE_CONFIRMED = "allowed \u2014 the developer confirmed this exact command in the IDE prompt.";
16
+ /** Pending-marker key the IDE hook wrote for this shell command (see devConfirm.pendingKey). */
17
+ export declare function ideShellPendingKey(command: string): string;
18
+ /** Remembered-confirmation key for a Local Safety ask rule on the shell tool (see devConfirm rememberKey). */
19
+ export declare function ideLocalSafetyRememberKey(ruleId: string): string;
20
+ /** How long a pending IDE marker counts as "the developer just allowed this" (mirrors CONFIRM_PENDING_TTL_MS). */
21
+ export declare const IDE_CONFIRMATION_WINDOW_MS: number;
22
+ /**
23
+ * Pure decision over a parsed dev-confirm ledger: did the IDE prompt already
24
+ * answer for this command / rule? Used by the in-process tests and mirrored
25
+ * by the generated guard code below.
26
+ */
27
+ export declare function ideConfirmedFor(ledger: {
28
+ pending?: Array<{
29
+ key?: string;
30
+ raisedAt?: string;
31
+ }>;
32
+ remembered?: Array<{
33
+ key?: string;
34
+ expiresAt?: string;
35
+ }>;
36
+ } | undefined, command: string, ruleId: string, now?: number): boolean;
37
+ /**
38
+ * Node snippet for the cmd.exe guard and the POSIX checker. Defines
39
+ * `agentTerminal()` and `ideConfirmed(line, ruleId)`; expects `fs`, `crypto`,
40
+ * `os` and `path` to be in scope.
41
+ */
42
+ export declare function buildAgentTerminalJs(): string;
43
+ /**
44
+ * PowerShell snippet for the PSReadLine guard. Defines
45
+ * `Test-FcdAgentTerminal` and `Test-FcdIdeConfirmed -Line -RuleId`.
46
+ */
47
+ export declare function buildAgentTerminalPs(): string;
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IDE_CONFIRMATION_WINDOW_MS = exports.AGENT_TERMINAL_IDE_CONFIRMED = exports.AGENT_TERMINAL_ASK_BLOCKED = exports.AGENT_TERMINAL_MARKERS = void 0;
37
+ exports.isAgentDrivenTerminal = isAgentDrivenTerminal;
38
+ exports.ideShellPendingKey = ideShellPendingKey;
39
+ exports.ideLocalSafetyRememberKey = ideLocalSafetyRememberKey;
40
+ exports.ideConfirmedFor = ideConfirmedFor;
41
+ exports.buildAgentTerminalJs = buildAgentTerminalJs;
42
+ exports.buildAgentTerminalPs = buildAgentTerminalPs;
43
+ /**
44
+ * Agent-driven terminals — ONE definition for the three shell guards.
45
+ *
46
+ * The terminal "ask" (`Run anyway? [y] / [s] / [N]`) exists for a HUMAN at a
47
+ * keyboard. When an AI agent drives the terminal (Cursor's agent-exec shell,
48
+ * Claude Code's Bash tool, ...) the agent is the one that would type the
49
+ * answer — so an ask there is not a control at all. Rule: in an agent-driven
50
+ * terminal the guard never offers y/N. It honors the answer the developer
51
+ * already gave in the IDE prompt for this exact command (the hook raised it a
52
+ * moment ago and the IDE only runs the command after "Allow"), and otherwise
53
+ * blocks with a message that says where the question is answered.
54
+ *
55
+ * Pure: no I/O. The guards render these markers + keys into their generated
56
+ * PowerShell / Node code, and `scripts/test-agent-terminal-ask.js` pins that
57
+ * the rendered code and this module agree.
58
+ */
59
+ const crypto = __importStar(require("crypto"));
60
+ /** Environment markers that an AI agent, not a person, is driving this terminal. */
61
+ exports.AGENT_TERMINAL_MARKERS = [
62
+ { name: 'FCD_AGENT_TERMINAL', equals: '1', agent: 'explicit (org / wrapper opt-in)' },
63
+ { name: 'CURSOR_AGENT', equals: '1', agent: 'Cursor agent' },
64
+ { name: 'CURSOR_EXTENSION_HOST_ROLE', equals: 'agent-exec', agent: 'Cursor agent' },
65
+ { name: 'CLAUDECODE', equals: '1', agent: 'Claude Code' },
66
+ { name: 'CLAUDE_CODE_SESSION_ID', agent: 'Claude Code' },
67
+ { name: 'CODEX_SANDBOX', agent: 'Codex CLI' },
68
+ { name: 'CODEX_THREAD_ID', agent: 'Codex CLI' },
69
+ { name: 'GEMINI_CLI', equals: '1', agent: 'Gemini CLI' },
70
+ { name: 'COPILOT_AGENT', agent: 'GitHub Copilot agent' },
71
+ ];
72
+ function isAgentDrivenTerminal(env = process.env) {
73
+ return exports.AGENT_TERMINAL_MARKERS.some(marker => {
74
+ const value = env[marker.name];
75
+ if (marker.equals !== undefined)
76
+ return value === marker.equals;
77
+ return typeof value === 'string' && value.length > 0;
78
+ });
79
+ }
80
+ /** Shown (with the rule name) when an ask lands in an agent-driven terminal with no IDE answer behind it. */
81
+ exports.AGENT_TERMINAL_ASK_BLOCKED = 'an AI agent is driving this terminal, so nobody can answer this Ask here. Not executed. ' +
82
+ 'The developer answers Allow / Deny in the IDE prompt (the agent must run the command through the IDE hook), ' +
83
+ 'or runs it in their own terminal.';
84
+ /** Shown when the IDE prompt already answered for this exact command. */
85
+ exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed — the developer confirmed this exact command in the IDE prompt.';
86
+ // ---------------------------------------------------------------------------
87
+ // IDE-answer lookup keys. These MUST match devConfirm.ts: the hook raises a
88
+ // pending marker keyed by sha256(`${event}|${tool}|${sha256('cmd:'+command)}`)
89
+ // with event 'shell' and tool 'shell', and a remembered entry keyed by
90
+ // sha256(`shell|local-safety:${ruleId}|${ruleId}`) for Local Safety ask rules.
91
+ // ---------------------------------------------------------------------------
92
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
93
+ /** Pending-marker key the IDE hook wrote for this shell command (see devConfirm.pendingKey). */
94
+ function ideShellPendingKey(command) {
95
+ return sha256(`shell|shell|${sha256(`cmd:${command.trim()}`)}`);
96
+ }
97
+ /** Remembered-confirmation key for a Local Safety ask rule on the shell tool (see devConfirm rememberKey). */
98
+ function ideLocalSafetyRememberKey(ruleId) {
99
+ return sha256(`shell|local-safety:${ruleId}|${ruleId}`);
100
+ }
101
+ /** How long a pending IDE marker counts as "the developer just allowed this" (mirrors CONFIRM_PENDING_TTL_MS). */
102
+ exports.IDE_CONFIRMATION_WINDOW_MS = 10 * 60_000;
103
+ /**
104
+ * Pure decision over a parsed dev-confirm ledger: did the IDE prompt already
105
+ * answer for this command / rule? Used by the in-process tests and mirrored
106
+ * by the generated guard code below.
107
+ */
108
+ function ideConfirmedFor(ledger, command, ruleId, now = Date.now()) {
109
+ if (!ledger)
110
+ return false;
111
+ const pendingKey = ideShellPendingKey(command);
112
+ if ((ledger.pending || []).some(p => p?.key === pendingKey && typeof p.raisedAt === 'string' && now - Date.parse(p.raisedAt) < exports.IDE_CONFIRMATION_WINDOW_MS))
113
+ return true;
114
+ const rememberKey = ideLocalSafetyRememberKey(ruleId);
115
+ return (ledger.remembered || []).some(r => r?.key === rememberKey && typeof r.expiresAt === 'string' && Date.parse(r.expiresAt) > now);
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Rendered snippets for the generated guards (single source of truth).
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Node snippet for the cmd.exe guard and the POSIX checker. Defines
122
+ * `agentTerminal()` and `ideConfirmed(line, ruleId)`; expects `fs`, `crypto`,
123
+ * `os` and `path` to be in scope.
124
+ */
125
+ function buildAgentTerminalJs() {
126
+ const markers = JSON.stringify(exports.AGENT_TERMINAL_MARKERS.map(m => ({ n: m.name, v: m.equals })));
127
+ return [
128
+ `// Agent-driven terminal: never offer y/N to an AI agent (it would answer itself). Honor the IDE prompt's answer for this exact command, else block.`,
129
+ `const AGENT_MARKERS=${markers};`,
130
+ `function agentTerminal(){const e=process.env;return AGENT_MARKERS.some(m=>m.v!==undefined?e[m.n]===m.v:(typeof e[m.n]==='string'&&e[m.n].length>0));}`,
131
+ `const IDE_WINDOW_MS=${exports.IDE_CONFIRMATION_WINDOW_MS};`,
132
+ `function sha(s){return crypto.createHash('sha256').update(s).digest('hex');}`,
133
+ `function ideConfirmed(line,ruleId){try{const ledger=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.fullcourtdefense','dev-confirm.json'),'utf8'));const now=Date.now();const pk=sha('shell|shell|'+sha('cmd:'+line.trim()));if((ledger.pending||[]).some(p=>p&&p.key===pk&&typeof p.raisedAt==='string'&&now-Date.parse(p.raisedAt)<IDE_WINDOW_MS))return true;const rk=sha('shell|local-safety:'+ruleId+'|'+ruleId);return (ledger.remembered||[]).some(r=>r&&r.key===rk&&typeof r.expiresAt==='string'&&Date.parse(r.expiresAt)>now);}catch{return false;}}`,
134
+ `const AGENT_ASK_BLOCKED=${JSON.stringify(exports.AGENT_TERMINAL_ASK_BLOCKED)};`,
135
+ `const AGENT_IDE_CONFIRMED=${JSON.stringify(exports.AGENT_TERMINAL_IDE_CONFIRMED)};`,
136
+ ].join('\n');
137
+ }
138
+ /**
139
+ * PowerShell snippet for the PSReadLine guard. Defines
140
+ * `Test-FcdAgentTerminal` and `Test-FcdIdeConfirmed -Line -RuleId`.
141
+ */
142
+ function buildAgentTerminalPs() {
143
+ const markers = exports.AGENT_TERMINAL_MARKERS
144
+ .map(m => `@{ n = '${m.name}'; v = ${m.equals === undefined ? '$null' : `'${m.equals}'`} }`)
145
+ .join(', ');
146
+ return [
147
+ `# Agent-driven terminal: never offer y/N to an AI agent (it would answer itself).`,
148
+ `# Honor the IDE prompt's answer for this exact command, else block.`,
149
+ `$global:FcdAgentMarkers = @(${markers})`,
150
+ `$global:FcdAgentAskBlocked = '${exports.AGENT_TERMINAL_ASK_BLOCKED.replace(/'/g, "''")}'`,
151
+ `$global:FcdAgentIdeConfirmed = '${exports.AGENT_TERMINAL_IDE_CONFIRMED.replace(/'/g, "''")}'`,
152
+ `function global:Test-FcdAgentTerminal {`,
153
+ ` foreach ($m in $global:FcdAgentMarkers) {`,
154
+ ` $val = [Environment]::GetEnvironmentVariable($m.n)`,
155
+ ` if ($null -ne $m.v) { if ($val -eq $m.v) { return $true } }`,
156
+ ` elseif (-not [string]::IsNullOrEmpty($val)) { return $true }`,
157
+ ` }`,
158
+ ` return $false`,
159
+ `}`,
160
+ `function global:Get-FcdSha256 { param([string]$Text)`,
161
+ ` $sha = [System.Security.Cryptography.SHA256]::Create()`,
162
+ ` try { return (($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Text)) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $sha.Dispose() }`,
163
+ `}`,
164
+ `function global:Test-FcdIdeConfirmed { param([string]$Line, [string]$RuleId)`,
165
+ ` try {`,
166
+ ` $ledgerPath = Join-Path $HOME '.fullcourtdefense\\dev-confirm.json'`,
167
+ ` if (-not (Test-Path -LiteralPath $ledgerPath)) { return $false }`,
168
+ ` $ledger = Get-Content -LiteralPath $ledgerPath -Raw | ConvertFrom-Json`,
169
+ ` $now = [DateTimeOffset]::UtcNow`,
170
+ ` $pk = Get-FcdSha256 ('shell|shell|' + (Get-FcdSha256 ('cmd:' + $Line.Trim())))`,
171
+ ` foreach ($p in @($ledger.pending)) {`,
172
+ ` if ($null -ne $p -and $p.key -eq $pk -and $p.raisedAt) {`,
173
+ ` $age = $now - [DateTimeOffset]::Parse($p.raisedAt, [Globalization.CultureInfo]::InvariantCulture)`,
174
+ ` if ($age.TotalMilliseconds -lt ${exports.IDE_CONFIRMATION_WINDOW_MS}) { return $true }`,
175
+ ` }`,
176
+ ` }`,
177
+ ` $rk = Get-FcdSha256 ('shell|local-safety:' + $RuleId + '|' + $RuleId)`,
178
+ ` foreach ($r in @($ledger.remembered)) {`,
179
+ ` if ($null -ne $r -and $r.key -eq $rk -and $r.expiresAt) {`,
180
+ ` if ([DateTimeOffset]::Parse($r.expiresAt, [Globalization.CultureInfo]::InvariantCulture) -gt $now) { return $true }`,
181
+ ` }`,
182
+ ` }`,
183
+ ` } catch { }`,
184
+ ` return $false`,
185
+ `}`,
186
+ ].join('\n');
187
+ }
@@ -12,4 +12,6 @@ export interface AgentCiArgs {
12
12
  apiKey?: string;
13
13
  apiUrl?: string;
14
14
  }
15
+ /** True when the path lives under the repository being gated (not the runner's home / system dirs). */
16
+ export declare function isInsideRepo(cwd: string, filePath?: string): boolean;
15
17
  export declare function agentCiCommand(args: AgentCiArgs, config: BotGuardConfig): Promise<void>;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isInsideRepo = isInsideRepo;
36
37
  exports.agentCiCommand = agentCiCommand;
37
38
  const crypto = __importStar(require("crypto"));
38
39
  const child_process_1 = require("child_process");
@@ -201,6 +202,13 @@ function resolveChangedFiles(cwd, args) {
201
202
  return null;
202
203
  return new Set(files.map(file => normalizeRelativePath(cwd, file)));
203
204
  }
205
+ /** True when the path lives under the repository being gated (not the runner's home / system dirs). */
206
+ function isInsideRepo(cwd, filePath) {
207
+ if (!filePath)
208
+ return false;
209
+ const rel = path.relative(path.resolve(cwd), path.resolve(cwd, filePath));
210
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
211
+ }
204
212
  function isChangedFile(cwd, changedFiles, filePath) {
205
213
  if (!changedFiles)
206
214
  return true;
@@ -234,7 +242,13 @@ function evaluateAgentGate(args) {
234
242
  const agentFiles = (0, discoverAgentFiles_1.scanAgentFiles)({ cwd })
235
243
  .filter(file => isChangedFile(cwd, changedFiles, file.filePath));
236
244
  const secrets = (0, discoverSecrets_1.scanSecrets)({ cwd, maxEnvDepth: 3 });
245
+ // agent-ci is a gate on the REPOSITORY. The secrets scanner also inspects the
246
+ // host's credential stores (~/.docker/config.json, ~/.kube/config, ~/.aws, …);
247
+ // on a CI runner those belong to the runner image, not to the code under
248
+ // review, and the developer cannot fix them from the PR. Host posture is the
249
+ // `discover` command's job — here only findings inside the repo count.
237
250
  const secretFindings = secrets.findings
251
+ .filter(item => isInsideRepo(cwd, item.filePath))
238
252
  .filter(item => isChangedFile(cwd, changedFiles, item.filePath));
239
253
  const requireGateway = args.requireGateway !== 'false';
240
254
  const requiredControls = parseRequiredControls(args.requireApprovalFor);
@@ -46,6 +46,7 @@ const fs = __importStar(require("fs"));
46
46
  const os = __importStar(require("os"));
47
47
  const path = __importStar(require("path"));
48
48
  const shellGuard_1 = require("./shellGuard");
49
+ const agentTerminal_1 = require("../agentTerminal");
49
50
  const notify_1 = require("../notify");
50
51
  /**
51
52
  * Interactive cmd.exe guard — blocks dangerous commands TYPED in Command Prompt.
@@ -163,11 +164,12 @@ function buildGuardJs(nodePath, cliEntry) {
163
164
  `// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
164
165
  `// made the user's terminal wait for a full CLI process INCLUDING the network`,
165
166
  `// flush after every finding. Monitor mode must never add synchronous latency.`,
166
- `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
167
- `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this cmd window (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
167
+ `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' | 'ide' | 'agent-terminal' — the "ask" outcome, reported with the same facts as an IDE prompt.`,
168
+ `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this cmd window (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;else if(developer==='ide')reason='developer confirmed in the IDE prompt (agent-driven terminal) — '+reason;else if(developer==='agent-terminal')reason='ask cannot be answered in an agent-driven terminal (no IDE confirmation) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered'||developer==='ide')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
168
169
  `function askRemembered(ruleId){try{const m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'));const until=m&&m[ruleId];return typeof until==='number'&&until>Date.now();}catch{return false;}}`,
169
170
  `function rememberAsk(ruleId){try{let m={};try{m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'))||{};}catch{}m[ruleId]=Date.now()+ASK_REMEMBER_MS;fs.mkdirSync(path.dirname(ASK_MEMORY_PATH),{recursive:true});fs.writeFileSync(ASK_MEMORY_PATH,JSON.stringify(m),{encoding:'utf8',mode:0o600});}catch{}}`,
170
- `// One key via choice.exe (ships with Windows): Y run once, S run + remember for this window, N / timeout not executed. FCD_ASK_TEST_ANSWER overrides for the drill.`,
171
+ (0, agentTerminal_1.buildAgentTerminalJs)(),
172
+ `// One key via choice.exe (ships with Windows): Y run once, S run + remember for this window, N / timeout not executed. FCD_ASK_TEST_ANSWER overrides for the drill (a human-only terminal — agent terminals never reach this).`,
171
173
  `function askDeveloper(hit){const forced=process.env.FCD_ASK_TEST_ANSWER;if(forced)return forced;try{const r=spawnSync('choice',['/c','YSN','/n','/t',String(ASK_TIMEOUT_S),'/d','N','/m','Run anyway? [Y] yes [S] yes, remember for this cmd window [N] no ('+ASK_TIMEOUT_S+'s):'],{stdio:'inherit',windowsHide:true});if(r.status===1)return 'y';if(r.status===2)return 's';if(r.status===3)return 'n';}catch{}return 'n';}`,
172
174
  `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);}`,
173
175
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
@@ -179,6 +181,10 @@ function buildGuardJs(nodePath, cliEntry) {
179
181
  `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: '+(hit.action==='ask'?'would ask you to confirm':'would block')+' ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
180
182
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
181
183
  `if(hit.action==='ask'){`,
184
+ ` if(agentTerminal()){`,
185
+ ` if(ideConfirmed(line,hit.id)){console.error('[FullCourtDefense] '+AGENT_IDE_CONFIRMED+' (rule '+hit.id+')');spoolEvent(hit,line,'allow','ide');delegate(args);}`,
186
+ ` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — '+AGENT_ASK_BLOCKED);console.error(unblockHint(hit));spoolEvent(hit,line,'block','agent-terminal');process.exit(1);`,
187
+ ` }`,
182
188
  ` if(askRemembered(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this cmd window.');spoolEvent(hit,line,'allow','remembered');delegate(args);}`,
183
189
  ` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
184
190
  ` console.error(unblockHint(hit));`,
@@ -107,6 +107,22 @@ export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
107
107
  export declare function stripInertDataSegments(text: string): string;
108
108
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
109
109
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
110
+ /**
111
+ * Prompt (chat text) scan. A prompt is TEXT, not an action: the only thing that
112
+ * can leave the machine through it is what is literally in it. So the prompt
113
+ * path decides on exactly two things:
114
+ * 1. an explicit org custom block, and
115
+ * 2. a real secret VALUE pasted into the prompt (with send intent, or next to
116
+ * credential wording — once it is in agent context it can leak anywhere).
117
+ *
118
+ * Mentioning a command (`gh auth token`, `rm -rf /`, `kubectl config view --raw`),
119
+ * a sensitive path or a metadata IP is NOT judged here. Those are ACTIONS and
120
+ * are decided when they happen — beforeShellExecution / file read / MCP call —
121
+ * where the developer's "Ask" answer can actually be collected and the target,
122
+ * cwd and actor are real. A prompt hook has no Allow / Deny to offer, so a rule
123
+ * set to Ask could only degrade to a block here, and a developer asking how a
124
+ * command works would be stopped for typing its name. (action-only decision path)
125
+ */
110
126
  export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
111
127
  export interface DeterministicScanOutcome {
112
128
  /** The finding that stops the action, if any. */
@@ -526,11 +526,35 @@ function customBlock(value, options) {
526
526
  }
527
527
  return undefined;
528
528
  }
529
+ /**
530
+ * A vendor-format match whose body is a DOCUMENTED placeholder, not a credential:
531
+ * a random body never spells a dictionary word like EXAMPLE / SAMPLE /
532
+ * PLACEHOLDER / REDACTED inside it (odds ~ 1 in 36^7), and never runs one
533
+ * character five times. Vendors reserve exactly such bodies for docs (every
534
+ * AWS docs access key ends in EXAMPLE). Structural, so it holds for every format.
535
+ */
536
+ // Same placeholder contract as the policy engine's `plausibleTokenBody`: a
537
+ // documentation word, a run of one character, or a >= 3-char chunk repeated 3+
538
+ // times back to back (`TestTokenTestTokenTestToken…` — how fixtures get padded
539
+ // to length; odds ~ 1 in 62^6 per position in a random body).
540
+ const SECRET_PLACEHOLDER_BODY_RE = /example|sample|placeholder|redacted|dummy|changeme|fake|xxxx|(.)\1{4}|(.{3,})\2{2,}/i;
541
+ function isDocumentedPlaceholderSecret(match) {
542
+ // Private key headers and connection strings are judged as a whole elsewhere.
543
+ if (/^-----BEGIN /.test(match) || /:\/\//.test(match))
544
+ return false;
545
+ return SECRET_PLACEHOLDER_BODY_RE.test(match);
546
+ }
529
547
  function findSecret(value) {
530
548
  for (const pattern of SECRET_PATTERNS) {
531
- const match = value.match(pattern.re);
532
- if (match?.[0])
549
+ const re = new RegExp(pattern.re.source, pattern.re.flags.includes('g') ? pattern.re.flags : `${pattern.re.flags}g`);
550
+ let match;
551
+ while ((match = re.exec(value)) !== null) {
552
+ if (!match[0])
553
+ break;
554
+ if (isDocumentedPlaceholderSecret(match[0]))
555
+ continue;
533
556
  return { itemId: pattern.itemId, label: pattern.label, value: match[0] };
557
+ }
534
558
  }
535
559
  return undefined;
536
560
  }
@@ -844,7 +868,20 @@ function isSensitivePathContext(toolName, candidate) {
844
868
  // remote executor earlier in the same statement) are kept.
845
869
  // 5. Secret-literal rules and honeypot/custom rules never use this view —
846
870
  // a token pasted into a commit message still leaks.
847
- const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
871
+ const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
872
+ // A LANGUAGE interpreter's `-c` / `-e` argument is CODE in another language, not
873
+ // a shell command line. `node -e "console.log('rm -rf /')"` prints a string;
874
+ // `python -c "print('DROP TABLE')"` prints a string. That code only reaches the
875
+ // shell through a process-spawning API — then (and only then) the whole line is
876
+ // kept, exactly like `bash -c`. Structural: API names, not vocabulary.
877
+ const LANG_INTERPRETER_CODE_BEFORE = /\b(?:node|nodejs|deno|bun|python[0-9.]*|py|perl|ruby|php|lua|tsx|ts-node)(?:\.exe)?\s+(?:-c|-e|-r|eval)[= ]\s*$/i;
878
+ const CODE_SPAWNS_PROCESS = /\b(?:child_process|execSync|execFileSync|spawnSync|spawn|execFile|exec|fork|os\.system|os\.popen|os\.exec[lvpe]*|subprocess|Popen|system|passthru|shell_exec|proc_open|popen|Process\.Start|Deno\.run|Deno\.Command|Bun\.spawn)\b/;
879
+ const BACKTICK_SHELL_INTERPRETER = /\b(?:perl|ruby)(?:\.exe)?\s+(?:-c|-e)[= ]\s*$/i;
880
+ function interpreterCodeSpawnsProcess(before, code) {
881
+ if (CODE_SPAWNS_PROCESS.test(code))
882
+ return true;
883
+ return BACKTICK_SHELL_INTERPRETER.test(before) && /`[^`]*`|%x[\[{(]/.test(code);
884
+ }
848
885
  // $( … ) always executes; backtick pairs only with ≥2 chars of content
849
886
  // (PowerShell escapes like `n / `t are single-char and inert).
850
887
  const INTERPOLATES = /\$\(|`[^`]{2,}`/;
@@ -870,6 +907,9 @@ function stripInertDataSegments(text) {
870
907
  if (!/\s/.test(body))
871
908
  return whole; // single token: argv, not prose
872
909
  const before = src.slice(Math.max(0, offset - 80), offset);
910
+ // Language-interpreter code: data unless it spawns a process (see above).
911
+ if (LANG_INTERPRETER_CODE_BEFORE.test(before))
912
+ return interpreterCodeSpawnsProcess(before, body) ? whole : INERT_DATA;
873
913
  if (CODE_CONTEXT_BEFORE.test(before))
874
914
  return whole; // code position: executes
875
915
  if (dq !== undefined && INTERPOLATES.test(body))
@@ -1010,15 +1050,22 @@ function scanDeterministicTextResponse(text, options) {
1010
1050
  const finding = builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
1011
1051
  return finding ? { ...finding, matchedSecret: secret.value } : undefined;
1012
1052
  }
1013
- function looksLikeQuestion(value) {
1014
- return /^(?:what|why|how|explain|describe|is|are|can you explain)\b/i.test(value.trim());
1015
- }
1016
- function bareOrActionIntent(value, action) {
1017
- const trimmed = value.trim();
1018
- if (looksLikeQuestion(trimmed))
1019
- return false;
1020
- return action.test(trimmed) || /^[^\n]{1,240}$/.test(trimmed);
1021
- }
1053
+ /**
1054
+ * Prompt (chat text) scan. A prompt is TEXT, not an action: the only thing that
1055
+ * can leave the machine through it is what is literally in it. So the prompt
1056
+ * path decides on exactly two things:
1057
+ * 1. an explicit org custom block, and
1058
+ * 2. a real secret VALUE pasted into the prompt (with send intent, or next to
1059
+ * credential wording — once it is in agent context it can leak anywhere).
1060
+ *
1061
+ * Mentioning a command (`gh auth token`, `rm -rf /`, `kubectl config view --raw`),
1062
+ * a sensitive path or a metadata IP is NOT judged here. Those are ACTIONS and
1063
+ * are decided when they happen — beforeShellExecution / file read / MCP call —
1064
+ * where the developer's "Ask" answer can actually be collected and the target,
1065
+ * cwd and actor are real. A prompt hook has no Allow / Deny to offer, so a rule
1066
+ * set to Ask could only degrade to a block here, and a developer asking how a
1067
+ * command works would be stopped for typing its name. (action-only decision path)
1068
+ */
1022
1069
  function scanDeterministicPrompt(text, options) {
1023
1070
  const trimmed = text.trim();
1024
1071
  if (!trimmed)
@@ -1038,43 +1085,6 @@ function scanDeterministicPrompt(text, options) {
1038
1085
  if (contextualSecret) {
1039
1086
  return builtIn(contextualSecret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-prompt-contextual-secret', `Blocked prompt containing ${contextualSecret.label}.`, contextualSecret.value, contextualSecret.label, options);
1040
1087
  }
1041
- // Questions are benign for action-oriented rules below, but this check must
1042
- // happen AFTER secret detection: "is this my API secret: <value>?" still
1043
- // leaks a credential into agent context.
1044
- if (looksLikeQuestion(trimmed))
1045
- return undefined;
1046
- const sensitivePath = containsSensitiveCredentialPath(trimmed);
1047
- if (sensitivePath && bareOrActionIntent(trimmed, /\b(?:read|open|cat|show|print|copy|upload|send|exfiltrate)\b/i)) {
1048
- return builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-prompt-sensitive-credential-path', `Blocked prompt targeting ${sensitivePath.label}.`, trimmed, sensitivePath.label, options);
1049
- }
1050
- if (bareOrActionIntent(trimmed, /\b(?:run|execute|launch|start)\b/i)) {
1051
- const reverseShell = reverseShellReason(trimmed);
1052
- if (reverseShell) {
1053
- return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-prompt-reverse-shell', `Blocked prompt containing ${reverseShell.reason}.`, trimmed, reverseShell.reason, options);
1054
- }
1055
- const credentialCmd = credentialCommandReason(trimmed);
1056
- if (credentialCmd) {
1057
- const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-prompt-credential-command', `Blocked prompt containing ${credentialCmd.reason}.`, trimmed, credentialCmd.reason, options);
1058
- if (finding)
1059
- return finding;
1060
- }
1061
- const destructive = destructiveCommandReason(trimmed);
1062
- if (destructive) {
1063
- return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-prompt-destructive-command', `Blocked prompt containing ${destructive.reason}.`, trimmed, destructive.reason, options);
1064
- }
1065
- const infraDestroy = infraDestroyReason(trimmed);
1066
- if (infraDestroy) {
1067
- return builtIn(infraDestroy.itemId, 'infra_destroy', 'infra_destroy', 'local-prompt-infra-destroy', `Blocked prompt containing ${infraDestroy.reason}.`, trimmed, infraDestroy.reason, options);
1068
- }
1069
- }
1070
- const destructiveSql = destructiveSqlReason(trimmed);
1071
- if (destructiveSql && bareOrActionIntent(trimmed, /\b(?:run|execute|query|apply)\b/i)) {
1072
- return builtIn(destructiveSql.itemId, 'destructive_sql', 'destructive_sql', 'local-prompt-destructive-sql', `Blocked prompt containing destructive SQL command: ${destructiveSql.reason}.`, trimmed, destructiveSql.reason, options);
1073
- }
1074
- const metadata = containsMetadataEndpoint(trimmed);
1075
- if (metadata && /\b(?:fetch|request|curl|open|connect|call)\b/i.test(trimmed)) {
1076
- return builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-prompt-cloud-metadata-ssrf', `Blocked prompt targeting ${metadata.label}.`, trimmed, metadata.value, options);
1077
- }
1078
1088
  return undefined;
1079
1089
  }
1080
1090
  function splitCommand(command) {
@@ -668,7 +668,7 @@ function describeMissingUploadCreds(config, creds) {
668
668
  parts.push('no org API key');
669
669
  return parts.join('; ') || 'unknown';
670
670
  }
671
- async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName, extras) {
671
+ async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName, extras, silent = false) {
672
672
  const payload = {
673
673
  connectorName: connectorName || `Desktop MCP · ${host.hostname}`,
674
674
  host,
@@ -736,6 +736,11 @@ async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName
736
736
  // Any successful inventory upload satisfies the daemon's "initial discovery"
737
737
  // requirement — onboard-time, manual, scheduled, or remote-action sweeps all count.
738
738
  (0, discoveryMarker_1.writeDiscoveryUploadMarker)('discover_upload');
739
+ // Silent callers (agent-ci --format sarif|json, scheduled sweeps) own stdout:
740
+ // a human line here would land INSIDE the machine-readable document they are
741
+ // emitting and break the consumer (GitHub code scanning rejects the SARIF).
742
+ if (silent)
743
+ return;
739
744
  const postureNote = extras?.posture ? ` · machine score ${extras.posture.score}/100 (${extras.posture.grade})` : '';
740
745
  console.log(`${COLOR.green}Uploaded ${ingested} MCP server(s) from ${host.hostname} to your AI Inventory${postureNote}.${COLOR.reset}`);
741
746
  }
@@ -1017,7 +1022,7 @@ async function discoverCommand(args, config) {
1017
1022
  console.error(`discover --upload: missing credentials (${describeMissingUploadCreds(config, creds)}). Run \`fullcourtdefense login --token <fleet-enrollment-token>\`.`);
1018
1023
  process.exit(1);
1019
1024
  }
1020
- await upload(found, host, clientCoverage, creds.apiUrl, { apiKey: creds.apiKey, shieldId: creds.shieldId, shieldKey: creds.shieldKey, preferApiKey: !!args.apiKey }, args.connectorName, uploadExtras);
1025
+ await upload(found, host, clientCoverage, creds.apiUrl, { apiKey: creds.apiKey, shieldId: creds.shieldId, shieldKey: creds.shieldKey, preferApiKey: !!args.apiKey }, args.connectorName, uploadExtras, silent || args.json === 'true');
1021
1026
  }
1022
1027
  if (args.json === 'true') {
1023
1028
  await maybeUpload();
@@ -53,6 +53,41 @@ export declare function normalizeClaudePayload(payload: Record<string, unknown>)
53
53
  event: HookEvent | 'ignore';
54
54
  payload: Record<string, unknown>;
55
55
  } | null;
56
+ export declare function canonicalBuiltinToolName(toolName: string): string;
57
+ /**
58
+ * Map a built-in IDE tool (any client) onto our event taxonomy. Returns null
59
+ * for a tool name this layer does not recognize so the caller keeps its own
60
+ * default (Claude: ignore; Cursor: treat as an MCP tool).
61
+ */
62
+ export declare function mapBuiltinToolPayload(toolName: string, input: Record<string, unknown>, payload: Record<string, unknown>): {
63
+ event: HookEvent | 'ignore';
64
+ payload: Record<string, unknown>;
65
+ } | null;
66
+ /**
67
+ * Cursor delivers its OWN built-in tools (Shell / Read / Write / Grep / …)
68
+ * through the same execution hook as MCP servers, with `tool_use_id` + no
69
+ * server identity (`mcp_server_name` / `url` / `command` are MCP-only). Route
70
+ * them through the shared taxonomy so an IDE edit is a FILE action, a command
71
+ * is a SHELL action, and neither is ever judged as an unknown MCP tool.
72
+ *
73
+ * Shell is dropped here on purpose: Cursor also fires `beforeShellExecution`
74
+ * for the same command, which is the authoritative shell surface — evaluating
75
+ * it twice produced two (sometimes different) verdicts for one action.
76
+ */
77
+ export declare function normalizeCursorBuiltinTool(payload: Record<string, unknown>): {
78
+ event: HookEvent | 'ignore';
79
+ payload: Record<string, unknown>;
80
+ } | null;
81
+ /**
82
+ * Cursor-format entry: infer the event from the `--event` flag / payload shape,
83
+ * then route Cursor built-in tools through the shared taxonomy. Exported so the
84
+ * IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
85
+ */
86
+ export declare function canonicalizeCursorPayload(argEvent: string | undefined, payload: Record<string, unknown>): {
87
+ event: HookEvent;
88
+ payload: Record<string, unknown>;
89
+ ignore: boolean;
90
+ };
56
91
  /**
57
92
  * Map a Cursor execution-hook payload onto an Action Policy tool call.
58
93
  * Returns null when there is nothing actionable to evaluate.