fullcourtdefense-cli 1.34.21 → 1.34.25

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 (91) hide show
  1. package/README.md +1 -0
  2. package/dist/actionIdentity.d.ts +1 -0
  3. package/dist/actionIdentity.js +2 -0
  4. package/dist/actionPolicyEngine.d.ts +5 -0
  5. package/dist/actionPolicyEngine.js +273 -30
  6. package/dist/agentTerminal.d.ts +1 -3
  7. package/dist/agentTerminal.js +14 -29
  8. package/dist/approvalPrompt.d.ts +7 -0
  9. package/dist/approvalPrompt.js +46 -0
  10. package/dist/askDialog.d.ts +87 -0
  11. package/dist/askDialog.js +209 -0
  12. package/dist/blockExplanation.js +18 -6
  13. package/dist/boundaryVerification.d.ts +20 -0
  14. package/dist/boundaryVerification.js +147 -0
  15. package/dist/commands/daemon.js +198 -70
  16. package/dist/commands/deterministicGuard.d.ts +2 -0
  17. package/dist/commands/deterministicGuard.js +161 -9
  18. package/dist/commands/hook.js +81 -29
  19. package/dist/commands/installClaudeHook.js +11 -5
  20. package/dist/commands/installCursorHook.js +5 -4
  21. package/dist/commands/mcpGateway.d.ts +2 -0
  22. package/dist/commands/mcpGateway.js +163 -56
  23. package/dist/commands/onboard.js +6 -5
  24. package/dist/commands/onboardingSummary.d.ts +5 -0
  25. package/dist/commands/onboardingSummary.js +11 -0
  26. package/dist/commands/protectedRun.d.ts +14 -0
  27. package/dist/commands/protectedRun.js +207 -0
  28. package/dist/containment-runtime/index.mjs +30118 -0
  29. package/dist/containment-runtime/licenses/_anthropic-ai_sandbox-runtime.txt +201 -0
  30. package/dist/containment-runtime/licenses/_pondwader_socks5-server.txt +20 -0
  31. package/dist/containment-runtime/licenses/commander.txt +22 -0
  32. package/dist/containment-runtime/licenses/node-forge.txt +331 -0
  33. package/dist/containment-runtime/licenses/zod.txt +21 -0
  34. package/dist/containment-runtime/runtime-version.json +1 -0
  35. package/dist/containment-runtime/vendor/java-proxy-agent/build.ts +95 -0
  36. package/dist/containment-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
  37. package/dist/containment-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
  38. package/dist/containment-runtime/vendor/seccomp/build.ts +69 -0
  39. package/dist/containment-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
  40. package/dist/containment-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
  41. package/dist/containment-runtime/vendor/srt-win/build.ts +21 -0
  42. package/dist/containment-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
  43. package/dist/containmentLease.d.ts +5 -0
  44. package/dist/containmentLease.js +72 -0
  45. package/dist/detectionUpdates.d.ts +23 -3
  46. package/dist/detectionUpdates.js +45 -14
  47. package/dist/detectorArtifact.d.ts +17 -0
  48. package/dist/detectorArtifact.js +35 -0
  49. package/dist/detectorBaseline.d.ts +2 -0
  50. package/dist/detectorBaseline.js +5 -0
  51. package/dist/detectorModule.d.ts +9 -0
  52. package/dist/detectorModule.js +84 -0
  53. package/dist/detectorRuntime.d.ts +21 -0
  54. package/dist/detectorRuntime.js +49 -0
  55. package/dist/detectorUpdates.d.ts +21 -0
  56. package/dist/detectorUpdates.js +86 -0
  57. package/dist/devConfirm.d.ts +97 -15
  58. package/dist/devConfirm.js +447 -117
  59. package/dist/distress.d.ts +2 -0
  60. package/dist/distress.js +2 -0
  61. package/dist/frictionRollout.js +103 -0
  62. package/dist/gatewayRuntime.d.ts +19 -0
  63. package/dist/gatewayRuntime.js +93 -0
  64. package/dist/hookSlim.js +17 -18
  65. package/dist/index.js +35 -0
  66. package/dist/localDetectionUpdates.d.ts +3 -1
  67. package/dist/localDetectionUpdates.js +8 -1
  68. package/dist/localSafetySnapshot.d.ts +9 -5
  69. package/dist/localSafetySnapshot.js +42 -15
  70. package/dist/mcpToolReview.d.ts +17 -0
  71. package/dist/mcpToolReview.js +127 -0
  72. package/dist/mcpToolTrust.d.ts +22 -0
  73. package/dist/mcpToolTrust.js +66 -0
  74. package/dist/nativeHook.d.ts +4 -0
  75. package/dist/nativeHook.js +31 -17
  76. package/dist/policyRefresh.d.ts +3 -0
  77. package/dist/policyRefresh.js +27 -0
  78. package/dist/protectionMaintenance.d.ts +10 -0
  79. package/dist/protectionMaintenance.js +64 -0
  80. package/dist/protectionProfile.d.ts +26 -0
  81. package/dist/protectionProfile.js +63 -0
  82. package/dist/runtimeConfig.d.ts +2 -0
  83. package/dist/runtimeConfig.js +65 -26
  84. package/dist/selfTest.d.ts +2 -0
  85. package/dist/selfTest.js +9 -1
  86. package/dist/sessionLimits.d.ts +2 -0
  87. package/dist/sessionLimits.js +24 -0
  88. package/dist/telemetry.d.ts +5 -0
  89. package/dist/telemetry.js +8 -0
  90. package/dist/version.json +1 -1
  91. package/package.json +10 -6
@@ -36,7 +36,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.IDE_CONFIRMATION_WINDOW_MS = exports.AGENT_TERMINAL_IDE_CONFIRMED = exports.AGENT_TERMINAL_ASK_BLOCKED = exports.AGENT_TERMINAL_MARKERS = void 0;
37
37
  exports.isAgentDrivenTerminal = isAgentDrivenTerminal;
38
38
  exports.ideShellPendingKey = ideShellPendingKey;
39
- exports.ideLocalSafetyRememberKey = ideLocalSafetyRememberKey;
40
39
  exports.ideConfirmedFor = ideConfirmedFor;
41
40
  exports.buildAgentTerminalJs = buildAgentTerminalJs;
42
41
  exports.buildAgentTerminalPs = buildAgentTerminalPs;
@@ -84,19 +83,13 @@ exports.AGENT_TERMINAL_ASK_BLOCKED = 'an AI agent is driving this terminal, so n
84
83
  /** Shown when the IDE prompt already answered for this exact command. */
85
84
  exports.AGENT_TERMINAL_IDE_CONFIRMED = 'allowed through the IDE permission gate; confirmation is recorded by the IDE post-execution hook.';
86
85
  // ---------------------------------------------------------------------------
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.
86
+ // Exact command + working directory handoff keys match devConfirm v3.
87
+ // A handoff delegates to the IDE permission gate; it is never consent.
91
88
  // ---------------------------------------------------------------------------
92
89
  const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
93
90
  /** 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}`);
91
+ function ideShellPendingKey(command, cwd = process.cwd()) {
92
+ return sha256(JSON.stringify(['shell', 'shell', sha256(`args-v3:${JSON.stringify({ command, cwd })}`)]));
100
93
  }
101
94
  /** Maximum lifetime of an exact-command IDE permission handoff (not consent). */
102
95
  exports.IDE_CONFIRMATION_WINDOW_MS = 10 * 60_000;
@@ -106,12 +99,9 @@ exports.IDE_CONFIRMATION_WINDOW_MS = 10 * 60_000;
106
99
  * by the generated guard code below.
107
100
  */
108
101
  function ideConfirmedFor(ledger, command, ruleId, now = Date.now(), sessionId) {
109
- if (!ledger || ledger.formatVersion !== 2)
110
- return false;
111
- // A pending prompt is not an affirmative answer. IDE handoffs are handled
112
- // separately by the generated guard, without claiming developer consent.
113
- const rememberKey = ideLocalSafetyRememberKey(ruleId);
114
- return (ledger.remembered || []).some(r => r?.key === rememberKey && typeof r.expiresAt === 'string' && Date.parse(r.expiresAt) > now && (r.remember === '1h' || (r.remember === 'session' && !!sessionId && r.sessionId === sessionId)));
102
+ // Terminal guards lack the hook's complete policy/context snapshot. They
103
+ // cannot reconstruct a scoped approval and must never infer one from a rule.
104
+ return false;
115
105
  }
116
106
  // ---------------------------------------------------------------------------
117
107
  // Rendered snippets for the generated guards (single source of truth).
@@ -129,7 +119,7 @@ function buildAgentTerminalJs() {
129
119
  `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));}`,
130
120
  `const IDE_WINDOW_MS=${exports.IDE_CONFIRMATION_WINDOW_MS};`,
131
121
  `function sha(s){return crypto.createHash('sha256').update(s).digest('hex');}`,
132
- `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.permissionHandoff===true&&p.key===pk&&typeof p.raisedAt==='string'&&now>=Date.parse(p.raisedAt)&&now-Date.parse(p.raisedAt)<IDE_WINDOW_MS))return true;if(ledger.formatVersion!==2)return false;const rk=sha('shell|local-safety:'+ruleId+'|'+ruleId);const session=process.env.FCD_SESSION_ID||process.env.CLAUDE_CODE_SESSION_ID||process.env.CODEX_THREAD_ID;return (ledger.remembered||[]).some(r=>r&&r.key===rk&&typeof r.expiresAt==='string'&&Date.parse(r.expiresAt)>now&&(r.remember==='1h'||(r.remember==='session'&&session&&r.sessionId===session)));}catch{return false;}}`,
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;}}`,
133
123
  `const AGENT_ASK_BLOCKED=${JSON.stringify(exports.AGENT_TERMINAL_ASK_BLOCKED)};`,
134
124
  `const AGENT_IDE_CONFIRMED=${JSON.stringify(exports.AGENT_TERMINAL_IDE_CONFIRMED)};`,
135
125
  ].join('\n');
@@ -165,24 +155,19 @@ function buildAgentTerminalPs() {
165
155
  ` $ledgerPath = Join-Path $HOME '.fullcourtdefense\\dev-confirm.json'`,
166
156
  ` if (-not (Test-Path -LiteralPath $ledgerPath)) { return $false }`,
167
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 }`,
168
160
  ` $now = [DateTimeOffset]::UtcNow`,
169
- ` $pk = Get-FcdSha256 ('shell|shell|' + (Get-FcdSha256 ('cmd:' + $Line.Trim())))`,
161
+ ` $argsJson = ConvertTo-Json -Compress -InputObject ([ordered]@{command=$Line;cwd=(Get-Location).ProviderPath})`,
162
+ ` $digest = Get-FcdSha256 ('args-v3:' + $argsJson)`,
163
+ ` $pk = Get-FcdSha256 (ConvertTo-Json -Compress -InputObject @('shell','shell',$digest))`,
170
164
  ` foreach ($p in @($ledger.pending)) {`,
171
- ` if ($null -ne $p -and $p.permissionHandoff -eq $true -and $p.key -eq $pk -and $p.raisedAt) {`,
165
+ ` if ($null -ne $p -and $p.permissionHandoff -eq $true -and $p.sessionId -ceq $session -and $p.key -ceq $pk -and $p.raisedAt) {`,
172
166
  ` $raised = if ($p.raisedAt -is [DateTime]) { [DateTimeOffset]$p.raisedAt } else { [DateTimeOffset]::Parse([string]$p.raisedAt, [Globalization.CultureInfo]::InvariantCulture) }`,
173
167
  ` $age = $now - $raised`,
174
168
  ` if ($age.TotalMilliseconds -ge 0 -and $age.TotalMilliseconds -lt ${exports.IDE_CONFIRMATION_WINDOW_MS}) { return $true }`,
175
169
  ` }`,
176
170
  ` }`,
177
- ` if ($ledger.formatVersion -ne 2) { return $false }`,
178
- ` $rk = Get-FcdSha256 ('shell|local-safety:' + $RuleId + '|' + $RuleId)`,
179
- ` $session = $env:FCD_SESSION_ID; if (-not $session) { $session = $env:CLAUDE_CODE_SESSION_ID }; if (-not $session) { $session = $env:CODEX_THREAD_ID }`,
180
- ` foreach ($r in @($ledger.remembered)) {`,
181
- ` if ($null -ne $r -and $r.key -eq $rk -and $r.expiresAt -and ($r.remember -eq '1h' -or ($r.remember -eq 'session' -and $session -and $r.sessionId -eq $session))) {`,
182
- ` $expiry = if ($r.expiresAt -is [DateTime]) { [DateTimeOffset]$r.expiresAt } else { [DateTimeOffset]::Parse([string]$r.expiresAt, [Globalization.CultureInfo]::InvariantCulture) }`,
183
- ` if ($expiry -gt $now) { return $true }`,
184
- ` }`,
185
- ` }`,
186
171
  ` } catch { }`,
187
172
  ` return $false`,
188
173
  `}`,
@@ -0,0 +1,7 @@
1
+ export interface ApprovalMessageOptions {
2
+ /** The dialog offers "Always allow"; the footer must say what that covers. */
3
+ allowAlways?: boolean;
4
+ /** Human wording of the grant scope, e.g. "`gh` commands (write) to github.com". */
5
+ alwaysScopeLabel?: string;
6
+ }
7
+ export declare function nativeApprovalMessage(reason: string, options?: ApprovalMessageOptions): string;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nativeApprovalMessage = nativeApprovalMessage;
4
+ /** Compact OS prompt. The complete decision remains in the IDE response and audit. */
5
+ function line(value, limit) {
6
+ const text = value.replace(/\s+/g, ' ').trim();
7
+ if (text.length <= limit)
8
+ return text;
9
+ // Keep the command tail: a destination or destructive operand often comes last.
10
+ const head = Math.ceil((limit - 3) * 0.6);
11
+ return `${text.slice(0, head)} … ${text.slice(-(limit - head - 3))}`;
12
+ }
13
+ function nativeApprovalMessage(reason, options = {}) {
14
+ const rows = reason.split(/\r?\n/).map(row => row.trim()).filter(Boolean);
15
+ const field = (name) => rows.find(row => row.startsWith(`${name}:`))?.slice(name.length + 1).trim();
16
+ const what = field('What');
17
+ const tool = field('Tool');
18
+ const evidence = field('Evidence');
19
+ const rule = field('Rule');
20
+ const why = field('Why');
21
+ const agent = field('Agent')
22
+ || (/^[a-z_ -]+:\s*\S+/i.test(rows[0] || '') && !/^(What|Rule|Why|Tool|Evidence|IDE|Developer):/.test(rows[0]) ? rows[0] : undefined);
23
+ const details = what || tool || rule || why ? [
24
+ agent ? `Agent: ${line(agent, 80)}` : '',
25
+ field('IDE') ? `IDE: ${line(field('IDE'), 40)}` : '',
26
+ what ? `Action: ${line(what, 230)}` : '',
27
+ tool ? `Tool: ${line([tool, evidence].filter(Boolean).join(' · '), 230)}` : '',
28
+ rule ? `Rule: ${line(rule, 200)}` : '',
29
+ why ? `Reason: ${line(why, 320)}` : '',
30
+ ].filter(Boolean) : [line(reason, 650)];
31
+ if (!options.allowAlways) {
32
+ return [
33
+ 'Allow this action once?',
34
+ '', ...details, '',
35
+ 'Full details: the IDE message or this machine’s Activity in FullCourtDefense.',
36
+ 'Allow once permits only this action. Deny or closing keeps it stopped.',
37
+ ].join('\n');
38
+ }
39
+ return [
40
+ 'Allow this action?',
41
+ '', ...details, '',
42
+ ...(options.alwaysScopeLabel ? [`Always allow covers: ${line(options.alwaysScopeLabel, 200)} — for 30 days on this machine, under this rule as it is written today.`, ''] : []),
43
+ 'Allow once permits only this action. Deny or closing keeps it stopped.',
44
+ 'Full details: the IDE message or this machine’s Activity in FullCourtDefense.',
45
+ ].join('\n');
46
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The developer's question, answered in OUR dialog — one flow for every IDE.
3
+ *
4
+ * The hook core (`commands/hook.ts`, possibly running inside the resident daemon,
5
+ * which has a 2.5 s budget and no desktop of its own) never opens a window. When a
6
+ * rule asks the developer it answers the IDE's native `ask` verdict and attaches a
7
+ * machine-readable envelope (`fcdAsk`) describing the action and the rule. The
8
+ * thin per-event hook process — the one running in the developer's session —
9
+ * takes the envelope off the verdict and, when a dialog can be shown, asks the
10
+ * person with three buttons:
11
+ *
12
+ * Always allow -> scoped grant (devConfirm.recordDeveloperGrant) + allow now
13
+ * Allow once -> allow now (+ the rule's own session / 1h memory)
14
+ * Deny / close -> deny now
15
+ *
16
+ * and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
17
+ * where no dialog is possible (no display, dialog binary missing) or where the
18
+ * org chose it (`FCD_ASK_CHANNEL=ide`). The envelope never reaches the IDE.
19
+ *
20
+ * Deterministic all the way: the rule that asked is named in the envelope, the
21
+ * grant is bound to that rule's hash, and nothing here re-evaluates the action.
22
+ */
23
+ import { ConfirmRemember, GrantScope, NativeDialogOptions, NativeDialogOutcome } from './devConfirm';
24
+ /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
25
+ export declare const ASK_ENVELOPE_FIELD = "fcdAsk";
26
+ /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
27
+ export declare const ASK_DIALOG_TIMEOUT_MS = 55000;
28
+ export interface AskEnvelope {
29
+ v: 1;
30
+ /** Hook event the marker was raised under (shell | mcp | file | read). */
31
+ event: string;
32
+ toolName: string;
33
+ argsDigest: string;
34
+ operation: string;
35
+ /** What "Always allow" would cover — built by the core from engine facts. */
36
+ scope: GrantScope;
37
+ policyId?: string;
38
+ policyName?: string;
39
+ policyHash?: string;
40
+ matchedRule?: string;
41
+ remember: ConfirmRemember;
42
+ /** Text for the dialog (the same explanation the IDE card shows). */
43
+ reason: string;
44
+ }
45
+ export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide';
46
+ export interface HookVerdict {
47
+ stdout: string;
48
+ stderr: string;
49
+ exitCode: number;
50
+ }
51
+ export type DialogFn = (reason: string, timeoutMs: number, options: NativeDialogOptions) => Promise<{
52
+ outcome: NativeDialogOutcome;
53
+ latencyMs: number;
54
+ }>;
55
+ /** Which channel asks the developer. `dialog` (default) = our OS dialog, `ide` = the IDE's own card. */
56
+ export declare function askChannel(): 'dialog' | 'ide';
57
+ /** Attach the envelope to a decision object (core side). */
58
+ export declare function attachAskEnvelope(decision: Record<string, unknown>, envelope: AskEnvelope | undefined): Record<string, unknown>;
59
+ /** Split a verdict's stdout into the IDE-facing decision and our envelope, if any. */
60
+ export declare function takeAskEnvelope(stdout: string): {
61
+ decision?: Record<string, unknown>;
62
+ envelope?: AskEnvelope;
63
+ stdout: string;
64
+ };
65
+ /**
66
+ * Last step of the per-event hook process (full and slim entries alike): an `ask`
67
+ * verdict is put to the developer in the FullCourtDefense dialog and becomes the
68
+ * IDE's allow / deny; anything else — and an ask where no dialog is possible — is
69
+ * written as-is with the envelope removed. Then the process exits.
70
+ */
71
+ export declare function emitVerdictForIde(args: {
72
+ event?: string;
73
+ agentClient?: string;
74
+ }, result: HookVerdict, entry: 'ipc' | 'local' | 'slim-ipc' | 'slim-local'): Promise<never>;
75
+ /**
76
+ * Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
77
+ * IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
78
+ * or that does not ask, passes through untouched.
79
+ */
80
+ export declare function resolveAskWithDialog(verdict: HookVerdict, options?: {
81
+ clientLabel?: string;
82
+ timeoutMs?: number;
83
+ dialog?: DialogFn;
84
+ }): Promise<HookVerdict & {
85
+ resolution?: AskResolution;
86
+ envelope?: AskEnvelope;
87
+ }>;
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ASK_DIALOG_TIMEOUT_MS = exports.ASK_ENVELOPE_FIELD = void 0;
4
+ exports.askChannel = askChannel;
5
+ exports.attachAskEnvelope = attachAskEnvelope;
6
+ exports.takeAskEnvelope = takeAskEnvelope;
7
+ exports.emitVerdictForIde = emitVerdictForIde;
8
+ exports.resolveAskWithDialog = resolveAskWithDialog;
9
+ /**
10
+ * The developer's question, answered in OUR dialog — one flow for every IDE.
11
+ *
12
+ * The hook core (`commands/hook.ts`, possibly running inside the resident daemon,
13
+ * which has a 2.5 s budget and no desktop of its own) never opens a window. When a
14
+ * rule asks the developer it answers the IDE's native `ask` verdict and attaches a
15
+ * machine-readable envelope (`fcdAsk`) describing the action and the rule. The
16
+ * thin per-event hook process — the one running in the developer's session —
17
+ * takes the envelope off the verdict and, when a dialog can be shown, asks the
18
+ * person with three buttons:
19
+ *
20
+ * Always allow -> scoped grant (devConfirm.recordDeveloperGrant) + allow now
21
+ * Allow once -> allow now (+ the rule's own session / 1h memory)
22
+ * Deny / close -> deny now
23
+ *
24
+ * and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
25
+ * where no dialog is possible (no display, dialog binary missing) or where the
26
+ * org chose it (`FCD_ASK_CHANNEL=ide`). The envelope never reaches the IDE.
27
+ *
28
+ * Deterministic all the way: the rule that asked is named in the envelope, the
29
+ * grant is bound to that rule's hash, and nothing here re-evaluates the action.
30
+ */
31
+ const devConfirm_1 = require("./devConfirm");
32
+ const telemetry_1 = require("./telemetry");
33
+ const hookIo_1 = require("./hookIo");
34
+ /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
35
+ exports.ASK_ENVELOPE_FIELD = 'fcdAsk';
36
+ /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
37
+ exports.ASK_DIALOG_TIMEOUT_MS = 55_000;
38
+ /** Which channel asks the developer. `dialog` (default) = our OS dialog, `ide` = the IDE's own card. */
39
+ function askChannel() {
40
+ return (process.env.FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
41
+ }
42
+ /** Attach the envelope to a decision object (core side). */
43
+ function attachAskEnvelope(decision, envelope) {
44
+ if (!envelope)
45
+ return decision;
46
+ return { ...decision, [exports.ASK_ENVELOPE_FIELD]: envelope };
47
+ }
48
+ /** Split a verdict's stdout into the IDE-facing decision and our envelope, if any. */
49
+ function takeAskEnvelope(stdout) {
50
+ const trimmed = stdout.trim();
51
+ if (!trimmed.startsWith('{') || !trimmed.includes(`"${exports.ASK_ENVELOPE_FIELD}"`))
52
+ return { stdout };
53
+ try {
54
+ const decision = JSON.parse(trimmed);
55
+ const raw = decision[exports.ASK_ENVELOPE_FIELD];
56
+ delete decision[exports.ASK_ENVELOPE_FIELD];
57
+ const envelope = isAskEnvelope(raw) ? raw : undefined;
58
+ return { decision, envelope, stdout: JSON.stringify(decision) };
59
+ }
60
+ catch {
61
+ return { stdout };
62
+ }
63
+ }
64
+ function isAskEnvelope(value) {
65
+ if (!value || typeof value !== 'object')
66
+ return false;
67
+ const e = value;
68
+ return e.v === 1 && typeof e.event === 'string' && typeof e.toolName === 'string' && typeof e.argsDigest === 'string'
69
+ && typeof e.operation === 'string' && typeof e.reason === 'string' && !!e.scope && typeof e.scope === 'object'
70
+ && (e.remember === 'none' || e.remember === 'session' || e.remember === '1h');
71
+ }
72
+ function decisionAsks(decision) {
73
+ const hso = decision.hookSpecificOutput;
74
+ return decision.permission === 'ask' || decision.permissionDecision === 'ask' || hso?.permissionDecision === 'ask';
75
+ }
76
+ /** Rewrite an `ask` decision into the IDE's allow / deny shape, in the format the core used. */
77
+ function rewriteDecision(decision, allowed, userMsg, agentMsg) {
78
+ const claude = 'hookSpecificOutput' in decision || 'permissionDecision' in decision;
79
+ if (claude) {
80
+ if (allowed)
81
+ return { systemMessage: agentMsg };
82
+ return {
83
+ permissionDecision: 'deny',
84
+ permissionDecisionReason: userMsg,
85
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: userMsg },
86
+ };
87
+ }
88
+ return allowed
89
+ ? { permission: 'allow', agent_message: agentMsg }
90
+ : { permission: 'deny', user_message: userMsg, agent_message: agentMsg };
91
+ }
92
+ /**
93
+ * Last step of the per-event hook process (full and slim entries alike): an `ask`
94
+ * verdict is put to the developer in the FullCourtDefense dialog and becomes the
95
+ * IDE's allow / deny; anything else — and an ask where no dialog is possible — is
96
+ * written as-is with the envelope removed. Then the process exits.
97
+ */
98
+ async function emitVerdictForIde(args, result, entry) {
99
+ let final = result;
100
+ try {
101
+ const resolved = await resolveAskWithDialog(result, { clientLabel: args.agentClient });
102
+ if (resolved.resolution)
103
+ (0, hookIo_1.dbg)({ phase: 'ask_dialog', event: args.event, entry, resolution: resolved.resolution, tool: resolved.envelope?.toolName });
104
+ final = resolved;
105
+ }
106
+ catch (err) {
107
+ // The dialog step must never break the verdict: fall back to the IDE ask (envelope stripped).
108
+ (0, hookIo_1.dbg)({ phase: 'ask_dialog_error', event: args.event, entry, error: err instanceof Error ? err.message : String(err) });
109
+ final = { ...result, stdout: takeAskEnvelope(result.stdout).stdout };
110
+ }
111
+ if (final.stderr) {
112
+ try {
113
+ process.stderr.write(final.stderr);
114
+ }
115
+ catch { /* stderr gone */ }
116
+ }
117
+ if (final.stdout) {
118
+ try {
119
+ process.stdout.write(final.stdout);
120
+ }
121
+ catch { /* stdout gone */ }
122
+ }
123
+ return process.exit(final.exitCode);
124
+ }
125
+ /**
126
+ * Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
127
+ * IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
128
+ * or that does not ask, passes through untouched.
129
+ */
130
+ async function resolveAskWithDialog(verdict, options = {}) {
131
+ const taken = takeAskEnvelope(verdict.stdout);
132
+ if (!taken.decision || !taken.envelope || !decisionAsks(taken.decision))
133
+ return { ...verdict, stdout: taken.stdout };
134
+ const envelope = taken.envelope;
135
+ const decision = taken.decision;
136
+ const clean = { ...verdict, stdout: taken.stdout, envelope };
137
+ // An injected dialog (tests, embedding hosts) only needs a person; the OS dialog also needs a display.
138
+ const canAsk = options.dialog ? (0, devConfirm_1.isHumanPresent)() : (0, devConfirm_1.nativeDialogAvailable)();
139
+ if (askChannel() === 'ide' || !canAsk)
140
+ return { ...clean, resolution: 'ide' };
141
+ const allowAlways = envelope.remember !== 'none' && Boolean(envelope.policyHash)
142
+ && (envelope.scope.event !== 'shell' || (envelope.scope.programs || []).length > 0);
143
+ const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope) : undefined;
144
+ const dialog = options.dialog || devConfirm_1.confirmNativeActionDetailed;
145
+ const reason = options.clientLabel ? `IDE: ${options.clientLabel}\n${envelope.reason}` : envelope.reason;
146
+ let outcome = 'unavailable';
147
+ let latencyMs = 0;
148
+ try {
149
+ ({ outcome, latencyMs } = await dialog(reason, options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, { allowAlways, alwaysScopeLabel: scopeLabel }));
150
+ }
151
+ catch {
152
+ outcome = 'unavailable';
153
+ }
154
+ if (outcome === 'unavailable')
155
+ return { ...clean, resolution: 'ide' };
156
+ const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
157
+ const rule = { policyId: envelope.policyId, policyName: envelope.policyName, matchedRule: envelope.matchedRule, policyHash: envelope.policyHash };
158
+ if (outcome === 'allow' || outcome === 'allow_always') {
159
+ // Same bookkeeping the post-execution hook would have done for an IDE Allow.
160
+ (0, devConfirm_1.settleDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
161
+ let granted = false;
162
+ if (outcome === 'allow_always' && allowAlways && envelope.policyHash) {
163
+ granted = Boolean((0, devConfirm_1.recordDeveloperGrant)(envelope.scope, { ...rule, policyHash: envelope.policyHash }));
164
+ }
165
+ // The MCP gateway sees this exact call next; do not ask the person twice.
166
+ if (envelope.event === 'mcp')
167
+ (0, devConfirm_1.recordConfirmedAction)(envelope.toolName, envelope.argsDigest);
168
+ (0, telemetry_1.spoolEvent)({
169
+ decision: 'allow',
170
+ toolName: envelope.toolName,
171
+ operation: envelope.operation,
172
+ reason: granted
173
+ ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (30 days, this rule)${policyLabel}`
174
+ : `developer confirmed in the FullCourtDefense dialog${policyLabel}`,
175
+ ruleId: envelope.policyId,
176
+ policyHash: envelope.policyHash,
177
+ approvalScope: 'developer',
178
+ approvedBy: 'developer',
179
+ confirmLatencyMs: latencyMs,
180
+ });
181
+ (0, telemetry_1.triggerFlush)(false);
182
+ const agentMsg = granted
183
+ ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} under this rule.`
184
+ : `FullCourtDefense: the developer allowed this ${envelope.event} once in the FullCourtDefense dialog.`;
185
+ return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, true, '', agentMsg)), exitCode: 0, resolution: outcome };
186
+ }
187
+ (0, devConfirm_1.rejectDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
188
+ const timedOut = outcome === 'timeout';
189
+ (0, telemetry_1.spoolEvent)({
190
+ decision: 'block',
191
+ toolName: envelope.toolName,
192
+ operation: envelope.operation,
193
+ reason: timedOut
194
+ ? `developer did not answer the FullCourtDefense dialog in time${policyLabel}`
195
+ : `developer denied in the FullCourtDefense dialog${policyLabel}`,
196
+ ruleId: envelope.policyId,
197
+ policyHash: envelope.policyHash,
198
+ approvalScope: 'developer',
199
+ confirmLatencyMs: latencyMs,
200
+ });
201
+ (0, telemetry_1.triggerFlush)(true);
202
+ const userMsg = timedOut
203
+ ? `FullCourtDefense: no answer in the confirmation dialog, so this ${envelope.event} was not run${policyLabel}. Run it again to be asked again.`
204
+ : `FullCourtDefense: you denied this ${envelope.event} in the confirmation dialog${policyLabel}.`;
205
+ const agentMsg = timedOut
206
+ ? `FullCourtDefense: the developer did not answer the confirmation dialog in time; this ${envelope.event} was not executed. Do not retry or work around it — ask the developer.`
207
+ : `FullCourtDefense: the developer denied this ${envelope.event} in the confirmation dialog. Do not retry or work around it.`;
208
+ return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: outcome };
209
+ }
@@ -56,7 +56,7 @@ function headlineFor(outcome, noun) {
56
56
  }
57
57
  /** The step list title — a pause is lifted by an answer, a block by a rule change. */
58
58
  function stepsTitle(outcome) {
59
- return outcome === 'block' ? 'To remove this block:' : outcome === 'mask' ? 'To change this rule:' : 'To remove this block (or to stop being asked):';
59
+ return outcome === 'block' ? 'To remove this block:' : outcome === 'mask' ? 'To change this rule:' : 'To review this rule:';
60
60
  }
61
61
  function eventNoun(event) {
62
62
  switch (event) {
@@ -84,7 +84,10 @@ const FACT_WORDS = [
84
84
  ? `a secret-shaped value was found in the arguments${f['toolArgs.secretKind'] && f['toolArgs.secretKind'] !== 'none' ? ` (${f['toolArgs.secretKind'].replace(/_/g, ' ')})` : ''}`
85
85
  : undefined],
86
86
  ['toolArgs.containsPii', v => v === 'true' ? 'personal data (PII) was found in the arguments' : undefined],
87
- ['destination.type', (v, f) => v === 'external' ? `the destination ${f['destination.domain'] ? `"${f['destination.domain']}" ` : ''}is classified as external; this does not establish whether your organisation trusts it` : undefined],
87
+ ['destination.type', (v, f) => v !== 'external' ? undefined
88
+ : f['action.kind'] === 'shell' && /^(shell|execute|unknown)$/i.test(f.operation || '')
89
+ ? `the script references ${f['destination.domain'] ? `"${f['destination.domain']}"` : 'an external host'}; this rule restricts possible outbound actions. This is not evidence of an upload or of an untrusted host`
90
+ : `the destination ${f['destination.domain'] ? `"${f['destination.domain']}" ` : ''}is classified as external; this does not establish whether your organisation trusts it`],
88
91
  ['destination.domain', (v, f) => f['destination.type'] === 'external' ? undefined : `destination host: ${v}`],
89
92
  ['url.risk', v => v && v !== 'none' && v !== 'low' ? `the URL was rated ${v} risk` : undefined],
90
93
  ['path.sensitive', v => v === 'true' ? 'the path is a sensitive location' : undefined],
@@ -141,7 +144,7 @@ function buildBlockExplanation(input) {
141
144
  const name = src.policyName || 'an Action Policy';
142
145
  consoleLink = policiesLink(consoleUrl, src.policyId);
143
146
  rule = `Action Policy "${name}" (${scopeWords(src.scope, input.developerName)})${src.matchedRule ? ` — rule: ${src.matchedRule}` : ''}`;
144
- const facts = relevantFacts(src.matchedRule, src.facts || {}, src.operation);
147
+ const facts = relevantFacts(src.matchedRule, { ...src.facts, operation: src.facts?.operation || src.operation }, src.operation);
145
148
  const extraReason = informativeReason(src.reason, name);
146
149
  if (extraReason)
147
150
  facts.push(extraReason);
@@ -163,12 +166,18 @@ function buildBlockExplanation(input) {
163
166
  rule = src.source === 'custom'
164
167
  ? `org custom rule "${label}" in Local Safety${detail ? ` (${detail})` : ''}`
165
168
  : `built-in Local Safety rule "${label}"${detail ? ` (${detail})` : ''}`;
166
- why = src.explanation ? `${src.reason} — ${src.explanation}` : src.reason;
169
+ // Findings describe a match before the configured action is applied. A built-in
170
+ // "Blocked ..." reason must not turn a pending approval into a final denial.
171
+ const reason = src.source === 'builtin' && (input.outcome === 'ask' || input.outcome === 'approval')
172
+ ? src.reason.replace(/^Blocked\b/, 'Matched') : src.reason;
173
+ why = src.explanation ? `${reason} — ${src.explanation}` : reason;
167
174
  unblock = [
168
175
  `Open the rule in the console: ${consoleLink}`,
169
176
  `An admin can set "${label}" to Ask the developer, Warn only, or Off — for everyone or only for ${input.developerName || 'this machine'}.`,
170
177
  ...(machine ? [`See this decision, with the rule name, on your machine timeline: ${machine}`] : []),
171
- 'Wrong block? Tell your admin the rule name above — every decision is recorded, nothing was sent anywhere.',
178
+ input.outcome === 'ask' || input.outcome === 'approval'
179
+ ? 'If this approval request looks wrong, tell your admin the rule name above.'
180
+ : 'Wrong block? Tell your admin the rule name above — every decision is recorded, nothing was sent anywhere.',
172
181
  ];
173
182
  }
174
183
  else {
@@ -228,7 +237,10 @@ function buildBlockExplanation(input) {
228
237
  const agentVerb = input.outcome === 'block' || input.outcome === 'mask'
229
238
  ? 'This is a policy decision, not an error — do not retry, rephrase, or work around it.'
230
239
  : 'Wait for the developer\'s answer — do not retry or work around it.';
231
- const agentMessage = `${headline} Rule: ${rule}. Why: ${why}. ${agentVerb} Tell the developer exactly this: the block came from ${rule}; to remove it: ${unblock[0]}`;
240
+ const resolution = input.outcome === 'ask' || input.outcome === 'approval'
241
+ ? `this approval request came from ${rule}; to review the rule: ${unblock[0]}`
242
+ : `the block came from ${rule}; to remove it: ${unblock[0]}`;
243
+ const agentMessage = `${headline} Rule: ${rule}. Why: ${why}. ${agentVerb} Tell the developer exactly this: ${resolution}`;
232
244
  return { headline, what, rule, why, unblock, consoleLink, userMessage, agentMessage };
233
245
  }
234
246
  /**
@@ -0,0 +1,20 @@
1
+ export interface BoundaryVerification {
2
+ version: 1;
3
+ ranAt: string;
4
+ basis: 'isolated_hook_fixture';
5
+ runtimeDigest: string;
6
+ checks: Array<{
7
+ boundary: 'shell' | 'files' | 'mcp' | 'network';
8
+ status: 'passed' | 'failed' | 'unavailable';
9
+ allowed: number;
10
+ denied: number;
11
+ durationMs: number;
12
+ }>;
13
+ }
14
+ /** Fingerprint the packaged JavaScript; shared by checker evidence and benchmarks. */
15
+ export declare function computeRuntimeDigest(): string;
16
+ /** Exercises the installed hook executable, never the commands in its stdin.
17
+ * A private HOME, cache and IPC address isolate the probe from customer state.
18
+ * This is checker proof, not proof that an IDE invokes the hook or obeys it.
19
+ */
20
+ export declare function runBoundaryVerification(): Promise<BoundaryVerification>;