fullcourtdefense-cli 1.35.1 → 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.
@@ -6925,6 +6925,13 @@ function plausibleTokenBody(body) {
6925
6925
  }
6926
6926
  var PLACEHOLDER_WORD_RE = /example|sample|placeholder|redacted|dummy|changeme|fake|xxxx/i;
6927
6927
  var REPEATED_CHUNK_RE = /(.{3,})\1{2,}/;
6928
+ var PUBLISHED_EXAMPLE_VALUES = /* @__PURE__ */ new Set([
6929
+ "JBSWY3DPEHPK3PXP",
6930
+ "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
6931
+ "12345678901234567890",
6932
+ ["AKIA", "IOSFODNN7", "EXAMPLE"].join(""),
6933
+ ["wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "EXAMPLEKEY"].join("")
6934
+ ]);
6928
6935
  function isHighEntropySecretToken(token, allowWordSlug = false) {
6929
6936
  if (token.length < 24 || token.length > 512) return false;
6930
6937
  const assignment = /^-{0,2}[A-Za-z_][\w.-]*=(.+)$/.exec(token);
@@ -6933,7 +6940,7 @@ function isHighEntropySecretToken(token, allowWordSlug = false) {
6933
6940
  if (!/^[A-Za-z0-9._~+/=-]+$/.test(token)) return false;
6934
6941
  if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(token)) return false;
6935
6942
  if (/^[0-9a-f]+$/i.test(token)) return false;
6936
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token)) return false;
6943
+ if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token) && (token.length === 36 || /[^0-9a-f]/i.test(token[token.length - 37] || ""))) return false;
6937
6944
  if (/^sha(?:1|256|384|512)-/i.test(token)) return false;
6938
6945
  if (/^data:/i.test(token) || /^[A-Za-z0-9+/]{200,}={0,2}$/.test(token)) return false;
6939
6946
  if (/^(?:https?|s3|gs|file):\/\//i.test(token) || /[\\/]{1}[\w.-]+[\\/]/.test(token)) return false;
@@ -6959,6 +6966,7 @@ function looksLikeSecretValue(raw) {
6959
6966
  if (value.length < 8) return false;
6960
6967
  if (/^[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]/.test(value)) return false;
6961
6968
  if (/^--?[A-Za-z]/.test(value)) return false;
6969
+ if (PUBLISHED_EXAMPLE_VALUES.has(value)) return false;
6962
6970
  if (SECRET_PLACEHOLDER_RE.test(value)) return false;
6963
6971
  if (SECRET_CODE_EXPR_RE.test(value)) return false;
6964
6972
  if (/^\/(?:[\[(^\\.]|.*\/[gimsuy]*$)/.test(value) || /[\[({]/.test(value)) return false;
@@ -8093,6 +8101,16 @@ function provenPowerShellReadSequence(command) {
8093
8101
  }
8094
8102
  return requests > 0;
8095
8103
  }
8104
+ function isLoopbackHost(hostname) {
8105
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
8106
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
8107
+ }
8108
+ var RAW_NETWORK_PROGRAM_RE = /(?:^|[\s;&|(`])(?:nc|ncat|netcat|socat|ssh|scp|sftp|rsync|telnet|ftp|tftp|mail|mailx|sendmail|msmtp|openssl\s+s_client)\b/i;
8109
+ function commandStaysOnLoopback(command) {
8110
+ if (!command || RAW_NETWORK_PROGRAM_RE.test(command)) return false;
8111
+ const hosts = [...command.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/(?:[^/\s@'"]*@)?([^/\s:'"?#]+)/gi)].map((match) => match[1]);
8112
+ return hosts.length > 0 && hosts.every(isLoopbackHost);
8113
+ }
8096
8114
  function isInternalHost(hostname) {
8097
8115
  const host = hostname.toLowerCase();
8098
8116
  if (!host || host === "localhost" || host === "::1" || host.endsWith(".local") || host.endsWith(".internal")) return true;
@@ -8133,6 +8151,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8133
8151
  const matchText = buildOperationMatchText(toolName, context);
8134
8152
  const toolCaps = toolCapabilities(toolName || context.toolName || "");
8135
8153
  const localFileContentAction = isLocalFileContentAction(toolName, operation, context);
8154
+ const loopbackContentAction = context["destination.loopback"] === "true";
8155
+ const ruleNamesDestination = (rule) => (rule.constraints || []).some((c) => c.field.startsWith("destination."));
8156
+ const contentCeilingApplies = (rule) => ruleNeedsContentFact(rule) && (localFileContentAction || loopbackContentAction && !ruleNamesDestination(rule));
8136
8157
  const isBareInvocation = !!operation && operation.toLowerCase() === (toolName || "").toLowerCase();
8137
8158
  const isCodeExecTool = toolCaps.has("code_execution");
8138
8159
  const candidateOperations = isBareInvocation && declaredOperations.length > 0 && !isCodeExecTool ? [operation, ...declaredOperations] : [operation];
@@ -8187,7 +8208,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8187
8208
  const constraintDesc = rule.constraints?.map((c) => `${c.field} ${c.operator} ${c.value}`).join(", ");
8188
8209
  const matchedRule = `${rule.operations.join("/")} \u2192 ${rule.verdict}${constraintDesc ? ` (${constraintDesc})` : ""}`;
8189
8210
  if (isMonitorStage) {
8190
- const wouldOnlyWarn = rule.verdict === "block" && policy.enforcement !== "hard" && ruleNeedsContentFact(rule) && localFileContentAction;
8211
+ const wouldOnlyWarn = rule.verdict === "block" && policy.enforcement !== "hard" && contentCeilingApplies(rule);
8191
8212
  if ((rule.verdict === "block" || rule.verdict === "require_approval") && !wouldOnlyWarn) {
8192
8213
  const match = { policyId: policy.id, policyName: policy.name, verdict: rule.verdict, matchedRule };
8193
8214
  monitorMatches.push(match);
@@ -8205,13 +8226,13 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8205
8226
  reason: `Action policy "${policy.name}": ${rule.operations.join("/")} ${rule.verdict === "block" ? "blocked" : rule.verdict === "require_approval" ? "requires approval" : rule.verdict}`,
8206
8227
  ...rule.verdict === "require_approval" ? { approval: resolveApprovalOptions(rule.approval) } : {}
8207
8228
  };
8208
- if (rule.verdict === "block" && policy.enforcement !== "hard" && ruleNeedsContentFact(rule) && localFileContentAction) {
8229
+ if (rule.verdict === "block" && policy.enforcement !== "hard" && contentCeilingApplies(rule)) {
8209
8230
  matched = {
8210
8231
  ...matched,
8211
8232
  allowed: true,
8212
8233
  verdict: "log",
8213
8234
  loggedInsteadOfBlocked: true,
8214
- reason: `${matched.reason} \u2014 recorded as a warning: this only touches a file on this machine and nothing leaves it (the same content leaving the machine still blocks; mark the policy Hard block to stop file edits too)`
8235
+ reason: localFileContentAction ? `${matched.reason} \u2014 recorded as a warning: this only touches a file on this machine and nothing leaves it (the same content leaving the machine still blocks; mark the policy Hard block to stop file edits too)` : `${matched.reason} \u2014 recorded as a warning: the destination is this machine (${context["destination.domain"]}) and nothing leaves it (the same content sent to an outside host still blocks; add a destination.type constraint to the rule to enforce on loopback too)`
8215
8236
  };
8216
8237
  }
8217
8238
  if (matched.verdict === "require_approval") {
@@ -8388,6 +8409,10 @@ function inferBundledToolContext(toolName, args) {
8388
8409
  context.amount = String(args.amount ?? args.price ?? args.total);
8389
8410
  }
8390
8411
  context["action.kind"] = isCodeExecTool || toolNameLower === "shell" ? "shell" : context.query !== void 0 && operation !== toolName && /^(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)$/.test(operation) ? "database" : ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(methodArg) ? "http" : context.path !== void 0 && (operation === "read" || operation === "write" || LOCAL_FILE_ACTION_RE.test(toolName) || FILE_REMOVAL_TOOL_RE.test(toolName)) ? "file" : "tool";
8412
+ delete context["destination.loopback"];
8413
+ if (context["destination.domain"] && isLoopbackHost(context["destination.domain"]) && (!isCodeExecTool || commandStaysOnLoopback(commandText))) {
8414
+ context["destination.loopback"] = "true";
8415
+ }
8391
8416
  delete context["destination.repository"];
8392
8417
  if (isCodeExecTool) {
8393
8418
  const repository = explicitGitHubWriteRepository(String(args.command || args.cmd || args.code || args.script || args.input || ""));
@@ -170,7 +170,10 @@ function buildBlockExplanation(input) {
170
170
  // "Blocked ..." reason must not turn a pending approval into a final denial.
171
171
  const reason = src.source === 'builtin' && (input.outcome === 'ask' || input.outcome === 'approval')
172
172
  ? src.reason.replace(/^Blocked\b/, 'Matched') : src.reason;
173
- why = src.explanation ? `${reason} — ${src.explanation}` : reason;
173
+ // Catalog findings often carry the same words as reason and explanation
174
+ // ("…: gcloud access-token print." / "gcloud access-token print"); say it once.
175
+ const explanation = src.explanation?.trim().replace(/\.$/, '');
176
+ why = explanation && !reason.toLowerCase().includes(explanation.toLowerCase()) ? `${reason} — ${src.explanation}` : reason;
174
177
  unblock = [
175
178
  `Open the rule in the console: ${consoleLink}`,
176
179
  `An admin can set "${label}" to Ask the developer, Warn only, or Off — for everyone or only for ${input.developerName || 'this machine'}.`,
@@ -610,19 +610,53 @@ function containsSensitiveCredentialPath(value) {
610
610
  // is not a file read. Traversal and the file must sit in the SAME path token:
611
611
  // `../.github/x.yml` on one argument and `.env` on another are two unrelated
612
612
  // arguments, not a reach outside the workspace (recorded IDE false positive).
613
- const envToken = text.split(/[\s'"]+/).find(token => /(?:^|\/)\.env(?:\.[a-z0-9_-]+)?(?:$|[?#])/.test(token));
614
- if (envToken) {
613
+ // `=` ends a token too: `--env-file=.env` and `DOTENV_CONFIG_PATH=.env` name
614
+ // the file as much as a bare operand does.
615
+ for (const token of text.split(/[\s'"=]+/)) {
616
+ const fileClass = envFileClass(token);
617
+ if (fileClass === 'template')
618
+ continue;
619
+ if (fileClass !== 'secret-store')
620
+ continue;
615
621
  // Traversal to a .env outside the workspace stays a hard block
616
622
  // (env_traversal); a plain workspace .env is a separate warn-by-default
617
623
  // item (env_workspace) — agents legitimately read it during development,
618
624
  // but the fleet should still see every touch.
619
- const reachesOutside = /(?:^|\/)\.\.(?:\/|$)/.test(envToken) || /%2e/i.test(envToken);
625
+ const reachesOutside = /(?:^|\/)\.\.(?:\/|$)/.test(token) || /%2e/i.test(token);
620
626
  return reachesOutside
621
627
  ? { itemId: 'env_traversal', label: 'environment file via path traversal' }
622
628
  : { itemId: 'env_workspace', label: 'workspace environment file' };
623
629
  }
624
630
  return undefined;
625
631
  }
632
+ /**
633
+ * file.class of an environment file, decided by the filename's structure — not
634
+ * by the text around it.
635
+ *
636
+ * secret-store `.env`, `.env.local`, `.env.production`, `HEAD:.env`
637
+ * template `.env.example`, `.env.sample`, `.env.template`, `.env.dist`,
638
+ * `.env.defaults`, `.env.schema` — committed on purpose, keys
639
+ * with empty or placeholder values, the file a README says to
640
+ * copy. Reading, editing or staging a template is not a
641
+ * credential access, whatever program does it.
642
+ *
643
+ * A git object path (`HEAD:.env`, `origin/main:.env`, `:.env` for the index)
644
+ * names the same file inside a commit — `git show HEAD:.env` prints it — so the
645
+ * revision prefix is stripped before the filename is classified. A Windows
646
+ * drive letter (`c:/repo/.env`) survives the same strip as a path.
647
+ */
648
+ const ENV_TEMPLATE_SUFFIXES = new Set(['example', 'examples', 'sample', 'template', 'tpl', 'dist', 'default', 'defaults', 'schema', 'skeleton', 'skel']);
649
+ const ENV_FILE_RE = /(?:^|\/)\.env((?:\.[a-z0-9_-]+)*)(?:$|[?#])/;
650
+ function envFileClass(token) {
651
+ if (!token.includes('.env'))
652
+ return undefined;
653
+ const filePath = token.replace(/^[^\s:]*:(?=[./~\w])/, '');
654
+ const match = ENV_FILE_RE.exec(filePath) || ENV_FILE_RE.exec(token);
655
+ if (!match)
656
+ return undefined;
657
+ const suffixes = match[1].split('.').filter(Boolean);
658
+ return suffixes.some(suffix => ENV_TEMPLATE_SUFFIXES.has(suffix)) ? 'template' : 'secret-store';
659
+ }
626
660
  /**
627
661
  * Commands that PRINT stored credentials to stdout — the file-path rules are
628
662
  * useless against them because the secret never comes from a watched file
@@ -1027,8 +1061,10 @@ function credentialAccessCommandText(text) {
1027
1061
  // PowerShell 7 permits a pipeline to continue on a line starting with `|`,
1028
1062
  // even across comment lines. A trailing comma also continues an argument list.
1029
1063
  // Keep these forms intact so a later consumer cannot lose its source path.
1064
+ // A lone `&` backgrounds or invokes (`& script.ps1`) and disqualifies the
1065
+ // text; `2>&1` / `>&2` merely duplicate a file descriptor and do not.
1030
1066
  const allowMetadata = !(/(?:^|[\r\n])[ \t]*\||,[ \t]*(?:#[^\r\n]*)?[\r\n]/.test(text)
1031
- || /`|(?:^|[^&])&(?:[^&]|$)|@['"]|(?:^|[;&|\r\n])\s*\.\s|\b(?:iex|invoke-expression|invoke-command|eval|function|filter|set-alias|new-alias|sal|nal|set-item|new-item|update-typedata|add-type|import-module|ipmo|PSDefaultParameterValues)\b/i.test(text));
1067
+ || /`|(?:^|[^&>])&(?:[^&\d]|$)|@['"]|(?:^|[;&|\r\n])\s*\.\s|\b(?:iex|invoke-expression|invoke-command|eval|function|filter|set-alias|new-alias|sal|nal|set-item|new-item|update-typedata|add-type|import-module|ipmo|PSDefaultParameterValues)\b/i.test(text));
1032
1068
  const statements = [];
1033
1069
  let current = '', quote = '', depth = 0;
1034
1070
  for (let i = 0; i < text.length; i++) {
@@ -1068,7 +1104,86 @@ function credentialAccessCommandText(text) {
1068
1104
  return text;
1069
1105
  statements.push(current);
1070
1106
  // A real shell separator is a boundary of the preceding path, even without spaces.
1071
- return statements.map(statement => allowMetadata && isFileMetadataQuery(statement) ? 'fcd_file_metadata' : statement).join('\n');
1107
+ return statements.map(statement => allowMetadata && (isFileMetadataQuery(statement) || isMetadataOnlyStatement(statement)) ? 'fcd_file_metadata' : statement).join('\n');
1108
+ }
1109
+ /**
1110
+ * access.role of a statement, from its LEADING PROGRAM: what does this program
1111
+ * do with the paths it is given? The programs below answer questions ABOUT a
1112
+ * file — tracked? ignored? present? how big? changed when? — and never print or
1113
+ * move its content. A sensitive path handed to them is the subject of the
1114
+ * question, not an access: `git status .env`, `git check-ignore -v .env`,
1115
+ * `ls -la .env`, `test -f .env`. Anything not listed keeps the conservative
1116
+ * default (the statement may read), so coverage grows without ever losing recall.
1117
+ *
1118
+ * Structural guards: no substitution, grouping or stdin redirection in the
1119
+ * statement (`$(…)`, backticks, parentheses, braces, `<`); a pipe may only feed
1120
+ * a pure text FILTER (`grep`, `wc`, `sort`, `Select-String`…) — metadata in,
1121
+ * metadata out — never a program that turns names into content or runs them
1122
+ * (`xargs cat`, `sh`, `ForEach-Object`). `git log -p` prints patches and
1123
+ * `find -exec` runs a program on the match, so those flags return the
1124
+ * statement to the default.
1125
+ */
1126
+ // Programs that print a listing. `Get-Item` / `Get-ChildItem` emit FileInfo
1127
+ // OBJECTS and stay with isFileMetadataQuery above, which knows which object
1128
+ // properties are metadata. `ls` / `dir` alias them in PowerShell, but every way
1129
+ // an object becomes content again — an output variable, a script block, a
1130
+ // non-filter consumer — is rejected structurally before this table is consulted.
1131
+ const METADATA_ONLY_PROGRAMS = new Set([
1132
+ 'ls', 'dir', 'stat', 'test', '[', 'file', 'du', 'realpath', 'readlink', 'basename', 'dirname', 'test-path',
1133
+ ]);
1134
+ const GIT_METADATA_SUBCOMMANDS = new Set(['status', 'check-ignore', 'check-attr', 'ls-files', 'ls-tree', 'log', 'shortlog', 'rev-parse', 'rev-list', 'branch', 'remote', 'tag', 'describe']);
1135
+ // Downstream of a metadata program, only these keep the pipe metadata-only.
1136
+ const TEXT_FILTER_PROGRAMS = new Set([
1137
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'wc', 'sort', 'uniq', 'head', 'tail', 'cut', 'tr', 'column', 'nl',
1138
+ 'select-string', 'sls', 'measure-object', 'measure', 'sort-object',
1139
+ 'format-table', 'ft', 'format-list', 'fl', 'format-wide', 'fw', 'out-string', 'out-null', 'out-host', 'more',
1140
+ ]);
1141
+ // An output/pipeline variable keeps the result for a later statement to open.
1142
+ const CAPTURING_PARAMETER_RE = /^-(?:outvariable|ov|pipelinevariable|pv|outbuffer|ob)$/i;
1143
+ function leadingProgram(tokens) {
1144
+ let i = 0;
1145
+ while (i < tokens.length && (/^[A-Za-z_]\w*=/.test(tokens[i]) || /^(?:sudo|command)$/i.test(tokens[i])))
1146
+ i++;
1147
+ return { program: (tokens[i] || '').toLowerCase().replace(/^.*\//, '').replace(/\.exe$/, ''), args: tokens.slice(i + 1) };
1148
+ }
1149
+ function isMetadataOnlyStatement(statement) {
1150
+ if (/[`(){}<]|\$\(/.test(statement))
1151
+ return false;
1152
+ const tokens = statement.trim().match(/"[^"\r\n]*"|'(?:[^'\r\n]|'')*'|[^\s"']+/g) || [];
1153
+ if (tokens.some(token => CAPTURING_PARAMETER_RE.test(token)))
1154
+ return false;
1155
+ const segments = [[]];
1156
+ for (const token of tokens) {
1157
+ if (token === '|')
1158
+ segments.push([]);
1159
+ else
1160
+ segments[segments.length - 1].push(token);
1161
+ }
1162
+ for (const segment of segments.slice(1)) {
1163
+ if (!TEXT_FILTER_PROGRAMS.has(leadingProgram(segment).program))
1164
+ return false;
1165
+ }
1166
+ const { program, args } = leadingProgram(segments[0]);
1167
+ if (!program)
1168
+ return false;
1169
+ if (program === 'git') {
1170
+ let j = 0;
1171
+ while (j < args.length && args[j].startsWith('-')) {
1172
+ if (/^-[cC]$/.test(args[j]))
1173
+ j++;
1174
+ j++;
1175
+ }
1176
+ const subcommand = (args[j] || '').toLowerCase();
1177
+ if (!GIT_METADATA_SUBCOMMANDS.has(subcommand))
1178
+ return false;
1179
+ const rest = args.slice(j + 1);
1180
+ if ((subcommand === 'log' || subcommand === 'rev-list') && rest.some(arg => /^(?:-p|-u|--patch|-L|--pretty|--format|-c|--cc)/.test(arg)))
1181
+ return false;
1182
+ return true;
1183
+ }
1184
+ if (program === 'find')
1185
+ return !args.some(arg => /^-(?:exec|execdir|ok|okdir|delete|fprint\w*|fls)$/i.test(arg));
1186
+ return METADATA_ONLY_PROGRAMS.has(program);
1072
1187
  }
1073
1188
  function scanTextValue(toolName, rawValue, options, commandContext = false) {
1074
1189
  // Honeypot decoys and org custom patterns scan the RAW value: a decoy path
@@ -1092,7 +1207,10 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1092
1207
  const sensitivePath = containsSensitiveCredentialPath(commandContext
1093
1208
  ? `${credentialAccessCommandText(value)}\n${interpreterFileArguments(rawValue)}` : value);
1094
1209
  if (sensitivePath) {
1095
- const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
1210
+ const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path',
1211
+ // Verdict-neutral: the same finding is a warning on a monitor machine and
1212
+ // a block on an enforcing one — the console shows the verdict, not this text.
1213
+ `Local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
1096
1214
  if (finding)
1097
1215
  return finding;
1098
1216
  }
@@ -2111,6 +2111,12 @@ async function enforceActionPolicy(ctx) {
2111
2111
  (0, policyGateHealth_1.recordGateSuccess)();
2112
2112
  }
2113
2113
  catch (err) {
2114
+ // A verdict was already written inside the try (respondDegraded / respond →
2115
+ // io.exit). In daemon mode io.exit unwinds with HookExitSignal; catching it
2116
+ // here answered the IDE a SECOND time ("Hook error: {code:0}") and counted
2117
+ // one rejected request as two gate failures, tripping fail-closed early.
2118
+ if (err instanceof HookExitSignal)
2119
+ throw err;
2114
2120
  const described = (0, describeError_1.describeError)(err);
2115
2121
  (0, hookIo_1.dbg)({ phase: 'policy_exception', event, error: described, failClosed: ctx.failClosed });
2116
2122
  const detail = `Hook error: ${described}.`;
@@ -2281,6 +2287,8 @@ async function enforceShieldText(ctx) {
2281
2287
  respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
2282
2288
  }
2283
2289
  catch (err) {
2290
+ if (err instanceof HookExitSignal)
2291
+ throw err; // verdict already written (daemon-mode unwind)
2284
2292
  respondDegraded(ctx, `Hook error: ${(0, describeError_1.describeError)(err)}.`, 'prompt');
2285
2293
  }
2286
2294
  }
@@ -212,14 +212,16 @@ function windowsDialogScript(message, timeoutSeconds, options, title, view) {
212
212
  "$bg=[Drawing.Color]::White; $muted=[Drawing.Color]::FromArgb(100,116,139); $ink=[Drawing.Color]::FromArgb(15,23,42)",
213
213
  `$accent=[Drawing.Color]::FromArgb(${argb(theme.icon)}); $badgeBg=[Drawing.Color]::FromArgb(${argb(theme.badgeBg)}); $badgeFg=[Drawing.Color]::FromArgb(${argb(theme.badgeFg)})`,
214
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
- "$root=New-Object Windows.Forms.Panel; $root.Dock='Fill'; $root.Padding=New-Object Windows.Forms.Padding(22,16,22,16); $root.AutoScroll=$true; $root.BackColor=$bg; $f.Controls.Add($root)",
216
- "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{0}),$y); $parent.Controls.Add($l); return $l }",
217
- "$y=0; $w=640",
218
- "AddLabel $root 'FullCourtDefense' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $muted $y $w | Out-Null; $y+=28",
219
- "$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(0,$y); $icon.ForeColor=$accent; $icon.BackColor=$badgeBg; $root.Controls.Add($icon)",
220
- "$badge=AddLabel $root ([string]$v.badge) (New-Object Drawing.Font('Segoe UI',8.25,[Drawing.FontStyle]::Bold)) $badgeFg $y 560 56; $badge.BackColor=$badgeBg",
221
- "$h=AddLabel $root ([string]$v.headline) (New-Object Drawing.Font('Segoe UI',16,[Drawing.FontStyle]::Bold)) $ink ($badge.Bottom+4) 560 56",
222
- "$s=AddLabel $root ([string]$v.subtitle) (New-Object Drawing.Font('Segoe UI',9.75)) $muted ($h.Bottom+4) 560 56; $y=[Math]::Max($icon.Bottom,$s.Bottom)+16",
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",
223
225
  "AddLabel $root 'Action' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22",
224
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 }",
225
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,13 +230,17 @@ function windowsDialogScript(message, timeoutSeconds, options, title, view) {
228
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 }",
229
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",
230
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 }",
231
- "$y+=16; $b=New-Object Windows.Forms.FlowLayoutPanel; $b.FlowDirection='RightToLeft'; $b.AutoSize=$true; $b.WrapContents=$false; $b.Location=New-Object Drawing.Point(0,$y)",
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)",
232
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)`,
233
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)`,
234
238
  options.allowAlways
235
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)`
236
240
  : '',
237
- "$root.Controls.Add($b); AddLabel $root ([string]$v.footer) (New-Object Drawing.Font('Segoe UI',8.25)) $muted ($b.Bottom+12) $w | Out-Null",
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)))",
238
244
  "$f.CancelButton=$deny; $f.AcceptButton=$deny; $f.ActiveControl=$deny",
239
245
  `$timer=New-Object Windows.Forms.Timer; $timer.Interval=${timeoutSeconds * 1000}; $timer.Add_Tick({ $f.DialogResult='Abort'; $f.Close() })`,
240
246
  "$f.Add_Load({ $f.WindowState='Minimized'; $f.WindowState='Normal' })",
@@ -765,6 +771,10 @@ function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PEN
765
771
  // ---------------------------------------------------------------------------
766
772
  /** Shell words that run the program that follows them, not a program of their own. */
767
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;
768
778
  /** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
769
779
  function splitShellSegments(command) {
770
780
  const segments = [];
@@ -808,11 +818,20 @@ function splitShellSegments(command) {
808
818
  function shellPrograms(command) {
809
819
  const out = new Set();
810
820
  for (const raw of splitShellSegments(command)) {
811
- const words = raw.trim().replace(/^[\s({]+/, '').split(/\s+/).filter(Boolean);
821
+ const words = raw.trim().split(/\s+/).filter(Boolean);
812
822
  let i = 0;
813
- // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …`
823
+ // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …` / `$t = (gcloud …)`
814
824
  while (i < words.length) {
815
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
+ }
816
835
  if (/^\$[\w:.]+$/.test(w) && words[i + 1] && /^[+-]?=$/.test(words[i + 1])) {
817
836
  i += 2;
818
837
  continue;
@@ -825,10 +844,32 @@ function shellPrograms(command) {
825
844
  i++;
826
845
  continue;
827
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
+ }
828
869
  break;
829
870
  }
830
871
  const lead = words[i];
831
- 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))
832
873
  continue;
833
874
  const name = lead.replace(/^["']|["']$/g, '').split(/[\\/]/).pop().replace(/\.(exe|cmd|bat|ps1|sh)$/i, '').toLowerCase();
834
875
  if (name)
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.35.1"
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.1",
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": {