fullcourtdefense-cli 1.35.0 → 1.35.2

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.
@@ -13,7 +13,7 @@ export declare const MIN_HUMAN_ANSWER_MS = 1000;
13
13
  export declare function looksLikeClientAutoAnswer(answer: 'accept' | 'decline' | 'timeout', latencyMs: number): boolean;
14
14
  /**
15
15
  * OS-owned confirmation dialog — the IDE-independent fallback for "Ask the developer".
16
- * PowerShell `WScript.Shell.Popup` on Windows, `osascript` on macOS, `zenity` elsewhere.
16
+ * Branded WinForms card on Windows, WKWebView card on macOS, `zenity` elsewhere.
17
17
  * The agent cannot answer it: it is not connected to hook stdin, the MCP stream or the
18
18
  * agent's terminal. Resolves `true` only for an explicit "Allow once"; decline, close,
19
19
  * timeout, a missing dialog binary or no human present all resolve `false`.
@@ -42,6 +42,8 @@ export interface NativeDialogOptions {
42
42
  alwaysScopeLabel?: string;
43
43
  /** How long / how many times it would hold, e.g. "30 days" or "7 days or 20 uses". */
44
44
  alwaysLimitsLabel?: string;
45
+ /** Icon and copy. Buttons stay Deny / Allow once / Always allow for `ask`. */
46
+ tone?: 'ask' | 'approval' | 'block';
45
47
  }
46
48
  export declare function confirmNativeActionDetailed(reason: string, timeoutMs?: number, options?: NativeDialogOptions): Promise<{
47
49
  outcome: NativeDialogOutcome;
@@ -107,7 +107,7 @@ function looksLikeClientAutoAnswer(answer, latencyMs) {
107
107
  }
108
108
  /**
109
109
  * OS-owned confirmation dialog — the IDE-independent fallback for "Ask the developer".
110
- * PowerShell `WScript.Shell.Popup` on Windows, `osascript` on macOS, `zenity` elsewhere.
110
+ * Branded WinForms card on Windows, WKWebView card on macOS, `zenity` elsewhere.
111
111
  * The agent cannot answer it: it is not connected to hook stdin, the MCP stream or the
112
112
  * agent's terminal. Resolves `true` only for an explicit "Allow once"; decline, close,
113
113
  * timeout, a missing dialog binary or no human present all resolve `false`.
@@ -129,43 +129,158 @@ function nativeDialogAvailable() {
129
129
  return true;
130
130
  return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
131
131
  }
132
+ function b64(value) {
133
+ return Buffer.from(value, 'utf8').toString('base64');
134
+ }
135
+ function htmlEsc(value) {
136
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
137
+ }
138
+ function toneTheme(tone) {
139
+ if (tone === 'block')
140
+ return { icon: '#DC2626', badgeBg: '#FEF2F2', badgeFg: '#B91C1C', glyph: '✕' };
141
+ if (tone === 'approval')
142
+ return { icon: '#D97706', badgeBg: '#FFEDD5', badgeFg: '#C2410C', glyph: '!' };
143
+ return { icon: '#2563EB', badgeBg: '#DBEAFE', badgeFg: '#1D4ED8', glyph: '?' };
144
+ }
145
+ function argb(hex) {
146
+ const n = hex.replace('#', '');
147
+ return `${parseInt(n.slice(0, 2), 16)},${parseInt(n.slice(2, 4), 16)},${parseInt(n.slice(4, 6), 16)}`;
148
+ }
149
+ /** Card HTML for macOS WKWebView. Button labels stay the existing Deny / Allow once / Always allow. */
150
+ function dialogCardHtml(view, options) {
151
+ const theme = toneTheme(view.tone);
152
+ const bullets = (items, color) => items.map(item => `<li><span class="dot" style="background:${color}"></span>${htmlEsc(item)}</li>`).join('');
153
+ const always = options.allowAlways
154
+ ? `<button class="ghost" onclick="location.hash='fcd-always'">${htmlEsc(exports.DIALOG_BUTTON_ALWAYS)}</button>`
155
+ : '';
156
+ return `<!doctype html><html><head><meta charset="utf-8"><style>
157
+ *{box-sizing:border-box}body{margin:0;font:13px/1.45 -apple-system,Segoe UI,sans-serif;background:#f4f7fb;color:#0f172a}
158
+ .card{margin:12px;background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:20px 22px 16px;box-shadow:0 10px 30px rgba(15,23,42,.08)}
159
+ .brand{display:flex;justify-content:space-between;align-items:center;color:#64748b;font-weight:600;font-size:12px;margin-bottom:14px}
160
+ .hero{display:flex;gap:14px;align-items:flex-start}
161
+ .icon{width:44px;height:44px;border-radius:50%;background:${theme.badgeBg};color:${theme.icon};border:1px solid ${theme.icon}33;display:flex;align-items:center;justify-content:center;font:700 20px/1 sans-serif;flex:none}
162
+ .badge{display:inline-block;background:${theme.badgeBg};color:${theme.badgeFg};font-size:11px;font-weight:700;padding:3px 8px;border-radius:999px;margin-bottom:6px}
163
+ h1{margin:0 0 4px;font-size:22px}.sub{color:#64748b;margin:0 0 14px}
164
+ .box{border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px;margin:0 0 10px;background:#f8fafc}
165
+ h2{margin:0 0 6px;font-size:13px}.k{color:#64748b;font-size:11px;font-weight:700}
166
+ .action{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;white-space:pre-wrap;word-break:break-word;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:8px 10px}
167
+ ul{list-style:none;margin:0;padding:0}li{display:flex;gap:8px;margin:6px 0}
168
+ .dot{width:8px;height:8px;border-radius:50%;margin-top:6px;flex:none}
169
+ .row{display:flex;justify-content:flex-end;gap:8px;align-items:center;margin-top:14px;flex-wrap:wrap}
170
+ button{font:600 13px/1 -apple-system,Segoe UI,sans-serif;border-radius:8px;padding:9px 14px;cursor:pointer}
171
+ .primary{background:#1d4ed8;color:#fff;border:0}.ghost{background:#fff;border:1px solid #cbd5e1}
172
+ .danger{background:#fff;color:#dc2626;border:1px solid #fecaca}
173
+ .foot{color:#94a3b8;font-size:11px;margin-top:10px}
174
+ </style></head><body><div class="card">
175
+ <div class="brand"><span>FullCourtDefense</span><span>Keep developers moving, safely.</span></div>
176
+ <div class="hero"><div class="icon">${theme.glyph}</div><div>
177
+ <span class="badge">${htmlEsc(view.badge)}</span>
178
+ <h1>${htmlEsc(view.headline)}</h1>
179
+ <p class="sub">${htmlEsc(view.subtitle)}</p>
180
+ </div></div>
181
+ <div class="box"><h2>Action</h2>
182
+ ${view.kind ? `<div class="k">${htmlEsc(view.kind)}</div>` : ''}
183
+ ${view.action ? `<div class="action">${htmlEsc(view.action)}</div>` : '<p class="sub">The action is described in the IDE message.</p>'}
184
+ </div>
185
+ ${view.policy ? `<div class="box"><h2>Matched policy</h2><div><span class="k">Policy</span> ${htmlEsc(view.policy)}</div>${view.scope ? `<div><span class="k">Scope</span> ${htmlEsc(view.scope)}</div>` : ''}</div>` : ''}
186
+ <h2>Why it triggered</h2><ul>${bullets(view.why, theme.icon)}</ul>
187
+ <h2>What happens next</h2><ul>${bullets(view.next, '#2563EB')}</ul>
188
+ <div class="row">${always}
189
+ <button class="primary" onclick="location.hash='fcd-once'">${htmlEsc(exports.DIALOG_BUTTON_ONCE)}</button>
190
+ <button class="danger" onclick="location.hash='fcd-deny'">${htmlEsc(exports.DIALOG_BUTTON_DENY)}</button>
191
+ </div>
192
+ <p class="foot">${htmlEsc(view.footer)}</p>
193
+ </div></body></html>`;
194
+ }
132
195
  /**
133
- * Windows: a small owned window (System.Windows.Forms) — `WScript.Shell.Popup`
134
- * cannot label its buttons, and "Yes / No / Cancel" is not an approval UI.
135
- * TopMost keeps it above the IDE that triggered it; Enter/Escape both mean Deny.
196
+ * Windows branded card. Compact prompt text stays the first payload so tests still
197
+ * read the dialog copy; layout facts travel as JSON.
136
198
  * Exit codes: 0 = Allow once, 3 = Always allow, 2 = timed out, anything else = Deny.
137
199
  */
138
- function windowsDialogScript(message, timeoutSeconds, options, title = 'FullCourtDefense') {
139
- const encoded = Buffer.from(message, 'utf8').toString('base64');
140
- const encodedTitle = Buffer.from(title, 'utf8').toString('base64');
200
+ function windowsDialogScript(message, timeoutSeconds, options, title, view) {
201
+ const encoded = b64(message);
202
+ const encodedTitle = b64(title);
203
+ const encodedView = b64(JSON.stringify(view));
204
+ const theme = toneTheme(view.tone);
141
205
  return [
142
206
  `# ${exports.DIALOG_SCRIPT_MARKER}`,
143
207
  'Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing',
144
208
  '[System.Windows.Forms.Application]::EnableVisualStyles()',
145
209
  `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`,
146
210
  `$title=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedTitle}'))`,
147
- "$f=New-Object Windows.Forms.Form; $f.Text=$title; $f.TopMost=$true; $f.StartPosition='CenterScreen'; $f.FormBorderStyle='FixedDialog'; $f.MaximizeBox=$false; $f.MinimizeBox=$false; $f.ShowInTaskbar=$true; $f.AutoSize=$true; $f.AutoSizeMode='GrowAndShrink'; $f.Padding=New-Object Windows.Forms.Padding(18,16,18,16); $f.Font=New-Object Drawing.Font('Segoe UI',9.75)",
148
- "$t=New-Object Windows.Forms.TableLayoutPanel; $t.AutoSize=$true; $t.AutoSizeMode='GrowAndShrink'; $t.ColumnCount=1; $t.RowCount=3; $t.Dock='Fill'",
149
- // First line is the question; it is the headline. The rest is the body.
150
- "$parts=$msg -split \"`n\",2; $h=New-Object Windows.Forms.Label; $h.Text=$parts[0]; $h.AutoSize=$true; $h.MaximumSize=New-Object Drawing.Size(600,0); $h.Font=New-Object Drawing.Font('Segoe UI',12,[Drawing.FontStyle]::Bold); $h.Margin=New-Object Windows.Forms.Padding(0,0,0,8); $t.Controls.Add($h,0,0)",
151
- "$l=New-Object Windows.Forms.Label; $l.Text=$(if($parts.Count -gt 1){$parts[1].TrimStart(\"`n\")}else{''}); $l.AutoSize=$true; $l.MaximumSize=New-Object Drawing.Size(600,0); $l.Margin=New-Object Windows.Forms.Padding(0,0,0,14); $t.Controls.Add($l,0,1)",
152
- "$b=New-Object Windows.Forms.FlowLayoutPanel; $b.FlowDirection='RightToLeft'; $b.AutoSize=$true; $b.AutoSizeMode='GrowAndShrink'; $b.Dock='Fill'; $b.WrapContents=$false",
153
- `$deny=New-Object Windows.Forms.Button; $deny.Text='${exports.DIALOG_BUTTON_DENY}'; $deny.AutoSize=$true; $deny.MinimumSize=New-Object Drawing.Size(96,30); $deny.DialogResult='Cancel'; $deny.TabIndex=0; $b.Controls.Add($deny)`,
154
- `$once=New-Object Windows.Forms.Button; $once.Text='${exports.DIALOG_BUTTON_ONCE}'; $once.AutoSize=$true; $once.MinimumSize=New-Object Drawing.Size(96,30); $once.DialogResult='OK'; $once.TabIndex=1; $b.Controls.Add($once)`,
211
+ `$v=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedView}')) | ConvertFrom-Json`,
212
+ "$bg=[Drawing.Color]::White; $muted=[Drawing.Color]::FromArgb(100,116,139); $ink=[Drawing.Color]::FromArgb(15,23,42)",
213
+ `$accent=[Drawing.Color]::FromArgb(${argb(theme.icon)}); $badgeBg=[Drawing.Color]::FromArgb(${argb(theme.badgeBg)}); $badgeFg=[Drawing.Color]::FromArgb(${argb(theme.badgeFg)})`,
214
+ "$f=New-Object Windows.Forms.Form; $f.Text=$title; $f.TopMost=$true; $f.StartPosition='CenterScreen'; $f.FormBorderStyle='FixedDialog'; $f.MaximizeBox=$false; $f.MinimizeBox=$false; $f.ShowInTaskbar=$true; $f.BackColor=$bg; $f.Font=New-Object Drawing.Font('Segoe UI',9.75); $f.ClientSize=New-Object Drawing.Size(700,640)",
215
+ // Panel.Padding does not move absolutely positioned children: every control takes $x0 explicitly.
216
+ "$root=New-Object Windows.Forms.Panel; $root.Dock='Fill'; $root.AutoScroll=$true; $root.BackColor=$bg; $f.Controls.Add($root)",
217
+ "function AddLabel($parent,$text,$font,$color,$y,$w,$x){ $l=New-Object Windows.Forms.Label; $l.Text=$text; $l.Font=$font; $l.ForeColor=$color; $l.AutoSize=$true; $l.MaximumSize=New-Object Drawing.Size($w,0); $l.Location=New-Object Drawing.Point($(if($x){$x}else{$x0}),$y); $parent.Controls.Add($l); return $l }",
218
+ "$x0=24; $y=18; $w=652",
219
+ "AddLabel $root 'FullCourtDefense' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $muted $y $w | Out-Null; $y+=30",
220
+ `$icon=New-Object Windows.Forms.Label; $icon.Text='${theme.glyph}'; $icon.Font=New-Object Drawing.Font('Segoe UI',18,[Drawing.FontStyle]::Bold); $icon.TextAlign='MiddleCenter'; $icon.Size=New-Object Drawing.Size(44,44); $icon.Location=New-Object Drawing.Point($x0,$y); $icon.ForeColor=$accent; $icon.BackColor=$badgeBg; $root.Controls.Add($icon)`,
221
+ "$hx=$x0+58; $hw=$w-58",
222
+ "$badge=AddLabel $root ([string]$v.badge) (New-Object Drawing.Font('Segoe UI',8.25,[Drawing.FontStyle]::Bold)) $badgeFg $y $hw $hx; $badge.BackColor=$badgeBg; $badge.Padding=New-Object Windows.Forms.Padding(6,2,6,2)",
223
+ "$h=AddLabel $root ([string]$v.headline) (New-Object Drawing.Font('Segoe UI',16,[Drawing.FontStyle]::Bold)) $ink ($badge.Bottom+4) $hw $hx",
224
+ "$s=AddLabel $root ([string]$v.subtitle) (New-Object Drawing.Font('Segoe UI',9.75)) $muted ($h.Bottom+4) $hw $hx; $y=[Math]::Max($icon.Bottom,$s.Bottom)+18",
225
+ "AddLabel $root 'Action' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22",
226
+ "if($v.kind){ AddLabel $root ([string]$v.kind) (New-Object Drawing.Font('Segoe UI',8.25,[Drawing.FontStyle]::Bold)) $muted $y $w | Out-Null; $y+=18 }",
227
+ "if($v.action){ $a=AddLabel $root ([string]$v.action) (New-Object Drawing.Font('Consolas',9.75)) $ink $y $w; $y=$a.Bottom+12 } else { $y+=8 }",
228
+ "if($v.policy){ AddLabel $root 'Matched policy' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22; $pn=AddLabel $root ([string]$v.policy) (New-Object Drawing.Font('Segoe UI',9.75)) $ink $y $w; $y=$pn.Bottom+2; if($v.scope){ $ps=AddLabel $root ([string]$v.scope) (New-Object Drawing.Font('Segoe UI',9)) $muted $y $w; $y=$ps.Bottom+12 } }",
229
+ "AddLabel $root 'Why it triggered' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22",
230
+ "foreach($item in @($v.why)){ $row=AddLabel $root ('• '+$item) (New-Object Drawing.Font('Segoe UI',9.75)) $ink $y $w; $y=$row.Bottom+2 }",
231
+ "$y+=10; AddLabel $root 'What happens next' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22",
232
+ "foreach($item in @($v.next)){ $row=AddLabel $root ('• '+$item) (New-Object Drawing.Font('Segoe UI',9.75)) $ink $y $w; $y=$row.Bottom+2 }",
233
+ // Fixed-size row spanning the content width: RightToLeft flow puts Deny at the right edge,
234
+ // and a known height lets the footer and the form size be computed before the window exists.
235
+ "$y+=18; $b=New-Object Windows.Forms.FlowLayoutPanel; $b.FlowDirection='RightToLeft'; $b.AutoSize=$false; $b.WrapContents=$false; $b.Size=New-Object Drawing.Size($w,40); $b.Location=New-Object Drawing.Point($x0,$y)",
236
+ `$deny=New-Object Windows.Forms.Button; $deny.Text='${exports.DIALOG_BUTTON_DENY}'; $deny.AutoSize=$true; $deny.MinimumSize=New-Object Drawing.Size(96,34); $deny.FlatStyle='Flat'; $deny.ForeColor=[Drawing.Color]::FromArgb(220,38,38); $deny.BackColor=$bg; $deny.FlatAppearance.BorderColor=[Drawing.Color]::FromArgb(254,202,202); $deny.DialogResult='Cancel'; $deny.TabIndex=0; $b.Controls.Add($deny)`,
237
+ `$once=New-Object Windows.Forms.Button; $once.Text='${exports.DIALOG_BUTTON_ONCE}'; $once.AutoSize=$true; $once.MinimumSize=New-Object Drawing.Size(110,34); $once.FlatStyle='Flat'; $once.ForeColor=[Drawing.Color]::White; $once.BackColor=[Drawing.Color]::FromArgb(29,78,216); $once.FlatAppearance.BorderSize=0; $once.DialogResult='OK'; $once.TabIndex=1; $b.Controls.Add($once)`,
155
238
  options.allowAlways
156
- ? `$always=New-Object Windows.Forms.Button; $always.Text='${exports.DIALOG_BUTTON_ALWAYS}'; $always.AutoSize=$true; $always.MinimumSize=New-Object Drawing.Size(110,30); $always.DialogResult='Yes'; $always.TabIndex=2; $b.Controls.Add($always)`
239
+ ? `$always=New-Object Windows.Forms.Button; $always.Text='${exports.DIALOG_BUTTON_ALWAYS}'; $always.AutoSize=$true; $always.MinimumSize=New-Object Drawing.Size(110,34); $always.FlatStyle='Flat'; $always.BackColor=$bg; $always.DialogResult='Yes'; $always.TabIndex=2; $b.Controls.Add($always)`
157
240
  : '',
158
- "$t.Controls.Add($b,0,2); $f.Controls.Add($t); $f.CancelButton=$deny; $f.AcceptButton=$deny; $f.ActiveControl=$deny",
241
+ "$root.Controls.Add($b); $foot=AddLabel $root ([string]$v.footer) (New-Object Drawing.Font('Segoe UI',8.25)) $muted ($b.Bottom+14) $w",
242
+ // Fit the window to its content; only a very long action / reason list scrolls.
243
+ "$wa=[Windows.Forms.Screen]::PrimaryScreen.WorkingArea; $f.ClientSize=New-Object Drawing.Size(($w+2*$x0),[Math]::Min($foot.Bottom+18,[Math]::Max(360,$wa.Height-120)))",
244
+ "$f.CancelButton=$deny; $f.AcceptButton=$deny; $f.ActiveControl=$deny",
159
245
  `$timer=New-Object Windows.Forms.Timer; $timer.Interval=${timeoutSeconds * 1000}; $timer.Add_Tick({ $f.DialogResult='Abort'; $f.Close() })`,
160
- // The hook spawns PowerShell with a hidden startup show-state (no console flash);
161
- // WinForms applies that state to the FIRST window, so the form would run
162
- // invisibly. Bouncing WindowState issues a real ShowWindow(SW_RESTORE).
163
246
  "$f.Add_Load({ $f.WindowState='Minimized'; $f.WindowState='Normal' })",
164
247
  "$f.Add_Shown({ $f.Activate(); $deny.Select(); $deny.Focus(); $timer.Start() })",
165
248
  "$r=$f.ShowDialog(); $timer.Stop()",
166
249
  "if($r -eq 'OK'){exit 0} elseif($r -eq 'Yes'){exit 3} elseif($r -eq 'Abort'){exit 2} else {exit 1}",
167
250
  ].filter(Boolean).join('\n');
168
251
  }
252
+ function macosDialogScript(html, timeoutSeconds, allowAlways) {
253
+ const encoded = b64(html);
254
+ return `// ${exports.DIALOG_SCRIPT_MARKER}
255
+ ${allowAlways ? `// ${exports.DIALOG_BUTTON_ALWAYS}` : '// two-button'}
256
+ ObjC.import('Cocoa');
257
+ ObjC.import('WebKit');
258
+ var html = $.NSString.alloc.initWithDataEncoding($.NSData.alloc.initWithBase64Encoding('${encoded}'), $.NSUTF8StringEncoding);
259
+ var app = $.NSApplication.sharedApplication;
260
+ app.setActivationPolicy($.NSApplicationActivationPolicyRegular);
261
+ var win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(0,0,720,640), (1<<0)|(1<<1)|(1<<3), 2, false);
262
+ win.setTitle('FullCourtDefense');
263
+ win.setBackgroundColor($.NSColor.whiteColor);
264
+ var web = $.WKWebView.alloc.initWithFrame($.NSMakeRect(0,0,720,640));
265
+ win.contentView.addSubview(web);
266
+ web.loadHTMLStringbaseURL(html, $.NSURL.URLWithString('about:blank'));
267
+ win.center();
268
+ win.makeKeyAndOrderFront(win);
269
+ app.activateIgnoringOtherApps(true);
270
+ var deadline = Date.now() + ${timeoutSeconds * 1000};
271
+ var result = 'timeout';
272
+ while (Date.now() < deadline) {
273
+ $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.12));
274
+ var url = String(web.URL.absoluteString);
275
+ if (url.indexOf('fcd-once') >= 0) { result = '${exports.DIALOG_BUTTON_ONCE}'; break; }
276
+ if (url.indexOf('fcd-always') >= 0) { result = '${exports.DIALOG_BUTTON_ALWAYS}'; break; }
277
+ if (url.indexOf('fcd-deny') >= 0) { result = '${exports.DIALOG_BUTTON_DENY}'; break; }
278
+ if (!win.isVisible) { result = '${exports.DIALOG_BUTTON_DENY}'; break; }
279
+ }
280
+ win.close();
281
+ result;
282
+ `;
283
+ }
169
284
  async function confirmNativeActionDetailed(reason, timeoutMs = 60_000, options = {}) {
170
285
  if (!nativeDialogAvailable())
171
286
  return { outcome: 'unavailable', latencyMs: 0 };
@@ -173,21 +288,17 @@ async function confirmNativeActionDetailed(reason, timeoutMs = 60_000, options =
173
288
  let executable;
174
289
  let args;
175
290
  const message = (0, approvalPrompt_1.nativeApprovalMessage)(reason, { allowAlways: options.allowAlways, alwaysScopeLabel: options.alwaysScopeLabel, alwaysLimitsLabel: options.alwaysLimitsLabel });
291
+ const view = (0, approvalPrompt_1.nativeDialogView)(reason, { allowAlways: options.allowAlways, alwaysScopeLabel: options.alwaysScopeLabel, alwaysLimitsLabel: options.alwaysLimitsLabel, tone: options.tone || 'ask' });
176
292
  // The title names the kind of action and the policy, so the window itself says what is asking.
177
293
  const title = (0, approvalPrompt_1.nativeApprovalTitle)(reason);
178
294
  const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
179
295
  if (process.platform === 'win32') {
180
296
  executable = 'powershell.exe';
181
- args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(windowsDialogScript(message, timeoutSeconds, options, title), 'utf16le').toString('base64')];
297
+ args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(windowsDialogScript(message, timeoutSeconds, options, title, view), 'utf16le').toString('base64')];
182
298
  }
183
299
  else if (process.platform === 'darwin') {
184
300
  executable = '/usr/bin/osascript';
185
- const buttons = options.allowAlways
186
- ? `{"${exports.DIALOG_BUTTON_DENY}", "${exports.DIALOG_BUTTON_ONCE}", "${exports.DIALOG_BUTTON_ALWAYS}"}`
187
- : `{"${exports.DIALOG_BUTTON_DENY}", "${exports.DIALOG_BUTTON_ONCE}"}`;
188
- // The pressed label is printed on stdout; Deny is the cancel button (exit 1).
189
- // Message and title travel as argv items: never interpolated into the script.
190
- args = ['-e', 'on run argv', '-e', `set r to display dialog (item 1 of argv) with title (item 2 of argv) buttons ${buttons} default button "${exports.DIALOG_BUTTON_DENY}" cancel button "${exports.DIALOG_BUTTON_DENY}" giving up after ${timeoutSeconds}`, '-e', 'if gave up of r then return "timeout"', '-e', 'return button returned of r', '-e', 'end run', message, title];
301
+ args = ['-l', 'JavaScript', '-e', macosDialogScript(dialogCardHtml(view, options), timeoutSeconds, Boolean(options.allowAlways))];
191
302
  }
192
303
  else {
193
304
  executable = 'zenity';
@@ -660,6 +771,10 @@ function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PEN
660
771
  // ---------------------------------------------------------------------------
661
772
  /** Shell words that run the program that follows them, not a program of their own. */
662
773
  const SHELL_LEAD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'nice', 'nohup', 'time', 'command', 'builtin', 'exec', 'xargs', 'timeout', 'stdbuf', 'chronic', 'busybox', 'call', 'start', '&']);
774
+ /** Control-flow words (sh / bash / PowerShell): structure, never a program of their own. */
775
+ const SHELL_CONTROL_WORDS = /^(if|then|else|elif|elseif|fi|do|done|for|while|until|case|esac|try|catch|finally|foreach|function|return|switch|break|continue)$/i;
776
+ /** Words a body follows directly (`then rm x`, `do echo`, PowerShell `else { … }`). */
777
+ const SHELL_BODY_LEADS = /^(then|do|else)$/i;
663
778
  /** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
664
779
  function splitShellSegments(command) {
665
780
  const segments = [];
@@ -703,11 +818,20 @@ function splitShellSegments(command) {
703
818
  function shellPrograms(command) {
704
819
  const out = new Set();
705
820
  for (const raw of splitShellSegments(command)) {
706
- const words = raw.trim().replace(/^[\s({]+/, '').split(/\s+/).filter(Boolean);
821
+ const words = raw.trim().split(/\s+/).filter(Boolean);
707
822
  let i = 0;
708
- // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …`
823
+ // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …` / `$t = (gcloud …)`
709
824
  while (i < words.length) {
710
825
  const w = words[i];
826
+ // Grouping before the program: `( gcloud`, `{ gcloud`, `(gcloud` — the program is inside.
827
+ if (/^[({]+$/.test(w)) {
828
+ i++;
829
+ continue;
830
+ }
831
+ if (/^[({]/.test(w)) {
832
+ words[i] = w.replace(/^[({]+/, '');
833
+ continue;
834
+ }
711
835
  if (/^\$[\w:.]+$/.test(w) && words[i + 1] && /^[+-]?=$/.test(words[i + 1])) {
712
836
  i += 2;
713
837
  continue;
@@ -720,10 +844,32 @@ function shellPrograms(command) {
720
844
  i++;
721
845
  continue;
722
846
  }
847
+ if (SHELL_BODY_LEADS.test(w)) {
848
+ i++;
849
+ continue;
850
+ }
851
+ if (SHELL_CONTROL_WORDS.test(w)) {
852
+ // `if (cond) { body }` / `foreach ($x in $y) { body }` / `else { body }`: the program
853
+ // runs in the body, so skip to the first `{` and read on from there.
854
+ const open = words.findIndex((word, index) => index > i && word.includes('{'));
855
+ if (open < 0) {
856
+ i = words.length;
857
+ break;
858
+ }
859
+ const rest = words[open].slice(words[open].indexOf('{') + 1);
860
+ if (rest) {
861
+ words[open] = rest;
862
+ i = open;
863
+ }
864
+ else {
865
+ i = open + 1;
866
+ }
867
+ continue;
868
+ }
723
869
  break;
724
870
  }
725
871
  const lead = words[i];
726
- if (!lead || /^[$@(){}\]\[|&;<>#'"`-]/.test(lead) || /^(if|then|else|elif|fi|do|done|for|while|until|case|esac|try|catch|finally|foreach|function|return|switch|break|continue)$/i.test(lead))
872
+ if (!lead || /^[$@(){}\]\[|&;<>#'"`-]/.test(lead) || SHELL_CONTROL_WORDS.test(lead))
727
873
  continue;
728
874
  const name = lead.replace(/^["']|["']$/g, '').split(/[\\/]/).pop().replace(/\.(exe|cmd|bat|ps1|sh)$/i, '').toLowerCase();
729
875
  if (name)
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.35.0"
2
+ "version": "1.35.2"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.35.0",
3
+ "version": "1.35.2",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {