fullcourtdefense-cli 1.39.2 → 1.40.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.
Files changed (51) hide show
  1. package/dist/agentTerminal.d.ts +17 -9
  2. package/dist/agentTerminal.js +44 -32
  3. package/dist/askDialog.js +7 -0
  4. package/dist/callerEnv.d.ts +6 -0
  5. package/dist/callerEnv.js +9 -0
  6. package/dist/commands/cmdGuard.js +1 -1
  7. package/dist/commands/daemon.js +161 -62
  8. package/dist/commands/deterministicGuard.js +18 -0
  9. package/dist/commands/discover.d.ts +1 -0
  10. package/dist/commands/discover.js +1 -0
  11. package/dist/commands/discoverPaths.d.ts +34 -2
  12. package/dist/commands/discoverPaths.js +90 -7
  13. package/dist/commands/discoverProxy.d.ts +12 -3
  14. package/dist/commands/discoverProxy.js +17 -4
  15. package/dist/commands/hook.d.ts +10 -10
  16. package/dist/commands/hook.js +81 -129
  17. package/dist/commands/hookConfigFile.d.ts +34 -0
  18. package/dist/commands/hookConfigFile.js +104 -0
  19. package/dist/commands/installClaudeHook.d.ts +28 -1
  20. package/dist/commands/installClaudeHook.js +90 -21
  21. package/dist/commands/installCursorHook.d.ts +7 -1
  22. package/dist/commands/installCursorHook.js +46 -22
  23. package/dist/commands/mcpGateway.d.ts +10 -0
  24. package/dist/commands/mcpGateway.js +62 -7
  25. package/dist/commands/posixShellGuard.js +1 -1
  26. package/dist/commands/protectionRepair.d.ts +3 -0
  27. package/dist/commands/protectionRepair.js +2 -0
  28. package/dist/commands/shellGuard.js +1 -1
  29. package/dist/devConfirm.d.ts +5 -1
  30. package/dist/distress.d.ts +8 -0
  31. package/dist/distress.js +8 -0
  32. package/dist/gatewayRuntime.d.ts +11 -0
  33. package/dist/gatewayRuntime.js +78 -0
  34. package/dist/housekeeping.d.ts +26 -4
  35. package/dist/housekeeping.js +209 -48
  36. package/dist/housekeepingOps.d.ts +6 -0
  37. package/dist/housekeepingOps.js +51 -0
  38. package/dist/housekeepingWorker.js +3 -1
  39. package/dist/integrity.d.ts +1 -1
  40. package/dist/integrity.js +12 -2
  41. package/dist/machineIdentity.js +4 -0
  42. package/dist/nativeHook.d.ts +46 -11
  43. package/dist/nativeHook.js +271 -70
  44. package/dist/terminalAllowance.d.ts +24 -0
  45. package/dist/terminalAllowance.js +156 -0
  46. package/dist/testSandbox.d.ts +4 -0
  47. package/dist/testSandbox.js +100 -0
  48. package/dist/toolTaxonomy.d.ts +100 -0
  49. package/dist/toolTaxonomy.js +193 -0
  50. package/dist/version.json +1 -1
  51. package/package.json +2 -1
@@ -11,16 +11,18 @@ export declare const AGENT_TERMINAL_MARKERS: readonly AgentTerminalMarker[];
11
11
  export declare function isAgentDrivenTerminal(env?: NodeJS.ProcessEnv): boolean;
12
12
  /** Shown (with the rule name) when an ask lands in an agent-driven terminal with no IDE answer behind it. */
13
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 through the IDE permission gate; confirmation is recorded by the IDE post-execution hook.";
16
- /** Pending-marker key the IDE hook wrote for this shell command (see devConfirm.pendingKey). */
14
+ /** Shown when the developer already said Allow for this exact command in the FullCourtDefense dialog (one use). */
15
+ export declare const AGENT_TERMINAL_IDE_CONFIRMED = "allowed: the developer said Allow for this exact command in the FullCourtDefense dialog (that answer is used once).";
16
+ /** Key of this shell command + directory: the hook's marker key (see devConfirm.pendingKey), and the file name of the dialog's single-use answer (terminalAllowance.ts). */
17
17
  export declare function ideShellPendingKey(command: string, cwd?: string): string;
18
- /** Maximum lifetime of an exact-command IDE permission handoff (not consent). */
18
+ /** How long a pending marker lives (the ledger's TTL). Kept for the callers that name it; a pending marker has not been answered and the guards no longer read it. */
19
19
  export declare const IDE_CONFIRMATION_WINDOW_MS: number;
20
20
  /**
21
- * Pure decision over a parsed dev-confirm ledger: did the IDE prompt already
22
- * answer for this command / rule? Used by the in-process tests and mirrored
23
- * by the generated guard code below.
21
+ * Pure decision over a parsed dev-confirm ledger: did the developer already
22
+ * answer for this command / rule? Never — a ledger entry is a question or a
23
+ * remembered rule, neither of which a terminal guard can match to this exact
24
+ * command with the hook's complete context. The answer a guard honors is the
25
+ * dialog's single-use file (terminalAllowance.consumeTerminalAllowance).
24
26
  */
25
27
  export declare function ideConfirmedFor(ledger: {
26
28
  formatVersion?: number;
@@ -38,11 +40,17 @@ export declare function ideConfirmedFor(ledger: {
38
40
  /**
39
41
  * Node snippet for the cmd.exe guard and the POSIX checker. Defines
40
42
  * `agentTerminal()` and `ideConfirmed(line, ruleId)`; expects `fs`, `crypto`,
41
- * `os` and `path` to be in scope.
43
+ * `os` and `path` to be in scope. `ideConfirmed` is the guard's side of
44
+ * terminalAllowance.consumeTerminalAllowance: it reads the dialog's answer for
45
+ * this exact command + directory, checks it is live and (where both sides know a
46
+ * session) the same session's, then DELETES it — the delete is the claim, so the
47
+ * answer is honored once. Anything else, a pending marker included, is false.
42
48
  */
43
49
  export declare function buildAgentTerminalJs(): string;
44
50
  /**
45
51
  * PowerShell snippet for the PSReadLine guard. Defines
46
- * `Test-FcdAgentTerminal` and `Test-FcdIdeConfirmed -Line -RuleId`.
52
+ * `Test-FcdAgentTerminal` and `Test-FcdIdeConfirmed -Line -RuleId` (the same
53
+ * consume step as the Node snippet: read the dialog's answer for this exact
54
+ * command + directory, check it, delete it — once).
47
55
  */
48
56
  export declare function buildAgentTerminalPs(): string;
@@ -46,16 +46,20 @@ exports.buildAgentTerminalPs = buildAgentTerminalPs;
46
46
  * keyboard. When an AI agent drives the terminal (Cursor's agent-exec shell,
47
47
  * Claude Code's Bash tool, ...) the agent is the one that would type the
48
48
  * answer — so an ask there is not a control at all. Rule: in an agent-driven
49
- * terminal the guard never offers y/N. It honors the answer the developer
50
- * gave in the IDE, or delegates an explicitly marked exact-command handoff
51
- * to the IDE permission gate. Handoffs are never recorded as consent. Otherwise
52
- * blocks with a message that says where the question is answered.
49
+ * terminal the guard never offers y/N. It honors ONE thing: the developer's
50
+ * Allow, given in the FullCourtDefense dialog for this exact command and
51
+ * directory, which the dialog leaves as a single-use answer (terminalAllowance.ts)
52
+ * that the guard consumes. A PENDING ask marker — the hook raises it before
53
+ * anybody is asked, and a denied or abandoned IDE card leaves it standing — is
54
+ * never read as an answer (APPR-13). Otherwise the guard blocks with a message
55
+ * that says where the question is answered.
53
56
  *
54
57
  * Pure: no I/O. The guards render these markers + keys into their generated
55
58
  * PowerShell / Node code, and `scripts/test-agent-terminal-ask.js` pins that
56
59
  * the rendered code and this module agree.
57
60
  */
58
61
  const crypto = __importStar(require("crypto"));
62
+ const terminalAllowance_1 = require("./terminalAllowance");
59
63
  /** Environment markers that an AI agent, not a person, is driving this terminal. */
60
64
  exports.AGENT_TERMINAL_MARKERS = [
61
65
  { name: 'FCD_AGENT_TERMINAL', equals: '1', agent: 'explicit (org / wrapper opt-in)' },
@@ -80,23 +84,25 @@ function isAgentDrivenTerminal(env = process.env) {
80
84
  exports.AGENT_TERMINAL_ASK_BLOCKED = 'an AI agent is driving this terminal, so nobody can answer this Ask here. Not executed. ' +
81
85
  'The developer answers Allow / Deny in the IDE prompt (the agent must run the command through the IDE hook), ' +
82
86
  'or runs it in their own terminal.';
83
- /** Shown when the IDE prompt already answered for this exact command. */
84
- exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed through the IDE permission gate; confirmation is recorded by the IDE post-execution hook.';
87
+ /** Shown when the developer already said Allow for this exact command in the FullCourtDefense dialog (one use). */
88
+ exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed: the developer said Allow for this exact command in the FullCourtDefense dialog (that answer is used once).';
85
89
  // ---------------------------------------------------------------------------
86
- // Exact command + working directory handoff keys match devConfirm v3.
87
- // A handoff delegates to the IDE permission gate; it is never consent.
90
+ // Exact command + working directory keys match devConfirm v3: the dialog's
91
+ // Allow is stored under the key the hook's marker had (terminalAllowance.ts).
88
92
  // ---------------------------------------------------------------------------
89
93
  const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
90
- /** Pending-marker key the IDE hook wrote for this shell command (see devConfirm.pendingKey). */
94
+ /** Key of this shell command + directory: the hook's marker key (see devConfirm.pendingKey), and the file name of the dialog's single-use answer (terminalAllowance.ts). */
91
95
  function ideShellPendingKey(command, cwd = process.cwd()) {
92
96
  return sha256(JSON.stringify(['shell', 'shell', sha256(`args-v3:${JSON.stringify({ command, cwd })}`)]));
93
97
  }
94
- /** Maximum lifetime of an exact-command IDE permission handoff (not consent). */
98
+ /** How long a pending marker lives (the ledger's TTL). Kept for the callers that name it; a pending marker has not been answered and the guards no longer read it. */
95
99
  exports.IDE_CONFIRMATION_WINDOW_MS = 10 * 60_000;
96
100
  /**
97
- * Pure decision over a parsed dev-confirm ledger: did the IDE prompt already
98
- * answer for this command / rule? Used by the in-process tests and mirrored
99
- * by the generated guard code below.
101
+ * Pure decision over a parsed dev-confirm ledger: did the developer already
102
+ * answer for this command / rule? Never — a ledger entry is a question or a
103
+ * remembered rule, neither of which a terminal guard can match to this exact
104
+ * command with the hook's complete context. The answer a guard honors is the
105
+ * dialog's single-use file (terminalAllowance.consumeTerminalAllowance).
100
106
  */
101
107
  function ideConfirmedFor(ledger, command, ruleId, now = Date.now(), sessionId) {
102
108
  // Terminal guards lack the hook's complete policy/context snapshot. They
@@ -109,24 +115,30 @@ function ideConfirmedFor(ledger, command, ruleId, now = Date.now(), sessionId) {
109
115
  /**
110
116
  * Node snippet for the cmd.exe guard and the POSIX checker. Defines
111
117
  * `agentTerminal()` and `ideConfirmed(line, ruleId)`; expects `fs`, `crypto`,
112
- * `os` and `path` to be in scope.
118
+ * `os` and `path` to be in scope. `ideConfirmed` is the guard's side of
119
+ * terminalAllowance.consumeTerminalAllowance: it reads the dialog's answer for
120
+ * this exact command + directory, checks it is live and (where both sides know a
121
+ * session) the same session's, then DELETES it — the delete is the claim, so the
122
+ * answer is honored once. Anything else, a pending marker included, is false.
113
123
  */
114
124
  function buildAgentTerminalJs() {
115
125
  const markers = JSON.stringify(exports.AGENT_TERMINAL_MARKERS.map(m => ({ n: m.name, v: m.equals })));
116
126
  return [
117
- `// 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.`,
127
+ `// Agent-driven terminal: never offer y/N to an AI agent (it would answer itself). Honor the developer's Allow in the FullCourtDefense dialog for this exact command (single use), else block.`,
118
128
  `const AGENT_MARKERS=${markers};`,
119
129
  `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));}`,
120
- `const IDE_WINDOW_MS=${exports.IDE_CONFIRMATION_WINDOW_MS};`,
130
+ `const ALLOW_MAX_MS=${terminalAllowance_1.TERMINAL_ALLOWANCE_TTL_MS};`,
121
131
  `function sha(s){return crypto.createHash('sha256').update(s).digest('hex');}`,
122
- `function ideConfirmed(line,ruleId){try{const ledger=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.fullcourtdefense','dev-confirm.json'),'utf8'));if(ledger.formatVersion!==3)return false;const now=Date.now();const pk=sha(JSON.stringify(['shell','shell',sha('args-v3:'+JSON.stringify({command:line,cwd:process.cwd()}))]));const session=process.env.FCD_SESSION_ID||process.env.CLAUDE_CODE_SESSION_ID||process.env.CODEX_THREAD_ID;if(!session)return false;return (ledger.pending||[]).some(p=>p&&p.permissionHandoff===true&&p.sessionId===session&&p.key===pk&&typeof p.raisedAt==='string'&&now>=Date.parse(p.raisedAt)&&now-Date.parse(p.raisedAt)<IDE_WINDOW_MS);}catch{return false;}}`,
132
+ `function ideConfirmed(line,ruleId){try{const pk=sha(JSON.stringify(['shell','shell',sha('args-v3:'+JSON.stringify({command:line,cwd:process.cwd()}))]));const file=path.join(os.homedir(),'.fullcourtdefense','terminal-allow',pk+'.json');let a;try{a=JSON.parse(fs.readFileSync(file,'utf8'));}catch{return false;}const now=Date.now(),at=Date.parse(a.confirmedAt),until=Date.parse(a.expiresAt);if(a.formatVersion!==1||a.key!==pk||!(now>=at&&now<until&&until-at<=ALLOW_MAX_MS))return false;const session=process.env.FCD_SESSION_ID||process.env.CLAUDE_CODE_SESSION_ID||process.env.CODEX_THREAD_ID;if(a.sessionId&&session&&a.sessionId!==session)return false;fs.unlinkSync(file);return true;}catch{return false;}}`,
123
133
  `const AGENT_ASK_BLOCKED=${JSON.stringify(exports.AGENT_TERMINAL_ASK_BLOCKED)};`,
124
134
  `const AGENT_IDE_CONFIRMED=${JSON.stringify(exports.AGENT_TERMINAL_IDE_CONFIRMED)};`,
125
135
  ].join('\n');
126
136
  }
127
137
  /**
128
138
  * PowerShell snippet for the PSReadLine guard. Defines
129
- * `Test-FcdAgentTerminal` and `Test-FcdIdeConfirmed -Line -RuleId`.
139
+ * `Test-FcdAgentTerminal` and `Test-FcdIdeConfirmed -Line -RuleId` (the same
140
+ * consume step as the Node snippet: read the dialog's answer for this exact
141
+ * command + directory, check it, delete it — once).
130
142
  */
131
143
  function buildAgentTerminalPs() {
132
144
  const markers = exports.AGENT_TERMINAL_MARKERS
@@ -134,7 +146,7 @@ function buildAgentTerminalPs() {
134
146
  .join(', ');
135
147
  return [
136
148
  `# Agent-driven terminal: never offer y/N to an AI agent (it would answer itself).`,
137
- `# Honor the IDE prompt's answer for this exact command, else block.`,
149
+ `# Honor the developer's Allow in the FullCourtDefense dialog for this exact command (single use), else block.`,
138
150
  `$global:FcdAgentMarkers = @(${markers})`,
139
151
  `$global:FcdAgentAskBlocked = '${exports.AGENT_TERMINAL_ASK_BLOCKED.replace(/'/g, "''")}'`,
140
152
  `$global:FcdAgentIdeConfirmed = '${exports.AGENT_TERMINAL_IDE_CONFIRMED.replace(/'/g, "''")}'`,
@@ -152,22 +164,22 @@ function buildAgentTerminalPs() {
152
164
  `}`,
153
165
  `function global:Test-FcdIdeConfirmed { param([string]$Line, [string]$RuleId)`,
154
166
  ` try {`,
155
- ` $ledgerPath = Join-Path $HOME '.fullcourtdefense\\dev-confirm.json'`,
156
- ` if (-not (Test-Path -LiteralPath $ledgerPath)) { return $false }`,
157
- ` $ledger = Get-Content -LiteralPath $ledgerPath -Raw | ConvertFrom-Json`,
158
- ` if ($ledger.formatVersion -ne 3) { return $false }`,
159
- ` $session = $env:FCD_SESSION_ID; if (-not $session) { $session = $env:CLAUDE_CODE_SESSION_ID }; if (-not $session) { $session = $env:CODEX_THREAD_ID }; if (-not $session) { return $false }`,
160
- ` $now = [DateTimeOffset]::UtcNow`,
161
167
  ` $argsJson = ConvertTo-Json -Compress -InputObject ([ordered]@{command=$Line;cwd=(Get-Location).ProviderPath})`,
162
168
  ` $digest = Get-FcdSha256 ('args-v3:' + $argsJson)`,
163
169
  ` $pk = Get-FcdSha256 (ConvertTo-Json -Compress -InputObject @('shell','shell',$digest))`,
164
- ` foreach ($p in @($ledger.pending)) {`,
165
- ` if ($null -ne $p -and $p.permissionHandoff -eq $true -and $p.sessionId -ceq $session -and $p.key -ceq $pk -and $p.raisedAt) {`,
166
- ` $raised = if ($p.raisedAt -is [DateTime]) { [DateTimeOffset]$p.raisedAt } else { [DateTimeOffset]::Parse([string]$p.raisedAt, [Globalization.CultureInfo]::InvariantCulture) }`,
167
- ` $age = $now - $raised`,
168
- ` if ($age.TotalMilliseconds -ge 0 -and $age.TotalMilliseconds -lt ${exports.IDE_CONFIRMATION_WINDOW_MS}) { return $true }`,
169
- ` }`,
170
- ` }`,
170
+ ` $file = Join-Path $HOME ('.fullcourtdefense\\terminal-allow\\' + $pk + '.json')`,
171
+ ` if (-not (Test-Path -LiteralPath $file)) { return $false }`,
172
+ ` $a = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json`,
173
+ ` if ($a.formatVersion -ne 1 -or $a.key -cne $pk -or -not $a.confirmedAt -or -not $a.expiresAt) { return $false }`,
174
+ ` $now = [DateTimeOffset]::UtcNow`,
175
+ ` $at = if ($a.confirmedAt -is [DateTime]) { [DateTimeOffset]$a.confirmedAt } else { [DateTimeOffset]::Parse([string]$a.confirmedAt, [Globalization.CultureInfo]::InvariantCulture) }`,
176
+ ` $until = if ($a.expiresAt -is [DateTime]) { [DateTimeOffset]$a.expiresAt } else { [DateTimeOffset]::Parse([string]$a.expiresAt, [Globalization.CultureInfo]::InvariantCulture) }`,
177
+ ` if ($now -lt $at -or $now -ge $until -or ($until - $at).TotalMilliseconds -gt ${terminalAllowance_1.TERMINAL_ALLOWANCE_TTL_MS}) { return $false }`,
178
+ ` $session = $env:FCD_SESSION_ID; if (-not $session) { $session = $env:CLAUDE_CODE_SESSION_ID }; if (-not $session) { $session = $env:CODEX_THREAD_ID }`,
179
+ ` if ($a.sessionId -and $session -and $a.sessionId -cne $session) { return $false }`,
180
+ ` # The delete is the claim: if another guard took the answer first this throws and the line is NOT allowed.`,
181
+ ` Remove-Item -LiteralPath $file -Force -ErrorAction Stop`,
182
+ ` return $true`,
171
183
  ` } catch { }`,
172
184
  ` return $false`,
173
185
  `}`,
package/dist/askDialog.js CHANGED
@@ -32,6 +32,7 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
32
32
  * grant is bound to that rule's hash, and nothing here re-evaluates the action.
33
33
  */
34
34
  const devConfirm_1 = require("./devConfirm");
35
+ const terminalAllowance_1 = require("./terminalAllowance");
35
36
  const telemetry_1 = require("./telemetry");
36
37
  const actionIdentity_1 = require("./actionIdentity");
37
38
  const hookIo_1 = require("./hookIo");
@@ -236,6 +237,12 @@ async function resolveAskWithDialog(verdict, options = {}) {
236
237
  // The MCP gateway sees this exact call next; do not ask the person twice.
237
238
  if (envelope.event === 'mcp')
238
239
  (0, devConfirm_1.recordConfirmedAction)(envelope.toolName, envelope.argsDigest);
240
+ // APPR-13: the same for an agent-driven TERMINAL. If the IDE now runs this exact command in a terminal
241
+ // guarded by an `ask` rule, the guard must not ask again (an agent would answer itself) and must not
242
+ // block what the developer just allowed: ONE single-use answer for this command + directory. Only
243
+ // this Allow writes it — never the pending marker, a denied card or a timeout.
244
+ if (envelope.event === 'shell')
245
+ (0, terminalAllowance_1.recordTerminalAllowance)((0, devConfirm_1.pendingKey)(envelope.event, envelope.toolName, envelope.argsDigest), envelopeIdentity(envelope).sessionId);
239
246
  (0, telemetry_1.spoolEvent)({
240
247
  decision: 'allow',
241
248
  toolName: envelope.toolName,
@@ -80,6 +80,12 @@ export declare function envFlag(value: string | undefined): boolean;
80
80
  export declare function testRunMarkers(env?: NodeJS.ProcessEnv): string[];
81
81
  /** A test run that promised no real OS dialog or notification on this desktop (devConfirm, notify). */
82
82
  export declare function noRealUiUnderTest(env?: NodeJS.ProcessEnv): boolean;
83
+ /**
84
+ * The scan / config-rewrite / machine-identity containment (testSandbox.ts)
85
+ * when a suite set FCD_TEST_SANDBOX_ROOT, else undefined. Loaded only then, so
86
+ * it never joins a hook's module graph on an install.
87
+ */
88
+ export declare function testSandboxContainment(env?: NodeJS.ProcessEnv): typeof import('./testSandbox') | undefined;
83
89
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
84
90
  export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
85
91
  /**
package/dist/callerEnv.js CHANGED
@@ -11,6 +11,7 @@ exports.ideWaitCutoff = ideWaitCutoff;
11
11
  exports.envFlag = envFlag;
12
12
  exports.testRunMarkers = testRunMarkers;
13
13
  exports.noRealUiUnderTest = noRealUiUnderTest;
14
+ exports.testSandboxContainment = testSandboxContainment;
14
15
  exports.isCiEnvironment = isCiEnvironment;
15
16
  exports.isHumanPresent = isHumanPresent;
16
17
  const async_hooks_1 = require("async_hooks");
@@ -183,6 +184,14 @@ function testRunMarkers(env = process.env) {
183
184
  function noRealUiUnderTest(env = process.env) {
184
185
  return envFlag(env.FCD_TEST_NO_REAL_DIALOG) || !!env.FCD_TEST_SANDBOX_ROOT;
185
186
  }
187
+ /**
188
+ * The scan / config-rewrite / machine-identity containment (testSandbox.ts)
189
+ * when a suite set FCD_TEST_SANDBOX_ROOT, else undefined. Loaded only then, so
190
+ * it never joins a hook's module graph on an install.
191
+ */
192
+ function testSandboxContainment(env = process.env) {
193
+ return env.FCD_TEST_SANDBOX_ROOT ? require('./testSandbox') : undefined;
194
+ }
186
195
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
187
196
  function isCiEnvironment(env = callerEnv()) {
188
197
  return env.GITHUB_ACTIONS === 'true'
@@ -168,7 +168,7 @@ function buildGuardJs(nodePath, cliEntry) {
168
168
  `// made the user's terminal wait for a full CLI process INCLUDING the network`,
169
169
  `// flush after every finding. Monitor mode must never add synchronous latency.`,
170
170
  `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' | 'ide' | 'agent-terminal' — the "ask" outcome, reported with the same facts as an IDE prompt.`,
171
- `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='delegated to the IDE permission gate (confirmation recorded by post-hook) — '+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')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{}}`,
171
+ `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='the developer said Allow in the FullCourtDefense dialog, used once for this exact command — '+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')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{}}`,
172
172
  `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;}}`,
173
173
  `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{}}`,
174
174
  (0, agentTerminal_1.buildAgentTerminalJs)(),
@@ -146,9 +146,20 @@ const BUNDLE_FETCH_TIMEOUT_MS = envMs('FCD_DAEMON_BUNDLE_TIMEOUT_MS', 10_000);
146
146
  * the work each poll actually does — a bundle poll is a 10s fetch plus an 8s
147
147
  * snapshot refresh, a heartbeat is a spool flush plus a log upload — so these
148
148
  * only ever fire on a genuinely hung socket, never on a merely slow network.
149
+ * The heartbeat is ONLY the liveness report: the integrity check (a walk of
150
+ * the scan roots on a loaded laptop) runs apart from it, with its own signal.
149
151
  */
150
152
  const BUNDLE_POLL_DEADLINE_MS = envMs('FCD_DAEMON_BUNDLE_DEADLINE_MS', 45_000);
151
153
  const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000);
154
+ /** An integrity check (including its repair) slower than this is a `housekeeping_slow` distress signal. */
155
+ const INTEGRITY_SLOW_MS = envMs('FCD_DAEMON_INTEGRITY_SLOW_MS', 60_000);
156
+ /**
157
+ * The heartbeat's integrity check re-reads the project configs the last
158
+ * scan-root walk found when that walk is no older than this (three rescan
159
+ * periods): the rescan is the one deep walk per cycle, and it is what finds a
160
+ * new config (and repairs it as a discovery).
161
+ */
162
+ const INTEGRITY_WALK_REUSE_MS = envMs('FCD_DAEMON_WALK_REUSE_MS', 3 * RESCAN_INTERVAL_MS);
152
163
  /**
153
164
  * Wake-from-sleep detection. Timers are the only awake clock we have: a beat
154
165
  * that observes far more wall time than its own interval proves the machine
@@ -874,6 +885,17 @@ async function runDaemon(args, config) {
874
885
  let observedConfigurations = new Map();
875
886
  let refreshPending = false;
876
887
  let lastIntegrityHealFailureAt;
888
+ /**
889
+ * The newest integrity verdict this daemon computed by any path (a protection
890
+ * pass's inspection, the heartbeat's check, a post-repair verification). The
891
+ * liveness report carries it without waiting for a new check.
892
+ */
893
+ let lastIntegrity;
894
+ let integrityCheckInFlight = false;
895
+ const noteIntegrity = (report) => {
896
+ lastIntegrity = report;
897
+ return report;
898
+ };
877
899
  let suspended = false;
878
900
  let stopped = false;
879
901
  const executingActionIds = new Set();
@@ -1025,7 +1047,7 @@ async function runDaemon(args, config) {
1025
1047
  void (async () => {
1026
1048
  let verified = false;
1027
1049
  try {
1028
- verified = !repairFailed && (await housekeeping.run({ op: 'integrity', requireDaemon: true })).ok;
1050
+ verified = !repairFailed && noteIntegrity(await housekeeping.run({ op: 'integrity', requireDaemon: true })).ok;
1029
1051
  }
1030
1052
  catch { /* cannot verify */ }
1031
1053
  if (stopped || suspended)
@@ -1087,8 +1109,12 @@ async function runDaemon(args, config) {
1087
1109
  }
1088
1110
  repairInFlight = (async () => {
1089
1111
  const pass = `repair-${++passSeq}`;
1112
+ // The newest verdict this pass saw: a pass that fails leaves its config
1113
+ // as the last inspection found it, and that is what a report must say.
1114
+ let inspected;
1115
+ const inspect = async () => (inspected = await inspectProtection(pass));
1090
1116
  try {
1091
- const before = await inspectProtection(pass);
1117
+ const before = await inspect();
1092
1118
  // The inspection ran in the housekeeping worker; an admin suspension
1093
1119
  // (or shutdown) may have arrived meanwhile. A suspended machine is not
1094
1120
  // re-protected, whenever the pass was asked for.
@@ -1102,6 +1128,7 @@ async function runDaemon(args, config) {
1102
1128
  // content change still runs the full idempotent repair pipeline: the
1103
1129
  // integrity summary does not cover every hook migration/companion.
1104
1130
  if ((cause === 'integrity' || cause === 'discovery') && !repairFailed && !(0, integrity_1.hasHealableIntegrityReason)(before.reasons)) {
1131
+ noteIntegrity(before);
1105
1132
  reportProtectionHealth(before.ok && !repairFailed);
1106
1133
  return before;
1107
1134
  }
@@ -1136,8 +1163,10 @@ async function runDaemon(args, config) {
1136
1163
  else
1137
1164
  repairWindowKnown = false;
1138
1165
  await refreshWatchTargets(pass);
1139
- integrity = await inspectProtection(pass);
1140
- log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
1166
+ integrity = await inspect();
1167
+ // F-08: a hook file that is not valid JSON is left alone (it holds the developer's settings; a backup copy was kept) and named here.
1168
+ const hookState = (healthy, unreadable) => unreadable ? 'unreadable, left untouched' : healthy ? 'healthy' : 'unhealthy';
1169
+ log(`Re-protection pass complete (Cursor hooks ${hookState(repaired.cursorHooksHealthy, repaired.cursorHooksUnreadable)}, Claude hooks ${hookState(repaired.claudeHooksHealthy, repaired.claudeHooksUnreadable)}).`);
1141
1170
  if (!(0, integrity_1.hasHealableIntegrityReason)(integrity.reasons) || suspended || stopped)
1142
1171
  break;
1143
1172
  if (attempt === 1)
@@ -1150,6 +1179,9 @@ async function runDaemon(args, config) {
1150
1179
  const external = changedFiles.filter(file => !wrote.includes(file));
1151
1180
  noteRepairWrites(wrote);
1152
1181
  repairFailed = false;
1182
+ // The verdict after the pass, never the one before it: a heartbeat in
1183
+ // between must not report damage this pass is already repairing.
1184
+ noteIntegrity(integrity);
1153
1185
  log(`Re-protection verified: ${integrity.ok ? 'healthy' : 'incomplete'}; `
1154
1186
  + (repairWindowKnown
1155
1187
  ? `wrote ${wrote.length} config file(s)${namedFiles(wrote)}; external changes during the pass: ${external.length}${namedFiles(external)}`
@@ -1165,6 +1197,8 @@ async function runDaemon(args, config) {
1165
1197
  catch (error) {
1166
1198
  repairFailed = true;
1167
1199
  lastIntegrityHealFailureAt = Date.now();
1200
+ if (inspected)
1201
+ noteIntegrity(inspected);
1168
1202
  log(`Re-protection failed: ${error.message}`);
1169
1203
  reportProtectionHealth(false);
1170
1204
  // A remote repair must report its execution failure even when the
@@ -1912,21 +1946,82 @@ async function runDaemon(args, config) {
1912
1946
  housekeeping.stats(true);
1913
1947
  loopDelay.reset();
1914
1948
  };
1915
- const heartbeat = async () => {
1916
- (0, daemonForensics_1.touchDaemonAlive)();
1917
- await recoverCredentialsIfMissing();
1918
- if (!creds.shieldId)
1919
- return;
1920
- // Did THIS beat actually record daemon liveness upstream? `flushSpool`
1921
- // reports every failure by returning null and never throws, so without
1922
- // this flag a heartbeat that never left the machine was indistinguishable
1923
- // from a delivered one — and the loop happily waited a full cadence before
1924
- // trying again. A hook flusher holding the spool lock is not a miss: the
1925
- // flush op then sends this beat without the spool (housekeepingOps).
1926
- let reported = false;
1927
- let refusal;
1949
+ /**
1950
+ * One liveness report: the spool flush, the heartbeat itself and what only
1951
+ * this process knows, through the housekeeping worker (spool read/rewrite,
1952
+ * shell-audit registry probes, guard-rule refresh, EDR probe). `integrity` is
1953
+ * the newest verdict the daemon HAS, not a new check.
1954
+ */
1955
+ const sendHeartbeat = async (integrity, unreportedCrash) => {
1956
+ const shieldId = creds.shieldId;
1957
+ if (!shieldId)
1958
+ throw new Error('no shield configured');
1959
+ const powershell = recoveredPowershell ?? (0, config_1.getPowershellHealth)();
1960
+ return housekeeping.run({
1961
+ op: 'heartbeatFlush',
1962
+ securityAgents: true,
1963
+ input: {
1964
+ apiUrl: creds.apiUrl,
1965
+ shieldId,
1966
+ shieldKey: creds.shieldKey,
1967
+ heartbeat: true,
1968
+ daemon: true,
1969
+ agentVersion: cliVersion(),
1970
+ integrityOk: integrity?.ok,
1971
+ integrityReasons: integrity?.reasons,
1972
+ integrityCheckedAt: integrity?.checkedAt,
1973
+ desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
1974
+ powershellSpawnOk: powershell?.spawnOk,
1975
+ powershellDecryptOk: powershell?.decryptOk,
1976
+ powershellLanguageMode: powershell?.languageMode,
1977
+ shieldKeySource: (recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)())?.shieldKeySource,
1978
+ distress: (0, distress_1.readDistressSnapshot)(),
1979
+ lastCrash: unreportedCrash
1980
+ ? {
1981
+ version: unreportedCrash.version,
1982
+ startedAt: unreportedCrash.startedAt,
1983
+ lastAliveAt: unreportedCrash.lastAliveAt,
1984
+ detectedAt: unreportedCrash.detectedAt,
1985
+ detectedBy: unreportedCrash.detectedBy,
1986
+ cause: unreportedCrash.cause,
1987
+ machineBootedAt: unreportedCrash.machineBootedAt,
1988
+ }
1989
+ : undefined,
1990
+ verdictIpc: verdictIpcHealth(),
1991
+ housekeeping: { ...housekeeping.stats() },
1992
+ // The detector that decides verdicts is this process's, not the worker's.
1993
+ detectionUpdatesStatus: localDetectionUpdates_1.localDetectionUpdates.getStatus(),
1994
+ detectorUpdatesStatus: localDetectionUpdates_1.localDetectorUpdates.getDetectorStatus(),
1995
+ },
1996
+ });
1997
+ };
1998
+ const sameIntegrity = (a, b) => !!a && !!b && a.ok === b.ok && [...a.reasons].sort().join('|') === [...b.reasons].sort().join('|');
1999
+ /**
2000
+ * The integrity check and its self-heal, apart from the liveness report. The
2001
+ * check walks the scan roots when it has no recent walk to reuse, which took
2002
+ * 16-165 s on a loaded laptop; as the first step of the heartbeat it held the
2003
+ * report (the console's only evidence that this daemon is alive) behind it,
2004
+ * under the heartbeat's own deadline, and a slow check surfaced as a stalled
2005
+ * poll. `reported` is the verdict the report just carried: when the check
2006
+ * finds a different one, the console is told right away.
2007
+ */
2008
+ const checkIntegrity = async (reported) => {
2009
+ if (integrityCheckInFlight)
2010
+ return; // the previous check is still running (a slow walk): it reports itself
2011
+ integrityCheckInFlight = true;
2012
+ // Only the check itself is timed: a repair in between has its own ceiling and log.
2013
+ let checkMs = 0;
2014
+ const check = async () => {
2015
+ const startedAt = Date.now();
2016
+ try {
2017
+ return await housekeeping.run({ op: 'integrity', requireDaemon: true, reuseWalkMs: INTEGRITY_WALK_REUSE_MS });
2018
+ }
2019
+ finally {
2020
+ checkMs += Date.now() - startedAt;
2021
+ }
2022
+ };
1928
2023
  try {
1929
- let integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
2024
+ let integrity = await check();
1930
2025
  // Recovery outside our repair pass (for example a manual fix) also clears
1931
2026
  // the old failure's backoff. It must not suppress an unrelated later gap.
1932
2027
  // An execution error can concern a component outside this summary (e.g.
@@ -1945,59 +2040,63 @@ async function runDaemon(args, config) {
1945
2040
  || Date.now() - lastIntegrityHealFailureAt >= INTEGRITY_HEAL_COOLDOWN_MS)
1946
2041
  && (repairFailed || (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons))) {
1947
2042
  const repaired = await reprotect(integrity.reasons, 'integrity');
1948
- integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
2043
+ integrity = await check();
1949
2044
  if (repaired) {
1950
2045
  log(integrity.ok
1951
2046
  ? 'Integrity self-heal: protection restored.'
1952
2047
  : `Integrity self-heal: still failing (${integrity.reasons.join(', ')}) — reported for support.`);
1953
2048
  }
1954
2049
  }
2050
+ noteIntegrity(integrity);
1955
2051
  if (!suspended)
1956
2052
  reportProtectionHealth(integrity.ok && !repairFailed);
2053
+ if (!integrity.ok)
2054
+ log(`Integrity warning: ${integrity.reasons.join(', ')}.`);
2055
+ if (checkMs > INTEGRITY_SLOW_MS) {
2056
+ log(`Integrity check took ${Math.round(checkMs / 1000)}s (longer than ${Math.round(INTEGRITY_SLOW_MS / 1000)}s) — local work on this machine is slow; heartbeats are not held for it.`);
2057
+ (0, distress_1.reportDistress)('integrity', distress_1.DISTRESS.HOUSEKEEPING_SLOW, `integrity check took ${Math.round(checkMs / 1000)}s; the heartbeat did not wait for it`);
2058
+ }
2059
+ if (!stopped && creds.shieldId && !sameIntegrity(reported, integrity)) {
2060
+ await sendHeartbeat(integrity, undefined).catch(() => undefined);
2061
+ }
2062
+ }
2063
+ catch (error) {
2064
+ log(`Integrity check failed: ${error?.message || String(error)} — checked again on the next heartbeat.`);
2065
+ }
2066
+ finally {
2067
+ integrityCheckInFlight = false;
2068
+ }
2069
+ };
2070
+ const heartbeat = async () => {
2071
+ (0, daemonForensics_1.touchDaemonAlive)();
2072
+ await recoverCredentialsIfMissing();
2073
+ if (!creds.shieldId)
2074
+ return;
2075
+ // Did THIS beat actually record daemon liveness upstream? `flushSpool`
2076
+ // reports every failure by returning null and never throws, so without
2077
+ // this flag a heartbeat that never left the machine was indistinguishable
2078
+ // from a delivered one — and the loop happily waited a full cadence before
2079
+ // trying again. A hook flusher holding the spool lock is not a miss: the
2080
+ // flush op then sends this beat without the spool (housekeepingOps).
2081
+ let reported = false;
2082
+ let refusal;
2083
+ // Liveness FIRST, carrying the newest integrity verdict the daemon has.
2084
+ // The integrity check follows on its own (checkIntegrity) and never holds
2085
+ // this report: the flush runs on the spare housekeeping worker when the
2086
+ // main one is inside a walk.
2087
+ const carried = lastIntegrity;
2088
+ let checkStarted = false;
2089
+ const startCheck = () => {
2090
+ if (checkStarted)
2091
+ return;
2092
+ checkStarted = true;
2093
+ void checkIntegrity(carried);
2094
+ };
2095
+ try {
1957
2096
  // Report a previous daemon's unclean death exactly once.
1958
2097
  const crash = (0, daemonForensics_1.readPostmortem)();
1959
2098
  const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
1960
- const powershell = recoveredPowershell ?? (0, config_1.getPowershellHealth)();
1961
- // The flush (spool read/rewrite, shell-audit registry probes, guard-rule
1962
- // refresh, EDR probe) runs in the housekeeping worker; what only this
1963
- // process knows travels with it.
1964
- const { result, failure } = await housekeeping.run({
1965
- op: 'heartbeatFlush',
1966
- securityAgents: true,
1967
- input: {
1968
- apiUrl: creds.apiUrl,
1969
- shieldId: creds.shieldId,
1970
- shieldKey: creds.shieldKey,
1971
- heartbeat: true,
1972
- daemon: true,
1973
- agentVersion: cliVersion(),
1974
- integrityOk: integrity.ok,
1975
- integrityReasons: integrity.reasons,
1976
- integrityCheckedAt: integrity.checkedAt,
1977
- desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
1978
- powershellSpawnOk: powershell?.spawnOk,
1979
- powershellDecryptOk: powershell?.decryptOk,
1980
- powershellLanguageMode: powershell?.languageMode,
1981
- shieldKeySource: (recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)())?.shieldKeySource,
1982
- distress: (0, distress_1.readDistressSnapshot)(),
1983
- lastCrash: unreportedCrash
1984
- ? {
1985
- version: unreportedCrash.version,
1986
- startedAt: unreportedCrash.startedAt,
1987
- lastAliveAt: unreportedCrash.lastAliveAt,
1988
- detectedAt: unreportedCrash.detectedAt,
1989
- detectedBy: unreportedCrash.detectedBy,
1990
- cause: unreportedCrash.cause,
1991
- machineBootedAt: unreportedCrash.machineBootedAt,
1992
- }
1993
- : undefined,
1994
- verdictIpc: verdictIpcHealth(),
1995
- housekeeping: { ...housekeeping.stats() },
1996
- // The detector that decides verdicts is this process's, not the worker's.
1997
- detectionUpdatesStatus: localDetectionUpdates_1.localDetectionUpdates.getStatus(),
1998
- detectorUpdatesStatus: localDetectionUpdates_1.localDetectorUpdates.getDetectorStatus(),
1999
- },
2000
- });
2099
+ const { result, failure } = await sendHeartbeat(carried, unreportedCrash);
2001
2100
  reported = result !== null;
2002
2101
  if (!reported)
2003
2102
  refusal = failure;
@@ -2007,11 +2106,11 @@ async function runDaemon(args, config) {
2007
2106
  (0, daemonForensics_1.markPostmortemReported)();
2008
2107
  if (result && result.accepted > 0)
2009
2108
  log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
2010
- if (!integrity.ok)
2011
- log(`Integrity warning: ${integrity.reasons.join(', ')}.`);
2109
+ startCheck();
2012
2110
  await uploadLogTail();
2013
2111
  }
2014
2112
  catch { /* spool stays on disk for the next tick */ }
2113
+ startCheck(); // a report that failed to go out never skips the integrity check
2015
2114
  // Settle any in-flight upgrade_cli action (success once the new build is
2016
2115
  // live, failure when the target never arrived). Runs BEFORE the failure
2017
2116
  // signal below so a lost heartbeat can never strand a pending upgrade.
@@ -4401,8 +4401,26 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
4401
4401
  return tamper;
4402
4402
  return other;
4403
4403
  }
4404
+ /**
4405
+ * Could ANY finding for this candidate survive the context gates in
4406
+ * scanBuiltInRules? A finding is kept only for a command, or — for a sensitive
4407
+ * path, a metadata endpoint, a honeypot decoy — where the string can act: a
4408
+ * path argument, a request, an outbound call. Inert text (the content of a file
4409
+ * write or edit, a note an MCP tool stores) can keep none, so scanning it only
4410
+ * produces findings the gates then drop. The matchers cost O(size): a 2 MB file
4411
+ * write held the daemon's verdict loop for 3-5 s (MON-PERF-02). The gates and
4412
+ * this check read the same helpers, so a verdict never changes.
4413
+ */
4414
+ function findingCanBeKept(toolName, candidate, outbound) {
4415
+ return isCommandContext(toolName, candidate)
4416
+ || isActionableContext(toolName, candidate)
4417
+ || isMetadataRequestContext(toolName, candidate)
4418
+ || outbound;
4419
+ }
4404
4420
  function scanBuiltInRules(toolName, toolArgs, candidates, outbound, options) {
4405
4421
  for (const candidate of candidates) {
4422
+ if (!findingCanBeKept(toolName, candidate, outbound))
4423
+ continue;
4406
4424
  const finding = scanTextValue(toolName, candidate.value, options, isCommandContext(toolName, candidate));
4407
4425
  if (!finding)
4408
4426
  continue;
@@ -69,6 +69,7 @@ export interface DesktopDiscoveryHost {
69
69
  /** True when the scan ran on an ephemeral CI runner (GitHub Actions, GitLab, etc.) — not a user machine. */
70
70
  isCi?: boolean;
71
71
  }
72
+ export declare function redactCommandArgs(args: string[] | undefined): string[];
72
73
  /**
73
74
  * Which credential is missing and the most likely reason — diagnosability for
74
75
  * remote fleet machines where only the daemon-captured stderr is visible.