fullcourtdefense-cli 1.34.25 → 1.34.26

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.
@@ -29,13 +29,26 @@ export interface ActionPolicyApproval {
29
29
  scope?: ActionPolicyApprovalScope;
30
30
  remember?: ActionPolicyApprovalRemember;
31
31
  onTimeout?: ActionPolicyApprovalOnTimeout;
32
+ /**
33
+ * Caps on a developer's "Always allow" under this rule: lifetime in days
34
+ * (1–30) and uses before the dialog returns (1–10000). Absent = the built-in
35
+ * 30-day lifetime, unlimited uses. The org ceiling delivered with the bundle
36
+ * can only tighten these, never widen them.
37
+ */
38
+ maxDays?: number;
39
+ maxUses?: number;
32
40
  }
33
41
  /** Fully-resolved approval options (every field present) attached to a `require_approval` result. */
34
42
  export interface ResolvedActionPolicyApproval {
35
43
  scope: ActionPolicyApprovalScope;
36
44
  remember: ActionPolicyApprovalRemember;
37
45
  onTimeout: ActionPolicyApprovalOnTimeout;
46
+ /** "Always allow" caps (see `ActionPolicyApproval`); undefined = built-in default. */
47
+ maxDays?: number;
48
+ maxUses?: number;
38
49
  }
50
+ export declare const ACTION_POLICY_GRANT_MAX_DAYS = 30;
51
+ export declare const ACTION_POLICY_GRANT_MAX_USES = 10000;
39
52
  export interface ActionPolicyRule {
40
53
  /** Explicit, narrowly scoped permission to relax named role actions only. */
41
54
  roleException?: {
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/actionPolicyEngine.ts
21
21
  var actionPolicyEngine_exports = {};
22
22
  __export(actionPolicyEngine_exports, {
23
+ ACTION_POLICY_GRANT_MAX_DAYS: () => ACTION_POLICY_GRANT_MAX_DAYS,
24
+ ACTION_POLICY_GRANT_MAX_USES: () => ACTION_POLICY_GRANT_MAX_USES,
23
25
  CONTENT_FACT_FIELDS: () => CONTENT_FACT_FIELDS,
24
26
  DEFAULT_ACTION_POLICY_APPROVAL: () => DEFAULT_ACTION_POLICY_APPROVAL,
25
27
  MACHINE_KINDS: () => MACHINE_KINDS,
@@ -6237,6 +6239,18 @@ function provenPowerShellQueryUrls(command) {
6237
6239
  return [];
6238
6240
  }
6239
6241
  }
6242
+ var ACTION_POLICY_GRANT_MAX_DAYS = 30;
6243
+ var ACTION_POLICY_GRANT_MAX_USES = 1e4;
6244
+ function approvalCap(value, limit) {
6245
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
6246
+ if (!Number.isFinite(n) || n < 1) return void 0;
6247
+ return Math.min(limit, Math.floor(n));
6248
+ }
6249
+ function minCap(a, b) {
6250
+ if (a === void 0) return b;
6251
+ if (b === void 0) return a;
6252
+ return Math.min(a, b);
6253
+ }
6240
6254
  function approvalExceptionError(rule) {
6241
6255
  const roleError = roleExceptionError(rule);
6242
6256
  if (roleError) return roleError;
@@ -6314,17 +6328,25 @@ function resolveApprovalOptions(approval) {
6314
6328
  const scope = approval?.scope;
6315
6329
  const remember = approval?.remember;
6316
6330
  const onTimeout = approval?.onTimeout;
6331
+ const maxDays = approvalCap(approval?.maxDays, ACTION_POLICY_GRANT_MAX_DAYS);
6332
+ const maxUses = approvalCap(approval?.maxUses, ACTION_POLICY_GRANT_MAX_USES);
6317
6333
  return {
6318
6334
  scope: scope === "developer" || scope === "org" ? scope : DEFAULT_ACTION_POLICY_APPROVAL.scope,
6319
6335
  remember: remember === "session" || remember === "1h" || remember === "none" ? remember : DEFAULT_ACTION_POLICY_APPROVAL.remember,
6320
- onTimeout: onTimeout === "org" || onTimeout === "block" ? onTimeout : DEFAULT_ACTION_POLICY_APPROVAL.onTimeout
6336
+ onTimeout: onTimeout === "org" || onTimeout === "block" ? onTimeout : DEFAULT_ACTION_POLICY_APPROVAL.onTimeout,
6337
+ ...maxDays !== void 0 ? { maxDays } : {},
6338
+ ...maxUses !== void 0 ? { maxUses } : {}
6321
6339
  };
6322
6340
  }
6323
6341
  function mergeApprovalOptions(a, b) {
6342
+ const maxDays = minCap(a.maxDays, b.maxDays);
6343
+ const maxUses = minCap(a.maxUses, b.maxUses);
6324
6344
  return {
6325
6345
  scope: APPROVAL_SCOPE_RANK[b.scope] > APPROVAL_SCOPE_RANK[a.scope] ? b.scope : a.scope,
6326
6346
  remember: APPROVAL_REMEMBER_RANK[b.remember] > APPROVAL_REMEMBER_RANK[a.remember] ? b.remember : a.remember,
6327
- onTimeout: APPROVAL_ON_TIMEOUT_RANK[b.onTimeout] > APPROVAL_ON_TIMEOUT_RANK[a.onTimeout] ? b.onTimeout : a.onTimeout
6347
+ onTimeout: APPROVAL_ON_TIMEOUT_RANK[b.onTimeout] > APPROVAL_ON_TIMEOUT_RANK[a.onTimeout] ? b.onTimeout : a.onTimeout,
6348
+ ...maxDays !== void 0 ? { maxDays } : {},
6349
+ ...maxUses !== void 0 ? { maxUses } : {}
6328
6350
  };
6329
6351
  }
6330
6352
  function effectiveApprovalScope(approval, presence) {
@@ -6790,14 +6812,24 @@ var COMMAND_LINE_FIELDS = /* @__PURE__ */ new Set(["command", "cmd", "commandLin
6790
6812
  var CODE_EXEC_TOOL_NAME_RE = /\b(node ?repl|repl|shell|exec|execute|terminal|command|cmd|run ?code|bash|zsh|sh|powershell|pwsh)\b/i;
6791
6813
  function shellVerbWords(command) {
6792
6814
  let lead = "";
6815
+ let first = true;
6816
+ let scriptArgs = false;
6793
6817
  return shellCommandWords(command).split(/\s+/).filter(Boolean).map((token) => {
6794
6818
  if (token === ";") {
6795
6819
  lead = "";
6820
+ first = true;
6821
+ scriptArgs = false;
6796
6822
  return token;
6797
6823
  }
6824
+ if (scriptArgs) return "";
6798
6825
  const flag = /^(-{1,2})([A-Za-z][A-Za-z0-9_-]*)(?:=.*)?$/.exec(token);
6799
6826
  const word = flag ? flag[2] : /^\.?[A-Za-z][A-Za-z0-9_-]*$/.test(token) ? token : "";
6800
- if (!word) return "";
6827
+ const leading = first && !flag;
6828
+ if (leading) first = false;
6829
+ if (!word) {
6830
+ if (leading ? isScriptPath(token, true) : !flag && SCRIPT_RUNNER_LEADS.has(lead) && isScriptPath(token, false)) scriptArgs = true;
6831
+ return "";
6832
+ }
6801
6833
  if (!flag && !lead) lead = word.toLowerCase();
6802
6834
  if (flag) {
6803
6835
  if (VCS_PROGRAMS.has(lead)) return "";
@@ -6812,6 +6844,50 @@ function shellVerbWords(command) {
6812
6844
  }).filter(Boolean).join(" ");
6813
6845
  }
6814
6846
  var VCS_PROGRAMS = /* @__PURE__ */ new Set(["git", "gh", "hg", "svn", "glab"]);
6847
+ var SCRIPT_RUNNER_LEADS = /* @__PURE__ */ new Set([
6848
+ "bash",
6849
+ "sh",
6850
+ "zsh",
6851
+ "dash",
6852
+ "ksh",
6853
+ "fish",
6854
+ "cmd",
6855
+ "powershell",
6856
+ "pwsh",
6857
+ "sudo",
6858
+ "doas",
6859
+ "env",
6860
+ "nohup",
6861
+ "time",
6862
+ "timeout",
6863
+ "watch",
6864
+ "ssh",
6865
+ "xargs",
6866
+ "node",
6867
+ "nodejs",
6868
+ "deno",
6869
+ "bun",
6870
+ "python",
6871
+ "python3",
6872
+ "python2",
6873
+ "py",
6874
+ "perl",
6875
+ "ruby",
6876
+ "php",
6877
+ "lua",
6878
+ "rscript",
6879
+ "julia",
6880
+ "tsx",
6881
+ "ts-node",
6882
+ "npx"
6883
+ ]);
6884
+ var SCRIPT_EXTENSION_RE = /\.(?:ps1|psm1|sh|bash|zsh|ksh|fish|py|pyw|js|mjs|cjs|ts|mts|cts|rb|pl|php|lua|r|jl|cmd|bat)$/i;
6885
+ function isScriptPath(token, leading) {
6886
+ const t = token.replace(/^["']|["']$/g, "");
6887
+ if (!t || /^[$%<>|&]/.test(t)) return false;
6888
+ if (SCRIPT_EXTENSION_RE.test(t)) return true;
6889
+ return leading ? /^\.{1,2}[\\/]/.test(t) : /[\\/]/.test(t);
6890
+ }
6815
6891
  var DOT_COMMAND_PROGRAMS = /* @__PURE__ */ new Set(["sqlite3", "sqlite", "litecli", "duckdb"]);
6816
6892
  function stringifyArgs(args) {
6817
6893
  try {
@@ -6866,13 +6942,14 @@ function isHighEntropySecretToken(token, allowWordSlug = false) {
6866
6942
  }
6867
6943
  var SECRET_NAME = String.raw`(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|bearer[_-]?token|client[_-]?secret|secret[_-]?key|secret|password|passwd|pwd|credentials?|private[_-]?key|token)`;
6868
6944
  var SECRET_ASSIGNMENT_RE = new RegExp(String.raw`(?:^|[^\w])[\w.-]*?${SECRET_NAME}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{8,})`, "gi");
6869
- var SECRET_FLAG_RE = /--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)[\\"']*([^\s\\"',;&|)}\]<>]{8,})/gi;
6870
- var SECRET_PLACEHOLDER_RE = /^(?:<.*>|\$\{?[A-Za-z_][\w.]*\}?|%[A-Za-z_]+%|\{\{.*\}\}|\[.*\]|\*{3,}|x{4,}|X{4,}|\.{3,}|-{3,}|_{3,}|(?:your|my|our|the|a|an|some|example|sample|dummy|fake|test|mock|placeholder|redacted|masked|hidden|changeme|change_me|replace_?me|todo|tbd|fixme|none|null|nil|undefined|true|false|required|optional|omitted|removed|unset|empty|secret|password|token|xxx+|abc+|123+)[\w.-]*)$/i;
6945
+ var SECRET_FLAG_RE = /(?:^|[\s"'`;(\[{,])--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)[\\"']*([^\s\\"',;&|)}\]<>-][^\s\\"',;&|)}\]<>]{7,})/gi;
6946
+ var SECRET_PLACEHOLDER_RE = /^(?:<.*>|\$\{?[A-Za-z_][\w.]*\}?|%[A-Za-z_]+%|\{\{.*\}\}|\[.*\]|\*{3,}|x{4,}|X{4,}|\.{3,}|-{3,}|_{3,}|(?:your|my|our|the|a|an)(?:[-_.][\w.-]*)?|(?:some|example|sample|dummy|fake|test|mock|placeholder|redacted|masked|hidden|changeme|change_me|replace_?me|todo|tbd|fixme|none|null|nil|undefined|true|false|required|optional|omitted|removed|unset|empty|secret|password|token|xxx+|abc+|123+)[\w.-]*)$/i;
6871
6947
  var SECRET_CODE_EXPR_RE = /^(?:[A-Za-z_$][\w$]*(?:[.\[][^\s=]*)+|[A-Za-z_$][\w$]*\(.*\)?|process\.env\.\w+|os\.environ.*|env(?:iron)?\.\w+|new\s+\w+.*|await\s+.*|require\(.*)$/;
6872
6948
  function looksLikeSecretValue(raw) {
6873
6949
  const value = raw.replace(/[\\"'`]+$/, "");
6874
6950
  if (value.length < 8) return false;
6875
6951
  if (/^[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]/.test(value)) return false;
6952
+ if (/^--?[A-Za-z]/.test(value)) return false;
6876
6953
  if (SECRET_PLACEHOLDER_RE.test(value)) return false;
6877
6954
  if (SECRET_CODE_EXPR_RE.test(value)) return false;
6878
6955
  if (/^\/(?:[\[(^\\.]|.*\/[gimsuy]*$)/.test(value) || /[\[({]/.test(value)) return false;
@@ -7077,7 +7154,7 @@ function extractUrl(args, _argsText) {
7077
7154
  if (typeof raw !== "string") continue;
7078
7155
  const shellField = ["command", "cmd", "script", "input"].includes(field);
7079
7156
  const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).join("\n") : raw;
7080
- const firstUrl = (text) => text.match(/https?:\/\/[^\s"'<>]+/i)?.[0] || text.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>]+/i)?.[0];
7157
+ const firstUrl = (text) => text.match(/https?:\/\/[^\s"'<>`]+/i)?.[0] || text.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>`]+/i)?.[0];
7081
7158
  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)) : [];
7082
7159
  const externalOutbound = outboundUrls.find((url) => {
7083
7160
  try {
@@ -7086,15 +7163,210 @@ function extractUrl(args, _argsText) {
7086
7163
  return false;
7087
7164
  }
7088
7165
  });
7089
- const found = externalOutbound || outboundUrls[0] || firstUrl(value);
7166
+ const found = externalOutbound || outboundUrls[0] || firstUrl(shellField ? segmentsThatMayReachNetwork(splitShellSegments(value)).join("\n") : value);
7090
7167
  if (found) return found;
7091
7168
  }
7092
7169
  return "";
7093
7170
  }
7171
+ var LOCAL_DATA_LEADS = /* @__PURE__ */ new Set([
7172
+ // PowerShell text / file / pipeline cmdlets and their aliases
7173
+ "get-content",
7174
+ "gc",
7175
+ "type",
7176
+ "set-content",
7177
+ "sc",
7178
+ "add-content",
7179
+ "ac",
7180
+ "out-file",
7181
+ "out-string",
7182
+ "out-null",
7183
+ "write-host",
7184
+ "write-output",
7185
+ "write-verbose",
7186
+ "write-warning",
7187
+ "write-error",
7188
+ "write-information",
7189
+ "write-debug",
7190
+ "select-string",
7191
+ "sls",
7192
+ "select-object",
7193
+ "select",
7194
+ "where-object",
7195
+ "where",
7196
+ "foreach-object",
7197
+ "foreach",
7198
+ "%",
7199
+ "?",
7200
+ "sort-object",
7201
+ "sort",
7202
+ "group-object",
7203
+ "group",
7204
+ "measure-object",
7205
+ "measure",
7206
+ "compare-object",
7207
+ "compare",
7208
+ "format-table",
7209
+ "ft",
7210
+ "format-list",
7211
+ "fl",
7212
+ "format-wide",
7213
+ "fw",
7214
+ "convertto-json",
7215
+ "convertfrom-json",
7216
+ "convertto-csv",
7217
+ "convertfrom-csv",
7218
+ "test-path",
7219
+ "get-item",
7220
+ "gi",
7221
+ "get-childitem",
7222
+ "gci",
7223
+ "ls",
7224
+ "dir",
7225
+ "split-path",
7226
+ "join-path",
7227
+ "resolve-path",
7228
+ "new-item",
7229
+ "ni",
7230
+ "copy-item",
7231
+ "cpi",
7232
+ "copy",
7233
+ "move-item",
7234
+ "mi",
7235
+ "move",
7236
+ "rename-item",
7237
+ "rni",
7238
+ "ren",
7239
+ "remove-item",
7240
+ "ri",
7241
+ "del",
7242
+ "erase",
7243
+ "rd",
7244
+ "rmdir",
7245
+ "get-date",
7246
+ "set-location",
7247
+ "cd",
7248
+ "chdir",
7249
+ "push-location",
7250
+ "pushd",
7251
+ "pop-location",
7252
+ "popd",
7253
+ "get-location",
7254
+ "pwd",
7255
+ // POSIX text / file tools
7256
+ "echo",
7257
+ "printf",
7258
+ "cat",
7259
+ "grep",
7260
+ "egrep",
7261
+ "fgrep",
7262
+ "rg",
7263
+ "ag",
7264
+ "cut",
7265
+ "uniq",
7266
+ "head",
7267
+ "tail",
7268
+ "tr",
7269
+ "wc",
7270
+ "test",
7271
+ "mkdir",
7272
+ "touch",
7273
+ "cp",
7274
+ "mv",
7275
+ "rm",
7276
+ "ln",
7277
+ "basename",
7278
+ "dirname",
7279
+ "realpath",
7280
+ "jq",
7281
+ "yq",
7282
+ "diff",
7283
+ "date",
7284
+ "true",
7285
+ "false"
7286
+ ]);
7287
+ var GIT_NETWORK_SUBCOMMANDS = /* @__PURE__ */ new Set([
7288
+ "clone",
7289
+ "fetch",
7290
+ "pull",
7291
+ "push",
7292
+ "remote",
7293
+ "ls-remote",
7294
+ "submodule",
7295
+ "archive",
7296
+ "request-pull",
7297
+ "svn",
7298
+ "daemon",
7299
+ "bundle",
7300
+ "http-fetch",
7301
+ "http-push",
7302
+ "send-pack",
7303
+ "fetch-pack",
7304
+ "upload-pack",
7305
+ "receive-pack",
7306
+ "lfs"
7307
+ ]);
7308
+ function segmentMayReachNetwork(segment) {
7309
+ if (/\$\(|`/.test(segment) || PS_VALUE_EXECUTOR_RE.test(segment)) return true;
7310
+ return splitShellSegments(segment, true).some((stage) => {
7311
+ if (/^\s*(?:\$[\w:]+|[A-Za-z_]\w*)\s*=/.test(stage)) return true;
7312
+ const masked = maskQuotedSpans(stage);
7313
+ for (const group of masked.matchAll(/\(\s*([A-Za-z][\w-]*)/g)) {
7314
+ if (!LOCAL_DATA_LEADS.has(group[1].toLowerCase())) return true;
7315
+ }
7316
+ const lead = shellSegmentLead(stage);
7317
+ if (!lead) return false;
7318
+ if (lead === "git") {
7319
+ const subcommand = gitSubcommand(stage);
7320
+ return !subcommand || GIT_NETWORK_SUBCOMMANDS.has(subcommand);
7321
+ }
7322
+ return !LOCAL_DATA_LEADS.has(lead);
7323
+ });
7324
+ }
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*$/;
7326
+ function segmentsThatMayReachNetwork(segments) {
7327
+ const tempRedefined = segments.some((segment) => /\$env:(?:TEMP|TMP)\s*=/i.test(maskQuotedSpans(segment)));
7328
+ const remotePath = /(?:^|[\s"'=(])[\\/]{2}[^\s\\/"']+[\\/]/;
7329
+ return segments.filter((segment, index) => {
7330
+ if (!LITERAL_ASSIGNMENT_RE.test(segment)) return segmentMayReachNetwork(segment);
7331
+ return segments.slice(index + 1).some((later) => !LITERAL_ASSIGNMENT_RE.test(later) && (remotePath.test(later) || ghMetadataWriteBodyFile(tempRedefined ? later : temporaryFileSyntax(later)) === void 0 && segmentMayReachNetwork(later)));
7332
+ });
7333
+ }
7334
+ function gitSubcommand(stage) {
7335
+ const tokens = maskQuotedSpans(stage).trim().split(/\s+/);
7336
+ let i2 = tokens.findIndex((token) => /^(?:.*[\\/])?git(?:\.exe)?$/i.test(token)) + 1;
7337
+ if (i2 <= 0) return "";
7338
+ while (i2 < tokens.length && tokens[i2].startsWith("-")) {
7339
+ if (/^(?:-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env|--super-prefix|--list-cmds)$/i.test(tokens[i2])) i2++;
7340
+ i2++;
7341
+ }
7342
+ const subcommand = (tokens[i2] || "").toLowerCase();
7343
+ return /^[a-z][\w-]*$/.test(subcommand) ? subcommand : "";
7344
+ }
7345
+ function temporaryFileSyntax(text) {
7346
+ 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
+ }
7348
+ var GH_FLAG_VALUE_RE = /^(?:'[^'$`\r\n]*'|"[^"$`\r\n]*"|[^\s'"$`|;&(){}<>]+)$/;
7349
+ 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);
7351
+ if (!ghBody) return void 0;
7352
+ const tokens = ghBody[2].match(/'[^'\r\n]*'|"[^"\r\n]*"|[^\s'"]+/g) || [];
7353
+ if (tokens.join("").replace(/\s/g, "") !== ghBody[2].replace(/\s/g, "")) return void 0;
7354
+ let bodyFile;
7355
+ for (let i2 = 0; i2 < tokens.length; i2 += 2) {
7356
+ const flag = tokens[i2];
7357
+ 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;
7359
+ if (/^--body-file$/i.test(flag)) {
7360
+ if (bodyFile !== void 0) return void 0;
7361
+ bodyFile = value;
7362
+ }
7363
+ }
7364
+ return bodyFile;
7365
+ }
7094
7366
  function shellUrlActionText(command) {
7095
7367
  if (command.length > 65536) return command;
7096
7368
  if (/\$env:(?:TEMP|TMP)\s*=/i.test(maskQuotedSpans(command))) return command;
7097
- const fileSyntax = (text) => text.replace(/"\$env:(?:TEMP|TMP)([\\/][A-Za-z0-9_ ./\\-]+)"/gi, '"fcd-temporary$1"').replace(/\(Join-Path\s+\$env:(?:TEMP|TMP)\s+'([A-Za-z0-9_ ./\\-]+)'\)/gi, '"fcd-temporary/$1"');
7369
+ const fileSyntax = temporaryFileSyntax;
7098
7370
  const originalSegments = splitShellSegments(command);
7099
7371
  const localPaths = /* @__PURE__ */ new Map();
7100
7372
  const literalValues = /* @__PURE__ */ new Map();
@@ -7111,7 +7383,7 @@ function shellUrlActionText(command) {
7111
7383
  return value;
7112
7384
  };
7113
7385
  const segments = originalSegments.map((segment, index) => {
7114
- const dataAssignment = /^\$([A-Za-z_][\w]*)\s*=\s*(@'\r?\n[\s\S]*?\r?\n'@|'(?:[^']|'')*')$/.exec(segment);
7386
+ const dataAssignment = /^\$([A-Za-z_][\w]*)\s*=\s*(@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$]*?\r?\n"@|'(?:[^']|'')*')$/.exec(segment);
7115
7387
  if (dataAssignment && !literalValues.has(dataAssignment[1].toLowerCase()) && literalValues.size < 32) {
7116
7388
  literalValues.set(dataAssignment[1].toLowerCase(), dataAssignment[2]);
7117
7389
  dataAssignments.add(index);
@@ -7125,7 +7397,7 @@ function shellUrlActionText(command) {
7125
7397
  pathAssignments.add(index);
7126
7398
  return segment;
7127
7399
  }
7128
- const literal2 = /^(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|'(?:[^']|'')*'|"[^"$`]*")/.exec(segment);
7400
+ const literal2 = /^(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$]*?\r?\n"@|'(?:[^']|'')*'|"[^"$`]*")/.exec(segment);
7129
7401
  const prefix = literal2?.[0] || "";
7130
7402
  const tail = segment.slice(prefix.length).replace(
7131
7403
  /"\$([A-Za-z_][\w]*)([\\/][\w ./\\-]*)?"/g,
@@ -7147,8 +7419,9 @@ function shellUrlActionText(command) {
7147
7419
  );
7148
7420
  });
7149
7421
  if (expansionOverflow) return command;
7150
- const filtered = segments.map((segment) => {
7151
- const literal2 = /^(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|'(?:[^']|'')*'|"[^"$`]*")/;
7422
+ const storedTargets = /* @__PURE__ */ new Map();
7423
+ const filtered = segments.map((segment, index) => {
7424
+ const literal2 = /^(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$]*?\r?\n"@|'(?:[^']|'')*'|"[^"$`]*")/;
7152
7425
  const piped = segment.match(literal2);
7153
7426
  let tail = piped ? segment.slice(piped[0].length).replace(/^\s*\|\s*/, "") : "";
7154
7427
  if (!piped || !/^\s*\|/.test(segment.slice(piped[0].length))) {
@@ -7176,8 +7449,15 @@ function shellUrlActionText(command) {
7176
7449
  }
7177
7450
  target = target.replace(/^['"]|['"]$/g, "");
7178
7451
  if (!target || /^[\\/]{2}|[\r\n*?\[\]<>]/.test(target) || /:/.test(target.replace(/^[A-Za-z]:[\\/]/, ""))) return segment;
7452
+ const key = target.toLowerCase();
7453
+ if (!storedTargets.has(key)) storedTargets.set(key, index);
7179
7454
  return tail;
7180
7455
  });
7456
+ const isStoredTarget = (token, before) => {
7457
+ if (!token) return false;
7458
+ const written = storedTargets.get(token.replace(/^['"]|['"]$/g, "").toLowerCase());
7459
+ return written !== void 0 && written < before;
7460
+ };
7181
7461
  const onlyStoredData = segments.every((segment, index) => {
7182
7462
  if (dataAssignments.has(index)) return true;
7183
7463
  if (pathAssignments.has(index)) return true;
@@ -7187,6 +7467,10 @@ function shellUrlActionText(command) {
7187
7467
  if (/^if\s*\(\$([A-Za-z_][\w]*)\.Count\)\s*\{\s*throw\s+\$\1\[0\]\s*\}$/i.test(segment)) return true;
7188
7468
  if (/^Write-Output\s+'(?:[^']|'')*'$/i.test(segment)) return true;
7189
7469
  if (/^gh\s+pr\s+checks\s+\d+\s+--json\s+[a-z,]+\s+--jq\s+'(?:[^']|'')*'$/i.test(segment)) return true;
7470
+ const ghBodyFile = ghMetadataWriteBodyFile(segment);
7471
+ if (ghBodyFile !== void 0) return isStoredTarget(ghBodyFile, index);
7472
+ const removal = /^Remove-Item\s+(?:-(?:LiteralPath|Path)\s+)?('[^'\r\n]*'|"[^"\r\n]*"|[^\s'"$`|;&(){}<>]+)(?:\s+-Force)?$/i.exec(segment);
7473
+ if (removal && isStoredTarget(removal[1], index)) return true;
7190
7474
  const read = /^Get-Content\s+(?:(?:-LiteralPath|-Path)\s+)?(?:'([^'$`\r\n]*)'|"([^"$`\r\n]*)"|([^\s'"$`|;&(){}<>]+))(?:\s+-(?:Tail|TotalCount)\s+\d{1,6})?(?:\s+-Raw)?\s*$/i.exec(segment);
7191
7475
  if (read) {
7192
7476
  const target = read[1] ?? read[2] ?? read[3];
@@ -7508,8 +7792,8 @@ function classifyShellWords(masked, raw, lead) {
7508
7792
  if (/\b(drop\s+(database|table|schema)|truncate\s+table|kubectl\s+delete|terraform\s+destroy|gcloud\s+(?:sql\s+instances|compute\s+instances|projects|run\s+services)\s+delete|az\s+(?:group|vm|sql\s+server)\s+delete|aws\s+(?:s3\s+rb|rds\s+delete-db-instance|ec2\s+terminate-instances)|docker\s+(?:system\s+prune|volume\s+rm|rm\s+-f)|mkfs|dd\s+if=)\b/.test(value)) {
7509
7793
  return "DELETE";
7510
7794
  }
7511
- const xfer = segment.toLowerCase().match(/\b(?:aws\s+s3\s+(?:cp|sync|mv)|gsutil\s+(?:-m\s+)?(?:cp|rsync|mv)|az\s+storage\s+blob\s+upload|scp|rsync|sftp)\s+(.+)$/);
7512
- if (xfer && /\b(?:aws\s+s3\s+(?:cp|sync|mv)|gsutil\s+(?:-m\s+)?(?:cp|rsync|mv)|az\s+storage\s+blob\s+upload|scp|rsync|sftp)\b/.test(value)) {
7795
+ const xfer = segment.toLowerCase().match(/\b(?:aws\s+s3\s+(?:cp|sync|mv)|gsutil\s+(?:-m\s+)?(?:cp|rsync|mv)|gcloud(?:\.cmd|\.exe)?\s+storage\s+(?:cp|rsync|mv)|az\s+storage\s+blob\s+upload|scp|rsync|sftp)\s+(.+)$/);
7796
+ if (xfer && /\b(?:aws\s+s3\s+(?:cp|sync|mv)|gsutil\s+(?:-m\s+)?(?:cp|rsync|mv)|gcloud(?:\.cmd|\.exe)?\s+storage\s+(?:cp|rsync|mv)|az\s+storage\s+blob\s+upload|scp|rsync|sftp)\b/.test(value)) {
7513
7797
  if (/az\s+storage\s+blob\s+upload/.test(value)) return "write";
7514
7798
  const tokens = xfer[1].split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']|["']$/g, ""));
7515
7799
  const operands = [];
@@ -7707,6 +7991,10 @@ function isInternalHost(hostname) {
7707
7991
  const private172 = host.match(/^172\.(\d+)\./);
7708
7992
  return Boolean(private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31);
7709
7993
  }
7994
+ function isSingleLabelWebHost(parsed) {
7995
+ const host = parsed.hostname.toLowerCase();
7996
+ return /^(?:https?|wss?):$/.test(parsed.protocol) && host.length > 0 && !host.includes(".") && !host.includes(":");
7997
+ }
7710
7998
  function policyTargetsCall(policy, context) {
7711
7999
  if (!policyTargetsMachineKind(policy.appliesTo, normalizeMachineKind(context.machineKind))) return false;
7712
8000
  const developerTargets = policy.appliesTo?.developerNames?.filter(Boolean) || [];
@@ -7962,7 +8250,7 @@ function inferBundledToolContext(toolName, args) {
7962
8250
  context.url = detectedUrl;
7963
8251
  try {
7964
8252
  const parsed = new URL(detectedUrl);
7965
- const hostType = isInternalHost(parsed.hostname) ? "internal" : "external";
8253
+ const hostType = isInternalHost(parsed.hostname) || isSingleLabelWebHost(parsed) ? "internal" : "external";
7966
8254
  const inbound = operation === "read" || ["GET", "HEAD", "OPTIONS", "SELECT", "SHOW", "DESCRIBE", "EXPLAIN"].includes(operation) || isCodeExecTool && !hasSecretLikeValue(argsText) && provenCompoundDownload(commandText, detectedUrl) || provenReadUrls.includes(detectedUrl) || operation === toolName && /\b(fetch|get|read|search|list|lookup|browse|navigate|download|clone|pull|view|open|crawl|scrape)\b/.test(nameWords) && !/\b(post|put|send|upload|push|write|submit|publish|export|create|update|delete|patch)\b/.test(nameWords);
7967
8255
  context["destination.domain"] = parsed.hostname;
7968
8256
  context["url.risk"] = hostType;
@@ -8065,7 +8353,7 @@ function recordHttpRequestFacts(context, args, detectedUrl) {
8065
8353
  if (!["http:", "https:"].includes(parsed.protocol)) return false;
8066
8354
  const containsSecret = hasSecretLikeValue(stringifyArgs({ url: detectedUrl, headers: args.headers, body: args.body, data: args.data }));
8067
8355
  context["request.destination.domain"] = parsed.hostname;
8068
- context["request.destination.type"] = isInternalHost(parsed.hostname) ? "internal" : "external";
8356
+ context["request.destination.type"] = isInternalHost(parsed.hostname) || isSingleLabelWebHost(parsed) ? "internal" : "external";
8069
8357
  context["request.containsSecret"] = containsSecret ? "true" : "false";
8070
8358
  return containsSecret;
8071
8359
  } catch {
@@ -8096,6 +8384,8 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8096
8384
  }
8097
8385
  // Annotate the CommonJS export names for ESM import in node:
8098
8386
  0 && (module.exports = {
8387
+ ACTION_POLICY_GRANT_MAX_DAYS,
8388
+ ACTION_POLICY_GRANT_MAX_USES,
8099
8389
  CONTENT_FACT_FIELDS,
8100
8390
  DEFAULT_ACTION_POLICY_APPROVAL,
8101
8391
  MACHINE_KINDS,
@@ -3,5 +3,7 @@ export interface ApprovalMessageOptions {
3
3
  allowAlways?: boolean;
4
4
  /** Human wording of the grant scope, e.g. "`gh` commands (write) to github.com". */
5
5
  alwaysScopeLabel?: string;
6
+ /** How long it holds: "30 days" (default) or "7 days or 20 uses". */
7
+ alwaysLimitsLabel?: string;
6
8
  }
7
9
  export declare function nativeApprovalMessage(reason: string, options?: ApprovalMessageOptions): string;
@@ -39,7 +39,7 @@ function nativeApprovalMessage(reason, options = {}) {
39
39
  return [
40
40
  'Allow this action?',
41
41
  '', ...details, '',
42
- ...(options.alwaysScopeLabel ? [`Always allow covers: ${line(options.alwaysScopeLabel, 200)} — for 30 days on this machine, under this rule as it is written today.`, ''] : []),
42
+ ...(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
43
  'Allow once permits only this action. Deny or closing keeps it stopped.',
44
44
  'Full details: the IDE message or this machine’s Activity in FullCourtDefense.',
45
45
  ].join('\n');
@@ -20,7 +20,7 @@
20
20
  * Deterministic all the way: the rule that asked is named in the envelope, the
21
21
  * grant is bound to that rule's hash, and nothing here re-evaluates the action.
22
22
  */
23
- import { ConfirmRemember, GrantScope, NativeDialogOptions, NativeDialogOutcome } from './devConfirm';
23
+ import { ConfirmRemember, GrantLimits, GrantScope, NativeDialogOptions, NativeDialogOutcome } from './devConfirm';
24
24
  /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
25
25
  export declare const ASK_ENVELOPE_FIELD = "fcdAsk";
26
26
  /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
@@ -36,9 +36,22 @@ export interface AskEnvelope {
36
36
  scope: GrantScope;
37
37
  policyId?: string;
38
38
  policyName?: string;
39
+ /** Bundle hash — goes on the recorded event. */
39
40
  policyHash?: string;
41
+ /**
42
+ * Identity of the rule as written (`actionPolicyRuleHash` / `localSafetyRuleHash`).
43
+ * A grant binds to this so it survives unrelated org edits and enrollments.
44
+ * Older hook builds omit it; the bundle hash is the fallback.
45
+ */
46
+ ruleHash?: string;
40
47
  matchedRule?: string;
41
48
  remember: ConfirmRemember;
49
+ /**
50
+ * Caps an "Always allow" would be created under (rule + org ceiling).
51
+ * `null` = the org switched "Always allow" off (no third button).
52
+ * Absent (older hook build) = the built-in 30 days.
53
+ */
54
+ grantLimits?: GrantLimits | null;
42
55
  /** Text for the dialog (the same explanation the IDE card shows). */
43
56
  reason: string;
44
57
  }
package/dist/askDialog.js CHANGED
@@ -138,15 +138,18 @@ async function resolveAskWithDialog(verdict, options = {}) {
138
138
  const canAsk = options.dialog ? (0, devConfirm_1.isHumanPresent)() : (0, devConfirm_1.nativeDialogAvailable)();
139
139
  if (askChannel() === 'ide' || !canAsk)
140
140
  return { ...clean, resolution: 'ide' };
141
- const allowAlways = envelope.remember !== 'none' && Boolean(envelope.policyHash)
141
+ const grantHash = envelope.ruleHash || envelope.policyHash;
142
+ const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
143
+ const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits)
142
144
  && (envelope.scope.event !== 'shell' || (envelope.scope.programs || []).length > 0);
143
145
  const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope) : undefined;
146
+ const limitsLabel = allowAlways && grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
144
147
  const dialog = options.dialog || devConfirm_1.confirmNativeActionDetailed;
145
148
  const reason = options.clientLabel ? `IDE: ${options.clientLabel}\n${envelope.reason}` : envelope.reason;
146
149
  let outcome = 'unavailable';
147
150
  let latencyMs = 0;
148
151
  try {
149
- ({ outcome, latencyMs } = await dialog(reason, options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, { allowAlways, alwaysScopeLabel: scopeLabel }));
152
+ ({ outcome, latencyMs } = await dialog(reason, options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, { allowAlways, alwaysScopeLabel: scopeLabel, alwaysLimitsLabel: limitsLabel }));
150
153
  }
151
154
  catch {
152
155
  outcome = 'unavailable';
@@ -154,13 +157,12 @@ async function resolveAskWithDialog(verdict, options = {}) {
154
157
  if (outcome === 'unavailable')
155
158
  return { ...clean, resolution: 'ide' };
156
159
  const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
157
- const rule = { policyId: envelope.policyId, policyName: envelope.policyName, matchedRule: envelope.matchedRule, policyHash: envelope.policyHash };
158
160
  if (outcome === 'allow' || outcome === 'allow_always') {
159
161
  // Same bookkeeping the post-execution hook would have done for an IDE Allow.
160
162
  (0, devConfirm_1.settleDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
161
163
  let granted = false;
162
- if (outcome === 'allow_always' && allowAlways && envelope.policyHash) {
163
- granted = Boolean((0, devConfirm_1.recordDeveloperGrant)(envelope.scope, { ...rule, policyHash: envelope.policyHash }));
164
+ if (outcome === 'allow_always' && allowAlways && grantHash && grantLimits) {
165
+ granted = Boolean((0, devConfirm_1.recordDeveloperGrant)(envelope.scope, { policyId: envelope.policyId, policyName: envelope.policyName, matchedRule: envelope.matchedRule, policyHash: grantHash }, grantLimits));
164
166
  }
165
167
  // The MCP gateway sees this exact call next; do not ask the person twice.
166
168
  if (envelope.event === 'mcp')
@@ -170,7 +172,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
170
172
  toolName: envelope.toolName,
171
173
  operation: envelope.operation,
172
174
  reason: granted
173
- ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (30 days, this rule)${policyLabel}`
175
+ ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (${limitsLabel}, this rule)${policyLabel}`
174
176
  : `developer confirmed in the FullCourtDefense dialog${policyLabel}`,
175
177
  ruleId: envelope.policyId,
176
178
  policyHash: envelope.policyHash,
@@ -180,7 +182,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
180
182
  });
181
183
  (0, telemetry_1.triggerFlush)(false);
182
184
  const agentMsg = granted
183
- ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} under this rule.`
185
+ ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} (${limitsLabel}) under this rule.`
184
186
  : `FullCourtDefense: the developer allowed this ${envelope.event} once in the FullCourtDefense dialog.`;
185
187
  return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, true, '', agentMsg)), exitCode: 0, resolution: outcome };
186
188
  }
@@ -568,7 +568,6 @@ function containsMetadataEndpoint(value) {
568
568
  }
569
569
  function containsSensitiveCredentialPath(value) {
570
570
  const text = normalizeForPath(value);
571
- const traversal = /(?:^|\/)\.\.(?:\/|$)/.test(text) || /%2e/i.test(value);
572
571
  const checks = [
573
572
  { itemId: 'ssh_private_key', label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"])/ },
574
573
  { itemId: 'ssh_config', label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"])/ },
@@ -605,12 +604,20 @@ function containsSensitiveCredentialPath(value) {
605
604
  if (check.re.test(text))
606
605
  return { itemId: check.itemId, label: check.label };
607
606
  }
608
- if (/(?:^|\/)\.env(?:\.[a-z0-9_-]+)?(?:$|[/?#\s'"])/.test(text)) {
607
+ // `.env` is a FILE, the last segment of its path: `../.env`, `.env.local`,
608
+ // `cat ../../.env`. A regex literal such as `process\.env\.\w*` normalizes to
609
+ // `process/.env/.\w*` — a directory named .env with more path after it — and
610
+ // is not a file read. Traversal and the file must sit in the SAME path token:
611
+ // `../.github/x.yml` on one argument and `.env` on another are two unrelated
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) {
609
615
  // Traversal to a .env outside the workspace stays a hard block
610
616
  // (env_traversal); a plain workspace .env is a separate warn-by-default
611
617
  // item (env_workspace) — agents legitimately read it during development,
612
618
  // but the fleet should still see every touch.
613
- return traversal
619
+ const reachesOutside = /(?:^|\/)\.\.(?:\/|$)/.test(envToken) || /%2e/i.test(envToken);
620
+ return reachesOutside
614
621
  ? { itemId: 'env_traversal', label: 'environment file via path traversal' }
615
622
  : { itemId: 'env_workspace', label: 'workspace environment file' };
616
623
  }