fullcourtdefense-cli 1.34.26 → 1.35.1

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.
@@ -6814,11 +6814,13 @@ function shellVerbWords(command) {
6814
6814
  let lead = "";
6815
6815
  let first = true;
6816
6816
  let scriptArgs = false;
6817
+ let previousBare = "";
6817
6818
  return shellCommandWords(command).split(/\s+/).filter(Boolean).map((token) => {
6818
6819
  if (token === ";") {
6819
6820
  lead = "";
6820
6821
  first = true;
6821
6822
  scriptArgs = false;
6823
+ previousBare = "";
6822
6824
  return token;
6823
6825
  }
6824
6826
  if (scriptArgs) return "";
@@ -6838,12 +6840,19 @@ function shellVerbWords(command) {
6838
6840
  }
6839
6841
  const dot = word.startsWith(".") ? "." : "";
6840
6842
  if (dot && !DOT_COMMAND_PROGRAMS.has(lead)) return "";
6843
+ if (lead === "gh" && GH_CONFIG_RESOURCES.has(previousBare) && /^(?:delete|remove)$/i.test(word)) {
6844
+ const glued = `${previousBare}${word.toLowerCase()}`;
6845
+ previousBare = "";
6846
+ return glued;
6847
+ }
6848
+ if (!flag) previousBare = word.toLowerCase();
6841
6849
  const [head, ...rest] = word.replace(/^\./, "").split(/[-_]+/).filter(Boolean);
6842
6850
  if (!head) return "";
6843
6851
  return `${flag ? flag[1] : dot}${head}${rest.length ? ` ${rest.join("")}` : ""}`;
6844
6852
  }).filter(Boolean).join(" ");
6845
6853
  }
6846
6854
  var VCS_PROGRAMS = /* @__PURE__ */ new Set(["git", "gh", "hg", "svn", "glab"]);
6855
+ var GH_CONFIG_RESOURCES = /* @__PURE__ */ new Set(["secret", "variable", "cache", "alias", "label", "ssh-key", "gpg-key"]);
6847
6856
  var SCRIPT_RUNNER_LEADS = /* @__PURE__ */ new Set([
6848
6857
  "bash",
6849
6858
  "sh",
@@ -7153,7 +7162,7 @@ function extractUrl(args, _argsText) {
7153
7162
  const raw = args[field];
7154
7163
  if (typeof raw !== "string") continue;
7155
7164
  const shellField = ["command", "cmd", "script", "input"].includes(field);
7156
- const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).join("\n") : raw;
7165
+ const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).map(withoutGhMetadataProse).join("\n") : raw;
7157
7166
  const firstUrl = (text) => text.match(/https?:\/\/[^\s"'<>`]+/i)?.[0] || text.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>`]+/i)?.[0];
7158
7167
  const outboundUrls = shellField ? splitShellSegments(value).filter((segment) => /^(?:curl(?:\.exe)?|wget(?:\.exe)?|invoke-restmethod|invoke-webrequest|irm|iwr)$/i.test(shellSegmentLead(segment)) && classifyShellSegment(segment) === "write").map(firstUrl).filter((url) => Boolean(url)) : [];
7159
7168
  const externalOutbound = outboundUrls.find((url) => {
@@ -7322,7 +7331,7 @@ function segmentMayReachNetwork(segment) {
7322
7331
  return !LOCAL_DATA_LEADS.has(lead);
7323
7332
  });
7324
7333
  }
7325
- var LITERAL_ASSIGNMENT_RE = /^\s*(?:\$|(?=[A-Za-z_]))([A-Za-z_]\w*)\s*=\s*(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|'(?:[^']|'')*')\s*$/;
7334
+ var LITERAL_ASSIGNMENT_RE = /^\s*(?:\$|(?=[A-Za-z_]))([A-Za-z_]\w*)\s*=\s*(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|'(?:[^']|'')*'|-?\d+(?:\.\d+)?|@\((?:[\s,]|'(?:[^']|'')*'|"[^"$`]*"|@'\r?\n[\s\S]*?\r?\n'@|-?\d+(?:\.\d+)?)*\))\s*$/;
7326
7335
  function segmentsThatMayReachNetwork(segments) {
7327
7336
  const tempRedefined = segments.some((segment) => /\$env:(?:TEMP|TMP)\s*=/i.test(maskQuotedSpans(segment)));
7328
7337
  const remotePath = /(?:^|[\s"'=(])[\\/]{2}[^\s\\/"']+[\\/]/;
@@ -7346,22 +7355,46 @@ function temporaryFileSyntax(text) {
7346
7355
  return text.replace(/"\$env:(?:TEMP|TMP)([\\/][A-Za-z0-9_ ./\\-]+)"/gi, '"fcd-temporary$1"').replace(/(?<=^|\s)\$env:(?:TEMP|TMP)([\\/][A-Za-z0-9_./\\-]+)(?=\s|$)/gi, '"fcd-temporary$1"').replace(/\(Join-Path\s+\$env:(?:TEMP|TMP)\s+'([A-Za-z0-9_ ./\\-]+)'\)/gi, '"fcd-temporary/$1"');
7347
7356
  }
7348
7357
  var GH_FLAG_VALUE_RE = /^(?:'[^'$`\r\n]*'|"[^"$`\r\n]*"|[^\s'"$`|;&(){}<>]+)$/;
7358
+ var GH_PROSE_VALUE_RE = /^(?:'[^'$`]*'|"[^"$`]*"|[^\s'"$`|;&(){}<>]+)$/;
7349
7359
  function ghMetadataWriteBodyFile(segment) {
7350
- const ghBody = /^gh\s+(pr\s+create|issue\s+create|pr\s+(?:comment|edit)\s+\d+|issue\s+(?:comment|edit)\s+\d+)\s+(.+?)(?:\s+2>&1)?(?:\s*\|\s*Select-Object\s+-Last\s+\d{1,3})?$/i.exec(segment);
7360
+ return parseGhMetadataWrite(segment)?.bodyFile;
7361
+ }
7362
+ function parseGhMetadataWrite(segment) {
7363
+ const ghBody = /^(gh\s+(?:pr\s+create|issue\s+create|pr\s+(?:comment|edit)\s+\d+|issue\s+(?:comment|edit)\s+\d+)\s+)([\s\S]+?)((?:\s+2>&1)?(?:\s*\|\s*Select-Object\s+-Last\s+\d{1,3})?)$/i.exec(segment);
7351
7364
  if (!ghBody) return void 0;
7352
- const tokens = ghBody[2].match(/'[^'\r\n]*'|"[^"\r\n]*"|[^\s'"]+/g) || [];
7365
+ const tokens = ghBody[2].match(/'[^']*'|"[^"]*"|[^\s'"]+/g) || [];
7353
7366
  if (tokens.join("").replace(/\s/g, "") !== ghBody[2].replace(/\s/g, "")) return void 0;
7354
7367
  let bodyFile;
7368
+ let bodySeen = false;
7369
+ const kept = [];
7355
7370
  for (let i2 = 0; i2 < tokens.length; i2 += 2) {
7356
7371
  const flag = tokens[i2];
7357
7372
  const value = tokens[i2 + 1];
7358
- if (!/^--(?:base|head|title|label|assignee|milestone|reviewer|body-file)$/i.test(flag) || !value || !GH_FLAG_VALUE_RE.test(value)) return void 0;
7373
+ if (!/^--(?:base|head|title|label|assignee|milestone|reviewer|body-file|body)$/i.test(flag) || !value) return void 0;
7374
+ const literal2 = (/^--(?:body|title)$/i.test(flag) ? GH_PROSE_VALUE_RE : GH_FLAG_VALUE_RE).test(value);
7375
+ const selectorVariable = !/^--(?:body|title|body-file)$/i.test(flag) && /^\$[A-Za-z_]\w*$/.test(value);
7376
+ if (!literal2 && !selectorVariable) return void 0;
7359
7377
  if (/^--body-file$/i.test(flag)) {
7360
- if (bodyFile !== void 0) return void 0;
7378
+ if (bodySeen) return void 0;
7379
+ bodySeen = true;
7361
7380
  bodyFile = value;
7362
7381
  }
7382
+ if (/^--body$/i.test(flag)) {
7383
+ if (bodySeen) return void 0;
7384
+ bodySeen = true;
7385
+ }
7386
+ const prose = /^--(?:body|title)$/i.test(flag);
7387
+ kept.push(flag, prose ? '"fcd-gh-metadata"' : value);
7363
7388
  }
7364
- return bodyFile;
7389
+ return { bodyFile, masked: `${ghBody[1]}${kept.join(" ")}${ghBody[3]}` };
7390
+ }
7391
+ function withoutGhMetadataProse(segment) {
7392
+ const assigned = /^(\s*\$[A-Za-z_]\w*\s*=\s*)([\s\S]+)$/.exec(segment);
7393
+ if (assigned) {
7394
+ const masked = parseGhMetadataWrite(assigned[2])?.masked;
7395
+ return masked === void 0 ? segment : `${assigned[1]}${masked}`;
7396
+ }
7397
+ return parseGhMetadataWrite(segment)?.masked ?? segment;
7365
7398
  }
7366
7399
  function shellUrlActionText(command) {
7367
7400
  if (command.length > 65536) return command;
@@ -7507,6 +7540,22 @@ function splitShellSegments(command, alsoOnPipe = false) {
7507
7540
  continue;
7508
7541
  }
7509
7542
  }
7543
+ if (ch === "@" && command[i2 + 1] === "{") {
7544
+ const end = hashtableEnd(command, i2);
7545
+ if (end > i2) {
7546
+ cur += command.slice(i2, end);
7547
+ i2 = end - 1;
7548
+ continue;
7549
+ }
7550
+ }
7551
+ if (ch === "@" && command[i2 + 1] === "(") {
7552
+ const end = literalArrayEnd(command, i2);
7553
+ if (end > i2) {
7554
+ cur += command.slice(i2, end);
7555
+ i2 = end - 1;
7556
+ continue;
7557
+ }
7558
+ }
7510
7559
  if (ch === '"' || ch === "'") {
7511
7560
  quote = ch;
7512
7561
  cur += ch;
@@ -7547,6 +7596,47 @@ ${q}@`;
7547
7596
  if (idx < 0) return -1;
7548
7597
  return idx + close.length;
7549
7598
  }
7599
+ function hashtableEnd(command, start) {
7600
+ let depth = 0;
7601
+ let quote = null;
7602
+ for (let i2 = start + 1; i2 < command.length; i2++) {
7603
+ const ch = command[i2];
7604
+ if (quote) {
7605
+ if (ch === "\\" && quote === '"' && i2 + 1 < command.length) {
7606
+ i2++;
7607
+ continue;
7608
+ }
7609
+ if (ch === quote) quote = null;
7610
+ continue;
7611
+ }
7612
+ if (ch === '"' || ch === "'") {
7613
+ quote = ch;
7614
+ continue;
7615
+ }
7616
+ if (ch === "{") depth++;
7617
+ else if (ch === "}" && --depth === 0) return i2 + 1;
7618
+ }
7619
+ return -1;
7620
+ }
7621
+ var LITERAL_ELEMENT_RE = /^(?:'(?:[^']|'')*'|"[^"$`]*"|@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|-?\d+(?:\.\d+)?)/;
7622
+ function literalArrayEnd(command, start) {
7623
+ let i2 = start + 2;
7624
+ let elements = 0;
7625
+ while (i2 < command.length) {
7626
+ const rest = command.slice(i2);
7627
+ const skip = /^[\s,]+/.exec(rest);
7628
+ if (skip) {
7629
+ i2 += skip[0].length;
7630
+ continue;
7631
+ }
7632
+ if (command[i2] === ")") return elements > 0 ? i2 + 1 : -1;
7633
+ const element = LITERAL_ELEMENT_RE.exec(rest);
7634
+ if (!element || elements >= 256) return -1;
7635
+ i2 += element[0].length;
7636
+ elements++;
7637
+ }
7638
+ return -1;
7639
+ }
7550
7640
  var QUOTED_LITERAL_RE = /@'[\s\S]*?\r?\n'@|@"[\s\S]*?\r?\n"@|"((?:[^"\\]|\\.)*)"|'([^']*)'/g;
7551
7641
  function quotedSpans(segment) {
7552
7642
  const spans = [];
@@ -7729,6 +7819,16 @@ function shellSegmentLead(segment) {
7729
7819
  }
7730
7820
  var PS_SESSION_VARIABLE_ASSIGN_RE = /^\s*\$(?:psdefaultparametervalues|psmodulepath|profile|executioncontext|env:(?:path|pathext|psmodulepath))\s*(?:\[[^\]]*\]\s*)?(?:\+?=)/i;
7731
7821
  var PS_VALUE_EXECUTOR_RE = /\b(?:invoke-expression|iex|invoke-command|icm|start-process|saps|invoke-item|ii)\b|&\s*[$(]|\.\s*invoke\s*\(/i;
7822
+ function isInertValueExpression(raw) {
7823
+ const masked = maskQuotedSpans(raw);
7824
+ if (/\$\(|`|&\s*[$(\w"']|[|<>]/.test(masked) || PS_VALUE_EXECUTOR_RE.test(masked)) return false;
7825
+ if (/(?:::|\.)\s*(?:invoke|start|run|exec|create|load|download|upload|send|open)\w*\s*\(/i.test(masked)) return false;
7826
+ for (const group of masked.matchAll(/\(\s*([A-Za-z][\w-]*)/g)) if (!LOCAL_DATA_LEADS.has(group[1].toLowerCase())) return false;
7827
+ const body = masked.replace(/^\s*\$[\w]+\s*(?:\+|-|\*|\/)?=\s*/, "").trim();
7828
+ if (/^\$\w+\s*(?:\+\+|--)$/.test(body)) return true;
7829
+ if (/^(?:@\{|@\()/.test(body)) return !/[$(]/.test(body.slice(2)) && /^@[{(](?:[\s;,=]|""|-?\d+(?:\.\d+)?|[\w-]+|@\{|\}|\))*$/.test(body);
7830
+ return /^(?:""|-?\d)/.test(body);
7831
+ }
7732
7832
  var worstShellOp = (ops, floor = "read") => ops.reduce((w, op) => (SHELL_OP_RANK[op] || 0) > (SHELL_OP_RANK[w] || 0) ? op : w, floor);
7733
7833
  function classifyShellSegment(segment, depth = 0) {
7734
7834
  const raw = segment.trim();
@@ -7737,6 +7837,7 @@ function classifyShellSegment(segment, depth = 0) {
7737
7837
  const lead = shellSegmentLead(segment);
7738
7838
  if ((/^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead)) && PS_VALUE_EXECUTOR_RE.test(maskQuotedSpans(raw))) return "SHELL";
7739
7839
  if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead)) return "read";
7840
+ if (isInertValueExpression(raw)) return "read";
7740
7841
  const rawLower = raw.toLowerCase();
7741
7842
  const sqlClient = SHELL_SQL_CLIENTS.has(lead) || /\|\s*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlcmd|sqlite3|mongosh?|clickhouse-client|bq|cockroach|usql)\b/.test(rawLower);
7742
7843
  if (sqlClient) {
@@ -7768,6 +7869,8 @@ function classifyShellWords(masked, raw, lead) {
7768
7869
  const explicitMethod = /(?:\s-x|\s--request|\s-method)[\s=]+["']?(post|put|patch|delete)\b/i.test(segment);
7769
7870
  const hasBody = /\s(?:-d|--data(?:-raw|-binary|-urlencode)?|-F|--form|-T|--upload-file|--json|-body|-infile)\b/i.test(segment);
7770
7871
  if (explicitMethod || hasBody) return "write";
7872
+ const credentialArgument = /\s(?:-h|--header|-headers|-u|--user|-token|-credential|-authentication|--oauth2-bearer|-proxycredential)(?:[\s=]|$)/i.exec(segment);
7873
+ if (credentialArgument && /\$\(|`|\$env:|\(\s*[A-Za-z][\w-]*\s/.test(segment.slice(credentialArgument.index))) return "write";
7771
7874
  return pipesIntoInterpreter ? "SHELL" : "read";
7772
7875
  }
7773
7876
  if (SHELL_READ_PROGRAMS.has(lead)) {
@@ -7869,7 +7972,8 @@ function provenCompoundDownload(command, detectedUrl) {
7869
7972
  return segments.every((segment) => {
7870
7973
  const statusRead = /^\(((?:Invoke-WebRequest|iwr)\s+[^\r\n]+)\)\.(?:StatusCode|StatusDescription|RawContentLength)$/i.exec(segment);
7871
7974
  if (statusRead) segment = statusRead[1];
7872
- if (/[$`{}()<>;&]/.test(segment)) return false;
7975
+ const withoutWriteOut = segment.replace(/(\s(?:-w|--write-out)\s+)(?:"[^"\r\n$`@]*"|'[^'\r\n$`@]*')/g, '$1"fcd-write-out"');
7976
+ if (/[$`{}()<>;&]/.test(withoutWriteOut)) return false;
7873
7977
  const pipeline = splitShellSegments(segment, true);
7874
7978
  if (pipeline.slice(1).some((part) => !isPowerShellReadFormatter(part))) return false;
7875
7979
  const raw = pipeline[0];
@@ -7883,7 +7987,13 @@ function provenCompoundDownload(command, detectedUrl) {
7883
7987
  let urls = 0;
7884
7988
  for (let i2 = 0; i2 < tokens.length; i2++) {
7885
7989
  const token = tokens[i2];
7886
- if (isCurl && /^(?:--max-time|--connect-timeout)$/.test(token) && /^\d+(?:\.\d+)?$/.test(tokens[i2 + 1] || "")) {
7990
+ if (isCurl && /^(?:-m|--max-time|--connect-timeout)$/.test(token) && /^\d+(?:\.\d+)?$/.test(tokens[i2 + 1] || "")) {
7991
+ i2++;
7992
+ continue;
7993
+ }
7994
+ if (isCurl && /^(?:-w|--write-out)$/.test(token)) {
7995
+ const format = tokens[i2 + 1] || "";
7996
+ if (!format || /^@/.test(format) || /[$`]/.test(format)) return false;
7887
7997
  i2++;
7888
7998
  continue;
7889
7999
  }
@@ -7924,7 +8034,7 @@ function isPowerShellResponseProjection(segment, responses) {
7924
8034
  const object = /^\[pscustomobject\]\s*@\{([^{}]+)\}\s*(?:\|\s*(.+))?$/i.exec(segment);
7925
8035
  if (!object) return false;
7926
8036
  if (object[2] && object[2].split("|").some((part) => !isPowerShellReadFormatter(part.trim()))) return false;
7927
- const fields = object[1].trim().replace(/;\s*$/, "").split(";");
8037
+ const fields = object[1].trim().replace(/[;\s]*$/, "").split(/;|\r?\n/).filter((field) => field.trim());
7928
8038
  if (fields.length > 32) return false;
7929
8039
  const labels = /* @__PURE__ */ new Set();
7930
8040
  return fields.every((field) => {
@@ -5,5 +5,32 @@ export interface ApprovalMessageOptions {
5
5
  alwaysScopeLabel?: string;
6
6
  /** How long it holds: "30 days" (default) or "7 days or 20 uses". */
7
7
  alwaysLimitsLabel?: string;
8
+ /** Visual variant. Ask is the interactive dialog; approval/block share the card, not extra buttons. */
9
+ tone?: DialogTone;
8
10
  }
11
+ /** Which policy outcome this window is showing. Icons and copy follow this; buttons do not. */
12
+ export type DialogTone = 'ask' | 'approval' | 'block';
13
+ export interface NativeDialogView {
14
+ tone: DialogTone;
15
+ badge: string;
16
+ headline: string;
17
+ subtitle: string;
18
+ kind?: string;
19
+ action?: string;
20
+ policy?: string;
21
+ scope?: string;
22
+ agent?: string;
23
+ ide?: string;
24
+ tool?: string;
25
+ why: string[];
26
+ next: string[];
27
+ footer: string;
28
+ }
29
+ /**
30
+ * Layout model for the branded OS dialog. Same facts as the compact prompt;
31
+ * skips mockup-only chrome (other-outcome chips, Request approval, project tags).
32
+ */
33
+ export declare function nativeDialogView(reason: string, options?: ApprovalMessageOptions): NativeDialogView;
34
+ /** Window title: the kind of action and the policy that stopped it, by name. */
35
+ export declare function nativeApprovalTitle(reason: string): string;
9
36
  export declare function nativeApprovalMessage(reason: string, options?: ApprovalMessageOptions): string;
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nativeDialogView = nativeDialogView;
4
+ exports.nativeApprovalTitle = nativeApprovalTitle;
3
5
  exports.nativeApprovalMessage = nativeApprovalMessage;
4
6
  /** Compact OS prompt. The complete decision remains in the IDE response and audit. */
5
7
  function line(value, limit) {
@@ -10,7 +12,19 @@ function line(value, limit) {
10
12
  const head = Math.ceil((limit - 3) * 0.6);
11
13
  return `${text.slice(0, head)} … ${text.slice(-(limit - head - 3))}`;
12
14
  }
13
- function nativeApprovalMessage(reason, options = {}) {
15
+ const CEILING_NOTE_RE = /\s*This policy is set to Block[.;]\s*(?:Because it is not marked "Hard block", your workstation requires your approval before continuing\.|on a developer workstation it asks you instead\.)?\s*(?:An admin can (?:review the matching rule and its scope in the console|mark it "Hard block" in the console to stop this without asking)\.)?/;
16
+ const CEILING_NOTE_SHORT = 'set to Block; on your workstation it asks you first.';
17
+ /** Parse the `Rule:` line the block explanation writes into the name the console lists and its scope. */
18
+ function ruleParts(rule) {
19
+ const policy = /^Action Policy "(.+?)" \((.+?)\)(?: — rule: (.+))?$/.exec(rule);
20
+ if (policy)
21
+ return { policy: policy[1], scope: [policy[2].replace(/^[^\w]+/, ''), policy[3]].filter(Boolean).join(' · ') };
22
+ const local = /^(built-in Local Safety rule|org custom rule) "(.+?)"(?: in Local Safety)?(?: \((.+)\))?$/.exec(rule);
23
+ if (local)
24
+ return { policy: local[2], localSafety: true, scope: [local[1] === 'org custom rule' ? 'Local Safety — org custom rule' : 'Local Safety — built-in rule', local[3]].filter(Boolean).join(' · ') };
25
+ return { policy: rule };
26
+ }
27
+ function promptFacts(reason) {
14
28
  const rows = reason.split(/\r?\n/).map(row => row.trim()).filter(Boolean);
15
29
  const field = (name) => rows.find(row => row.startsWith(`${name}:`))?.slice(name.length + 1).trim();
16
30
  const what = field('What');
@@ -20,20 +34,104 @@ function nativeApprovalMessage(reason, options = {}) {
20
34
  const why = field('Why');
21
35
  const agent = field('Agent')
22
36
  || (/^[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)];
37
+ if (!(what || tool || rule || why))
38
+ return { fallback: reason };
39
+ const facts = { agent, ide: field('IDE') };
40
+ if (rule)
41
+ Object.assign(facts, ruleParts(rule));
42
+ if (what) {
43
+ // summarizeAction ends with the kind in parentheses: "… (shell command)".
44
+ const kind = /^(.*)\s\(([a-z][a-z /-]{2,30})\)$/i.exec(what);
45
+ facts.kind = kind?.[2];
46
+ facts.action = kind ? kind[1] : what;
47
+ }
48
+ if (tool)
49
+ facts.tool = [tool, evidence].filter(Boolean).join(' · ');
50
+ if (why) {
51
+ const stripped = why.replace(CEILING_NOTE_RE, ' ').replace(/\s+/g, ' ').trim();
52
+ if (stripped !== why.trim())
53
+ facts.note = CEILING_NOTE_SHORT;
54
+ facts.because = stripped.replace(/([^.])$/, '$1.');
55
+ }
56
+ return facts;
57
+ }
58
+ function sentences(text) {
59
+ return text.split(/(?<=\.)\s+/).map(part => part.trim()).filter(Boolean);
60
+ }
61
+ /**
62
+ * Layout model for the branded OS dialog. Same facts as the compact prompt;
63
+ * skips mockup-only chrome (other-outcome chips, Request approval, project tags).
64
+ */
65
+ function nativeDialogView(reason, options = {}) {
66
+ const f = promptFacts(reason);
67
+ const tone = options.tone || 'ask';
68
+ const why = [
69
+ ...(f.because ? sentences(line(f.because, 400)) : []),
70
+ f.note ? line(f.note, 180) : '',
71
+ f.fallback ? line(f.fallback, 400) : '',
72
+ ].filter(Boolean);
73
+ const next = tone === 'block'
74
+ ? ['This action is stopped.', 'An admin can change the rule in the console.']
75
+ : tone === 'approval'
76
+ ? ['An admin or manager can approve it in the console.', 'Closing this window does not approve it.']
77
+ : [
78
+ 'Allow once lets only this action continue.',
79
+ 'Deny or close keeps it blocked.',
80
+ options.allowAlways && options.alwaysScopeLabel
81
+ ? `Always allow covers ${line(options.alwaysScopeLabel, 180)} for ${options.alwaysLimitsLabel || '30 days'} on this machine, under this rule as it is written today.`
82
+ : '',
83
+ ].filter(Boolean);
84
+ const footer = f.policy && !f.fallback
85
+ ? `Full details: the IDE message and this machine’s Activity. Console → Action Policies → ${f.localSafety ? 'Source: Local Safety → ' : ''}"${line(f.policy, 80)}".`
86
+ : 'Full details are in the IDE message and this machine’s Activity in FullCourtDefense.';
87
+ return {
88
+ tone,
89
+ badge: tone === 'block' ? 'Blocked' : tone === 'approval' ? 'Approval required' : 'Ask the developer',
90
+ headline: tone === 'block' ? 'Action blocked' : tone === 'approval' ? 'Action requires approval' : 'Action needs your OK',
91
+ subtitle: tone === 'block'
92
+ ? 'A security policy stopped this action. It did not run.'
93
+ : tone === 'approval'
94
+ ? 'A security policy paused this action for an admin or manager.'
95
+ : 'A security policy paused this action. Review the details and choose what to do next.',
96
+ kind: f.kind,
97
+ action: f.action ? line(f.action, 280) : undefined,
98
+ policy: f.policy ? line(f.policy, 160) : undefined,
99
+ scope: f.scope ? line(f.scope, 220) : undefined,
100
+ agent: f.agent ? line(f.agent, 80) : undefined,
101
+ ide: f.ide ? line(f.ide, 40) : undefined,
102
+ tool: f.tool ? line(f.tool, 230) : undefined,
103
+ why: why.length ? why : ['A policy matched this action.'],
104
+ next,
105
+ footer,
106
+ };
107
+ }
108
+ /** Window title: the kind of action and the policy that stopped it, by name. */
109
+ function nativeApprovalTitle(reason) {
110
+ const f = promptFacts(reason);
111
+ const parts = ['FullCourtDefense', f.kind ? `Approval needed: ${f.kind}` : 'Approval needed', f.policy ? line(f.policy, 60) : ''].filter(Boolean);
112
+ return parts.join(' · ');
113
+ }
114
+ function nativeApprovalMessage(reason, options = {}) {
115
+ const f = promptFacts(reason);
116
+ const details = f.fallback ? [line(f.fallback, 650)] : [
117
+ f.policy ? `Policy: ${line(f.policy, 160)}` : '',
118
+ f.scope ? ` ${line(f.scope, 200)}` : '',
119
+ f.action ? `Action: ${f.kind ? `${f.kind} — ` : ''}${line(f.action, 230)}` : '',
120
+ f.agent ? `Agent: ${line(f.agent, 80)}` : '',
121
+ f.ide ? `IDE: ${line(f.ide, 40)}` : '',
122
+ f.tool ? `Tool: ${line(f.tool, 230)}` : '',
123
+ f.because ? `Because: ${line(f.because, 300)}` : '',
124
+ f.note ? `Note: ${f.note}` : '',
125
+ ].filter(Boolean);
126
+ const where = f.policy && !f.fallback
127
+ ? `Console → Action Policies → ${f.localSafety ? 'Source: Local Safety → ' : ''}"${line(f.policy, 80)}" · this decision: your machine’s Activity.`
128
+ : 'Full details: the IDE message or this machine’s Activity in FullCourtDefense.';
31
129
  if (!options.allowAlways) {
32
130
  return [
33
131
  'Allow this action once?',
34
132
  '', ...details, '',
35
- 'Full details: the IDE message or this machine’s Activity in FullCourtDefense.',
36
133
  'Allow once permits only this action. Deny or closing keeps it stopped.',
134
+ where,
37
135
  ].join('\n');
38
136
  }
39
137
  return [
@@ -41,6 +139,6 @@ function nativeApprovalMessage(reason, options = {}) {
41
139
  '', ...details, '',
42
140
  ...(options.alwaysScopeLabel ? [`Always allow covers: ${line(options.alwaysScopeLabel, 200)} — for ${options.alwaysLimitsLabel || '30 days'} on this machine, under this rule as it is written today.`, ''] : []),
43
141
  '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.',
142
+ where,
45
143
  ].join('\n');
46
144
  }
@@ -53,8 +53,44 @@ export interface EphemeralStackOptions {
53
53
  hooks?: string;
54
54
  /** 'false' => skip wrapping MCP client configs with the gateway. */
55
55
  gateway?: string;
56
+ /**
57
+ * HTTP egress gateway (API destinations). OFF unless asked for: 'true' or a
58
+ * port number starts `http-gateway` detached on 127.0.0.1 and the identity
59
+ * env gains HTTP_PROXY / HTTPS_PROXY / NO_PROXY. Absent / 'false' => nothing
60
+ * changes for the workload's network path.
61
+ */
62
+ httpGateway?: string;
63
+ /** Agent name the HTTP gateway attributes calls to (the workload name). */
64
+ agentName?: string;
56
65
  sendLog?: EphemeralLogSink;
57
66
  }
67
+ export declare const DEFAULT_HTTP_GATEWAY_PORT = 18790;
68
+ /** Resolve `--http-gateway <true|false|port>`; undefined = off. */
69
+ export declare function resolveHttpGatewayPort(value: string | undefined): number | undefined;
70
+ /**
71
+ * Identity-env additions that route the agent's HTTP libraries through the
72
+ * local gateway. NO_PROXY keeps loopback and the FullCourtDefense control plane
73
+ * direct (the gateway would bypass them anyway). Both casings: curl/Python read
74
+ * lower-case, many Java/Go tools upper-case.
75
+ */
76
+ export declare function httpGatewayIdentityEnv(port: number, apiUrl: string, agentName: string | undefined, env?: NodeJS.ProcessEnv): Record<string, string>;
77
+ export declare function httpGatewayLogPath(): string;
78
+ export declare function httpGatewayReadyPath(): string;
79
+ /**
80
+ * Start `http-gateway` outside our process tree (plain node, no shells) and
81
+ * wait until it reports its port. The workload's entrypoint runs AFTER this
82
+ * command returns, so the proxy must already be listening when the agent starts.
83
+ */
84
+ export declare function startDetachedHttpGateway(input: {
85
+ port: number;
86
+ agentName?: string;
87
+ apiUrl: string;
88
+ waitMs?: number;
89
+ }): Promise<{
90
+ port: number;
91
+ pid?: number;
92
+ log: string;
93
+ }>;
58
94
  /**
59
95
  * Install the runtime hook + MCP gateway for an enrolled ephemeral identity.
60
96
  * Each step is best-effort and reported: a missing MCP client on the image is
@@ -33,16 +33,22 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.silentLogSink = void 0;
36
+ exports.DEFAULT_HTTP_GATEWAY_PORT = exports.silentLogSink = void 0;
37
37
  exports.ephemeralIdentityMarkerPath = ephemeralIdentityMarkerPath;
38
38
  exports.assertNotEnrolledLaptop = assertNotEnrolledLaptop;
39
39
  exports.writeEphemeralIdentityMarker = writeEphemeralIdentityMarker;
40
+ exports.resolveHttpGatewayPort = resolveHttpGatewayPort;
41
+ exports.httpGatewayIdentityEnv = httpGatewayIdentityEnv;
42
+ exports.httpGatewayLogPath = httpGatewayLogPath;
43
+ exports.httpGatewayReadyPath = httpGatewayReadyPath;
44
+ exports.startDetachedHttpGateway = startDetachedHttpGateway;
40
45
  exports.installEphemeralProtectionStack = installEphemeralProtectionStack;
41
46
  exports.exportIdentityEnv = exportIdentityEnv;
42
47
  const fs = __importStar(require("fs"));
43
48
  const os = __importStar(require("os"));
44
49
  const path = __importStar(require("path"));
45
50
  const config_1 = require("../config");
51
+ const child_process_1 = require("child_process");
46
52
  const installClaudeHook_1 = require("./installClaudeHook");
47
53
  const mcpGateway_1 = require("./mcpGateway");
48
54
  /**
@@ -105,6 +111,102 @@ function writeEphemeralIdentityMarker(noun, machineId, markerPath = ephemeralIde
105
111
  /** No-op sink for callers without a live log stream (workloads today). */
106
112
  const silentLogSink = async () => { };
107
113
  exports.silentLogSink = silentLogSink;
114
+ exports.DEFAULT_HTTP_GATEWAY_PORT = 18790;
115
+ /** Resolve `--http-gateway <true|false|port>`; undefined = off. */
116
+ function resolveHttpGatewayPort(value) {
117
+ const v = (value || '').trim().toLowerCase();
118
+ if (!v || v === 'false' || v === '0' || v === 'off' || v === 'no')
119
+ return undefined;
120
+ if (v === 'true' || v === 'on' || v === 'yes')
121
+ return exports.DEFAULT_HTTP_GATEWAY_PORT;
122
+ const port = Number(v);
123
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
124
+ throw new Error(`--http-gateway expects true, false or a port (1–65535), got "${value}"`);
125
+ return port;
126
+ }
127
+ /**
128
+ * Identity-env additions that route the agent's HTTP libraries through the
129
+ * local gateway. NO_PROXY keeps loopback and the FullCourtDefense control plane
130
+ * direct (the gateway would bypass them anyway). Both casings: curl/Python read
131
+ * lower-case, many Java/Go tools upper-case.
132
+ */
133
+ function httpGatewayIdentityEnv(port, apiUrl, agentName, env = process.env) {
134
+ const proxy = `http://127.0.0.1:${port}`;
135
+ let apiHost = '';
136
+ try {
137
+ apiHost = new URL(apiUrl).hostname;
138
+ }
139
+ catch { /* ignore */ }
140
+ const noProxy = ['localhost', '127.0.0.1', '::1', apiHost].filter(Boolean).join(',');
141
+ const out = {
142
+ FCD_HTTP_GATEWAY: proxy,
143
+ HTTP_PROXY: proxy,
144
+ HTTPS_PROXY: proxy,
145
+ http_proxy: proxy,
146
+ https_proxy: proxy,
147
+ NO_PROXY: noProxy,
148
+ no_proxy: noProxy,
149
+ };
150
+ if (agentName && !(env.FCD_AGENT_NAME || '').trim())
151
+ out.FCD_AGENT_NAME = agentName;
152
+ return out;
153
+ }
154
+ function httpGatewayLogPath() {
155
+ return path.join(os.homedir(), '.fullcourtdefense', 'http-gateway.log');
156
+ }
157
+ function httpGatewayReadyPath() {
158
+ return path.join(os.homedir(), '.fullcourtdefense', 'http-gateway.json');
159
+ }
160
+ /**
161
+ * Start `http-gateway` outside our process tree (plain node, no shells) and
162
+ * wait until it reports its port. The workload's entrypoint runs AFTER this
163
+ * command returns, so the proxy must already be listening when the agent starts.
164
+ */
165
+ async function startDetachedHttpGateway(input) {
166
+ const readyFile = httpGatewayReadyPath();
167
+ const logFile = httpGatewayLogPath();
168
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
169
+ try {
170
+ fs.unlinkSync(readyFile);
171
+ }
172
+ catch { /* none */ }
173
+ const out = fs.openSync(logFile, 'a');
174
+ const cliEntry = path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
175
+ // Credentials are NOT passed on the command line (visible in `ps`): the
176
+ // gateway reads the shield saved by workload-protect from ~/.fullcourtdefense.yml.
177
+ const args = [cliEntry, 'http-gateway', '--port', String(input.port), '--ready-file', readyFile,
178
+ '--api-url', input.apiUrl,
179
+ ...(input.agentName ? ['--agent-name', input.agentName] : [])];
180
+ const child = (0, child_process_1.spawn)(process.execPath, args, {
181
+ detached: true,
182
+ stdio: ['ignore', out, out],
183
+ windowsHide: true,
184
+ // The gateway must never route through itself, whatever the agent's env says,
185
+ // and a long-lived process has no business holding the org enrollment key.
186
+ env: Object.fromEntries(Object.entries({ ...process.env, FCD_EPHEMERAL: '1' })
187
+ .filter(([key]) => !/^(https?_proxy|no_proxy|FCD_API_KEY)$/i.test(key))),
188
+ });
189
+ child.unref();
190
+ fs.closeSync(out);
191
+ const deadline = Date.now() + (input.waitMs ?? 10_000);
192
+ while (Date.now() < deadline) {
193
+ try {
194
+ const ready = JSON.parse(fs.readFileSync(readyFile, 'utf8'));
195
+ if (ready.port)
196
+ return { port: ready.port, pid: ready.pid, log: logFile };
197
+ }
198
+ catch { /* not yet */ }
199
+ if (child.exitCode !== null)
200
+ break;
201
+ await new Promise(resolve => setTimeout(resolve, 200));
202
+ }
203
+ let tail = '';
204
+ try {
205
+ tail = fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean).slice(-3).join(' | ');
206
+ }
207
+ catch { /* ignore */ }
208
+ throw new Error(`http-gateway did not start on port ${input.port}${tail ? ` — ${tail}` : ''} (log: ${logFile})`);
209
+ }
108
210
  /**
109
211
  * Install the runtime hook + MCP gateway for an enrolled ephemeral identity.
110
212
  * Each step is best-effort and reported: a missing MCP client on the image is
@@ -150,6 +252,29 @@ async function installEphemeralProtectionStack(options, config) {
150
252
  await sendLog([{ level: 'warn', message: `MCP gateway wrap skipped: ${reason}` }]);
151
253
  }
152
254
  }
255
+ // HTTP egress gateway (API destinations) — opt-in. Nothing about the
256
+ // workload's network path changes unless the caller asked for it.
257
+ const httpPort = resolveHttpGatewayPort(options.httpGateway);
258
+ if (httpPort !== undefined) {
259
+ console.log(`\n\x1b[1mStarting the HTTP egress gateway (API destinations) on 127.0.0.1:${httpPort}…\x1b[0m`);
260
+ try {
261
+ const started = await startDetachedHttpGateway({
262
+ port: httpPort,
263
+ agentName: options.agentName,
264
+ apiUrl: options.apiUrl,
265
+ });
266
+ console.log(` \x1b[32m✓\x1b[0m http-gateway listening (pid ${started.pid ?? '?'}) · log: ${started.log}`);
267
+ console.log(' The identity env file now carries HTTP_PROXY / HTTPS_PROXY — source it before starting the agent.');
268
+ console.log(' HTTPS calls are gated by destination host; plain HTTP by host, method and endpoint shape.');
269
+ await sendLog([{ level: 'success', message: `HTTP egress gateway started on 127.0.0.1:${started.port}` }]);
270
+ }
271
+ catch (error) {
272
+ const reason = error instanceof Error ? error.message : String(error);
273
+ console.log(` \x1b[31mHTTP egress gateway failed to start:\x1b[0m ${reason}`);
274
+ await sendLog([{ level: 'warn', message: `HTTP egress gateway failed to start: ${reason}` }]);
275
+ throw error;
276
+ }
277
+ }
153
278
  }
154
279
  /**
155
280
  * Make the ephemeral identity visible to everything that runs AFTER this