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.
- package/dist/agentTerminal.d.ts +17 -9
- package/dist/agentTerminal.js +44 -32
- package/dist/askDialog.js +7 -0
- package/dist/callerEnv.d.ts +6 -0
- package/dist/callerEnv.js +9 -0
- package/dist/commands/cmdGuard.js +1 -1
- package/dist/commands/daemon.js +161 -62
- package/dist/commands/deterministicGuard.js +18 -0
- package/dist/commands/discover.d.ts +1 -0
- package/dist/commands/discover.js +1 -0
- package/dist/commands/discoverPaths.d.ts +34 -2
- package/dist/commands/discoverPaths.js +90 -7
- package/dist/commands/discoverProxy.d.ts +12 -3
- package/dist/commands/discoverProxy.js +17 -4
- package/dist/commands/hook.d.ts +10 -10
- package/dist/commands/hook.js +81 -129
- package/dist/commands/hookConfigFile.d.ts +34 -0
- package/dist/commands/hookConfigFile.js +104 -0
- package/dist/commands/installClaudeHook.d.ts +28 -1
- package/dist/commands/installClaudeHook.js +90 -21
- package/dist/commands/installCursorHook.d.ts +7 -1
- package/dist/commands/installCursorHook.js +46 -22
- package/dist/commands/mcpGateway.d.ts +10 -0
- package/dist/commands/mcpGateway.js +62 -7
- package/dist/commands/posixShellGuard.js +1 -1
- package/dist/commands/protectionRepair.d.ts +3 -0
- package/dist/commands/protectionRepair.js +2 -0
- package/dist/commands/shellGuard.js +1 -1
- package/dist/devConfirm.d.ts +5 -1
- package/dist/distress.d.ts +8 -0
- package/dist/distress.js +8 -0
- package/dist/gatewayRuntime.d.ts +11 -0
- package/dist/gatewayRuntime.js +78 -0
- package/dist/housekeeping.d.ts +26 -4
- package/dist/housekeeping.js +209 -48
- package/dist/housekeepingOps.d.ts +6 -0
- package/dist/housekeepingOps.js +51 -0
- package/dist/housekeepingWorker.js +3 -1
- package/dist/integrity.d.ts +1 -1
- package/dist/integrity.js +12 -2
- package/dist/machineIdentity.js +4 -0
- package/dist/nativeHook.d.ts +46 -11
- package/dist/nativeHook.js +271 -70
- package/dist/terminalAllowance.d.ts +24 -0
- package/dist/terminalAllowance.js +156 -0
- package/dist/testSandbox.d.ts +4 -0
- package/dist/testSandbox.js +100 -0
- package/dist/toolTaxonomy.d.ts +100 -0
- package/dist/toolTaxonomy.js +193 -0
- package/dist/version.json +1 -1
- package/package.json +2 -1
package/dist/agentTerminal.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
export declare const AGENT_TERMINAL_IDE_CONFIRMED = "allowed
|
|
16
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
22
|
-
* answer for this command / rule?
|
|
23
|
-
*
|
|
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;
|
package/dist/agentTerminal.js
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
84
|
-
exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed
|
|
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
|
|
87
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
98
|
-
* answer for this command / rule?
|
|
99
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
`
|
|
165
|
-
`
|
|
166
|
-
`
|
|
167
|
-
`
|
|
168
|
-
`
|
|
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,
|
package/dist/callerEnv.d.ts
CHANGED
|
@@ -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='
|
|
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)(),
|
package/dist/commands/daemon.js
CHANGED
|
@@ -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
|
|
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
|
|
1140
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|