fullcourtdefense-cli 1.38.0 → 1.39.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.
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
17
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.
18
+ * org chose it (`FCD_ASK_CHANNEL=ide`) — and never for a client that reports a
19
+ * permission mode in which it may answer its own card (v139 F-13 / F-33): our
20
+ * dialog asks there, or the action is denied. The envelope never reaches the IDE.
19
21
  *
20
22
  * Deterministic all the way: the rule that asked is named in the envelope, the
21
23
  * grant is bound to that rule's hash, and nothing here re-evaluates the action.
@@ -67,8 +69,20 @@ export interface AskEnvelope {
67
69
  instanceId?: string;
68
70
  attemptId?: string;
69
71
  };
72
+ /**
73
+ * The permission mode the asking client reported (Claude Code `permission_mode`).
74
+ * A mode in which the IDE may answer its own card (`idePromptState` = auto) never
75
+ * gets the question: our dialog asks, or the action is denied. Absent: the client
76
+ * does not say (Cursor, VS Code, Copilot CLI) or an older hook build.
77
+ */
78
+ ideMode?: string;
70
79
  }
71
- export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide';
80
+ /**
81
+ * How an ask ended in the per-event process. `ide`: handed to the IDE's own card.
82
+ * `ide_auto`: no FullCourtDefense dialog was possible and the IDE runs in a mode that
83
+ * may answer its own card — denied, never handed over.
84
+ */
85
+ export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide' | 'ide_auto';
72
86
  export interface HookVerdict {
73
87
  stdout: string;
74
88
  stderr: string;
@@ -92,12 +106,19 @@ export declare function takeAskEnvelope(stdout: string): {
92
106
  * Last step of the per-event hook process (full and slim entries alike): an `ask`
93
107
  * verdict is put to the developer in the FullCourtDefense dialog and becomes the
94
108
  * IDE's allow / deny; anything else — and an ask where no dialog is possible — is
95
- * written as-is with the envelope removed. Then the process exits.
109
+ * written as-is with the envelope removed (an ask the IDE may answer on its own is
110
+ * denied instead, v139 F-13). Then the process exits.
96
111
  */
97
112
  export declare function emitVerdictForIde(args: {
98
113
  event?: string;
99
114
  agentClient?: string;
100
115
  }, result: HookVerdict, entry: 'ipc' | 'local' | 'slim-ipc' | 'slim-local'): Promise<never>;
116
+ /**
117
+ * The verdict when the dialog step itself failed (emitVerdictForIde's catch): the dialog
118
+ * step must never break the verdict, so the IDE ask goes out with the envelope stripped —
119
+ * or, where the IDE may answer its own card, a deny (v139 F-13). Never throws.
120
+ */
121
+ export declare function verdictAfterDialogError(result: HookVerdict, clientLabel?: string): HookVerdict;
101
122
  /**
102
123
  * Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
103
124
  * IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
package/dist/askDialog.js CHANGED
@@ -5,6 +5,7 @@ exports.askChannel = askChannel;
5
5
  exports.attachAskEnvelope = attachAskEnvelope;
6
6
  exports.takeAskEnvelope = takeAskEnvelope;
7
7
  exports.emitVerdictForIde = emitVerdictForIde;
8
+ exports.verdictAfterDialogError = verdictAfterDialogError;
8
9
  exports.resolveAskWithDialog = resolveAskWithDialog;
9
10
  /**
10
11
  * The developer's question, answered in OUR dialog — one flow for every IDE.
@@ -23,7 +24,9 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
23
24
  *
24
25
  * and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
25
26
  * 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
+ * org chose it (`FCD_ASK_CHANNEL=ide`) — and never for a client that reports a
28
+ * permission mode in which it may answer its own card (v139 F-13 / F-33): our
29
+ * dialog asks there, or the action is denied. The envelope never reaches the IDE.
27
30
  *
28
31
  * Deterministic all the way: the rule that asked is named in the envelope, the
29
32
  * grant is bound to that rule's hash, and nothing here re-evaluates the action.
@@ -97,7 +100,8 @@ function rewriteDecision(decision, allowed, userMsg, agentMsg) {
97
100
  * Last step of the per-event hook process (full and slim entries alike): an `ask`
98
101
  * verdict is put to the developer in the FullCourtDefense dialog and becomes the
99
102
  * IDE's allow / deny; anything else — and an ask where no dialog is possible — is
100
- * written as-is with the envelope removed. Then the process exits.
103
+ * written as-is with the envelope removed (an ask the IDE may answer on its own is
104
+ * denied instead, v139 F-13). Then the process exits.
101
105
  */
102
106
  async function emitVerdictForIde(args, result, entry) {
103
107
  // This process ends with the verdict below: an upload the dialog's outcome
@@ -119,9 +123,8 @@ async function emitVerdictForIde(args, result, entry) {
119
123
  final = resolved;
120
124
  }
121
125
  catch (err) {
122
- // The dialog step must never break the verdict: fall back to the IDE ask (envelope stripped).
123
126
  (0, hookIo_1.dbg)({ phase: 'ask_dialog_error', event: args.event, entry, error: err instanceof Error ? err.message : String(err) });
124
- final = { ...result, stdout: takeAskEnvelope(result.stdout).stdout };
127
+ final = verdictAfterDialogError(result, args.agentClient);
125
128
  }
126
129
  if (final.stderr) {
127
130
  try {
@@ -137,6 +140,22 @@ async function emitVerdictForIde(args, result, entry) {
137
140
  }
138
141
  return process.exit(final.exitCode);
139
142
  }
143
+ /**
144
+ * The verdict when the dialog step itself failed (emitVerdictForIde's catch): the dialog
145
+ * step must never break the verdict, so the IDE ask goes out with the envelope stripped —
146
+ * or, where the IDE may answer its own card, a deny (v139 F-13). Never throws.
147
+ */
148
+ function verdictAfterDialogError(result, clientLabel) {
149
+ const taken = takeAskEnvelope(result.stdout);
150
+ let final = { ...result, stdout: taken.stdout };
151
+ try {
152
+ if (taken.decision && taken.envelope && decisionAsks(taken.decision) && (0, devConfirm_1.idePromptState)(taken.envelope.ideMode) === 'auto') {
153
+ final = denyNoHumanPrompt({ ...final, envelope: taken.envelope }, taken.decision, taken.envelope, clientLabel);
154
+ }
155
+ }
156
+ catch { /* the verdict must still be written */ }
157
+ return final;
158
+ }
140
159
  /**
141
160
  * Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
142
161
  * IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
@@ -155,8 +174,18 @@ async function resolveAskWithDialog(verdict, options = {}) {
155
174
  // left for a person to read and answer: the IDE's own ask card takes the
156
175
  // question instead, so the hook still answers inside the IDE's window.
157
176
  const dialogTimeoutMs = Math.min(options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, options.deadlineAt === undefined ? Number.POSITIVE_INFINITY : options.deadlineAt - Date.now());
158
- if (askChannel() === 'ide' || !canAsk || dialogTimeoutMs < MIN_DIALOG_TIMEOUT_MS)
159
- return { ...clean, resolution: 'ide' };
177
+ // v139 F-13 / F-33: the IDE's card is a channel only where it can reach a person. A
178
+ // client that reports a mode in which it may answer its own card (Claude Code
179
+ // bypassPermissions, acceptEdits, auto, dontAsk) never gets the question, whatever
180
+ // FCD_ASK_CHANNEL / FCD_HOOK_IDE_TIMEOUT_MS say (both reachable from an IDE settings
181
+ // env block the agent can write): our dialog asks, or — no dialog possible here — the
182
+ // action is denied. A client that does not say keeps today's routing; its card's
183
+ // answer is recorded as unverified and never becomes memory (settlePostExecution).
184
+ const ideMayAnswerItself = (0, devConfirm_1.idePromptState)(envelope.ideMode) === 'auto';
185
+ const dialogPossible = canAsk && dialogTimeoutMs >= MIN_DIALOG_TIMEOUT_MS;
186
+ if (!dialogPossible || (askChannel() === 'ide' && !ideMayAnswerItself)) {
187
+ return ideMayAnswerItself ? denyNoHumanPrompt(clean, decision, envelope, options.clientLabel) : { ...clean, resolution: 'ide' };
188
+ }
160
189
  const grantHash = envelope.ruleHash || envelope.policyHash;
161
190
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
162
191
  const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits) && (0, devConfirm_1.grantOffered)(envelope.scope);
@@ -187,7 +216,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
187
216
  outcome = 'unavailable';
188
217
  }
189
218
  if (outcome === 'unavailable')
190
- return { ...clean, resolution: 'ide' };
219
+ return ideMayAnswerItself ? denyNoHumanPrompt(clean, decision, envelope, options.clientLabel) : { ...clean, resolution: 'ide' };
191
220
  const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
192
221
  // The outcome is the asking call's: its client and identity, not this process's.
193
222
  // No latency / verdict path: the ask itself recorded the hook's wait (one health
@@ -253,6 +282,44 @@ async function resolveAskWithDialog(verdict, options = {}) {
253
282
  : `FullCourtDefense: the developer denied this ${envelope.event} in the confirmation dialog. Do not retry or work around it.`;
254
283
  return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: outcome };
255
284
  }
285
+ /**
286
+ * v139 F-13: the ask cannot reach a person — no FullCourtDefense dialog is possible here
287
+ * and the IDE runs in a mode that may answer its own card (or refuse it unseen). Denied
288
+ * with an honest record (nobody answered), and the pending marker is dropped so no later
289
+ * post-execution hook can settle it.
290
+ */
291
+ function denyNoHumanPrompt(clean, decision, envelope, clientLabel) {
292
+ try {
293
+ (0, devConfirm_1.rejectDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
294
+ }
295
+ catch { /* the deny below still stands */ }
296
+ const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
297
+ const agentClient = envelope.agentClient || clientLabel;
298
+ const client = agentClient || 'the agent';
299
+ const mode = envelope.ideMode || 'auto-approve';
300
+ try {
301
+ (0, telemetry_1.spoolEvent)({
302
+ decision: 'block',
303
+ toolName: envelope.toolName,
304
+ operation: envelope.operation,
305
+ // The gateway's "confirmation not possible" family: nobody could be asked, which the
306
+ // fleet keeps as a block alert (backend fleet-block-alerts.ts), unlike a dialog timeout.
307
+ reason: `developer confirmation not possible (the FullCourtDefense dialog cannot be shown here and ${client} runs in "${mode}" mode, which may answer its own prompt without showing it to a person) — denied${policyLabel}`,
308
+ ruleId: envelope.policyId,
309
+ policyHash: envelope.policyHash,
310
+ approvalScope: 'developer',
311
+ // Nobody answered: not a rule's block and not a person's deny.
312
+ askOutcome: 'unanswered',
313
+ ...(agentClient ? { agentClient } : {}),
314
+ ...envelopeIdentity(envelope),
315
+ });
316
+ (0, telemetry_1.triggerFlush)(true);
317
+ }
318
+ catch { /* the deny below still stands */ }
319
+ const userMsg = `FullCourtDefense stopped this ${envelope.event}: a rule asks you to confirm it${policyLabel}, but ${client} is in "${mode}" mode, which may approve its own prompts without showing them to you, and the FullCourtDefense dialog cannot be shown here. Switch the agent to a mode that asks you, or run it where the FullCourtDefense dialog can appear, to be asked.`;
320
+ const agentMsg = `${userMsg} Do not retry or work around it.`;
321
+ return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: 'ide_auto' };
322
+ }
256
323
  /** The asking call's identity from the envelope: well-formed parts only. */
257
324
  function envelopeIdentity(envelope) {
258
325
  const raw = envelope.identity;