fullcourtdefense-cli 1.35.1 → 1.35.3

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,11 +6940,12 @@ 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;
6940
6947
  if (allowWordSlug && /^[A-Za-z_][\w.-]*\/[A-Za-z_][\w.-]*\.[A-Za-z][A-Za-z0-9]{0,9}$/.test(token)) return false;
6948
+ if (allowWordSlug && /[/.]/.test(token) && token.split(/[/.]/).length >= 2 && token.split(/[/.]/).every((part) => /^[A-Za-z][a-z]*(?:[A-Z][a-z0-9]*)*$/.test(part))) return false;
6941
6949
  if (/^[a-z]+(?:[A-Z][a-z0-9]+)+$/.test(token) || /^[a-z]+(?:_[a-z0-9]+)+$/.test(token)) return false;
6942
6950
  if (allowWordSlug && /^[A-Z][a-z]+-(?:[A-Z][a-z]+){2,}$/.test(token)) return false;
6943
6951
  if (allowWordSlug && /^[a-z]+(?:-[a-z]+)+(?:-\d+)?$/.test(token)) return false;
@@ -6959,6 +6967,7 @@ function looksLikeSecretValue(raw) {
6959
6967
  if (value.length < 8) return false;
6960
6968
  if (/^[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]/.test(value)) return false;
6961
6969
  if (/^--?[A-Za-z]/.test(value)) return false;
6970
+ if (PUBLISHED_EXAMPLE_VALUES.has(value)) return false;
6962
6971
  if (SECRET_PLACEHOLDER_RE.test(value)) return false;
6963
6972
  if (SECRET_CODE_EXPR_RE.test(value)) return false;
6964
6973
  if (/^\/(?:[\[(^\\.]|.*\/[gimsuy]*$)/.test(value) || /[\[({]/.test(value)) return false;
@@ -7025,7 +7034,7 @@ function hasSecretLikeValue(value) {
7025
7034
  return detectSecretKind(value) !== "none";
7026
7035
  }
7027
7036
  var PII_EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
7028
- var PII_EMAIL_IGNORE_RE = /(?:@(?:example\.(?:com|org|net)|test\.[a-z]+|localhost(?:\.[a-z]+)?|[\w.-]*\.(?:invalid|test|local|localdomain|example)|users\.noreply\.github\.com|noreply\.[\w.-]+)$|^(?:no-?reply|noreply|do-?not-?reply|test\d*|example|user\d*|admin|root|foo|bar|baz|someone|somebody|you|me|name|first\.last|john\.doe|jane\.doe|email)@)/i;
7037
+ var PII_EMAIL_IGNORE_RE = /(?:@(?:example\.(?:com|org|net)|test\.[a-z]+|localhost(?:\.[a-z]+)?|[\w.-]*\.(?:invalid|test|local|localdomain|example)|users\.noreply\.github\.com|noreply\.[\w.-]+|[\w.-]*\.gserviceaccount\.com|[\w.-]*\.svc\.id\.goog)$|^(?:no-?reply|noreply|do-?not-?reply|test\d*|example|user\d*|admin|root|foo|bar|baz|someone|somebody|you|me|name|first\.last|john\.doe|jane\.doe|email)@)/i;
7029
7038
  var PII_PHONE_RE = /(?:^|[^\w.@/:-])(\+?\(?\d{1,4}\)?[\s-]\(?\d{2,4}\)?[\s-]\d{2,4}(?:[\s-]\d{2,4}){0,2})(?![\w.@/:-])/g;
7030
7039
  function hasPiiLikeValue(raw) {
7031
7040
  if (!raw) return false;
@@ -7163,7 +7172,9 @@ function extractUrl(args, _argsText) {
7163
7172
  if (typeof raw !== "string") continue;
7164
7173
  const shellField = ["command", "cmd", "script", "input"].includes(field);
7165
7174
  const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).map(withoutGhMetadataProse).join("\n") : raw;
7166
- const firstUrl = (text) => text.match(/https?:\/\/[^\s"'<>`]+/i)?.[0] || text.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>`]+/i)?.[0];
7175
+ const firstWebUrl = (text) => text.match(/https?:\/\/[^\s"'<>`]+/i)?.[0];
7176
+ const firstStorageUrl = (text) => text.match(/\b(?:s3|gs|az|ftp|sftp|smb):\/\/[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[^\s"'<>`]*)?/i)?.[0];
7177
+ const firstUrl = (text) => firstWebUrl(text) || firstStorageUrl(text);
7167
7178
  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)) : [];
7168
7179
  const externalOutbound = outboundUrls.find((url) => {
7169
7180
  try {
@@ -7172,7 +7183,8 @@ function extractUrl(args, _argsText) {
7172
7183
  return false;
7173
7184
  }
7174
7185
  });
7175
- const found = externalOutbound || outboundUrls[0] || firstUrl(shellField ? segmentsThatMayReachNetwork(splitShellSegments(value)).join("\n") : value);
7186
+ const networkSegments = shellField ? segmentsThatMayReachNetwork(splitShellSegments(value)) : [value];
7187
+ const found = externalOutbound || outboundUrls[0] || firstWebUrl(networkSegments.join("\n")) || (shellField ? firstStorageUrl(networkSegments.filter((segment, index, all) => storageUriIsDestination(segment, index, all)).join("\n")) : void 0) || firstStorageUrl(networkSegments.join("\n"));
7176
7188
  if (found) return found;
7177
7189
  }
7178
7190
  return "";
@@ -7819,6 +7831,24 @@ function shellSegmentLead(segment) {
7819
7831
  }
7820
7832
  var PS_SESSION_VARIABLE_ASSIGN_RE = /^\s*\$(?:psdefaultparametervalues|psmodulepath|profile|executioncontext|env:(?:path|pathext|psmodulepath))\s*(?:\[[^\]]*\]\s*)?(?:\+?=)/i;
7821
7833
  var PS_VALUE_EXECUTOR_RE = /\b(?:invoke-expression|iex|invoke-command|icm|start-process|saps|invoke-item|ii)\b|&\s*[$(]|\.\s*invoke\s*\(/i;
7834
+ var PS_VALUE_LEAD_RE = /^\$(?:env:)?\w+(?:\.\w+|\[\d+\])*$/;
7835
+ var PS_BLOCK_STAGE_LEADS = /* @__PURE__ */ new Set(["foreach-object", "foreach", "%", "where-object", "where", "?"]);
7836
+ function classifyPipelineStage(stage, depth) {
7837
+ const trimmed = stage.trim();
7838
+ if (!trimmed) return "read";
7839
+ if (isPowerShellReadFormatter(trimmed)) return "read";
7840
+ const lead = shellSegmentLead(trimmed);
7841
+ if (PS_BLOCK_STAGE_LEADS.has(lead) && depth < 3) {
7842
+ const open = trimmed.indexOf("{");
7843
+ const close = trimmed.lastIndexOf("}");
7844
+ if (open >= 0 && close > open) {
7845
+ const rest = maskQuotedSpans(trimmed.slice(0, open) + trimmed.slice(close + 1));
7846
+ if (!/^\s*(?:foreach-object|foreach|%|where-object|where|\?)\s*(?:-process\s*)?\s*$/i.test(rest)) return "SHELL";
7847
+ return classifyShellCommandOperation(trimmed.slice(open + 1, close), depth + 1);
7848
+ }
7849
+ }
7850
+ return classifyShellSegment(trimmed, depth + 1);
7851
+ }
7822
7852
  function isInertValueExpression(raw) {
7823
7853
  const masked = maskQuotedSpans(raw);
7824
7854
  if (/\$\(|`|&\s*[$(\w"']|[|<>]/.test(masked) || PS_VALUE_EXECUTOR_RE.test(masked)) return false;
@@ -7835,8 +7865,12 @@ function classifyShellSegment(segment, depth = 0) {
7835
7865
  if (!raw) return "read";
7836
7866
  if (PS_SESSION_VARIABLE_ASSIGN_RE.test(raw)) return "SHELL";
7837
7867
  const lead = shellSegmentLead(segment);
7838
- if ((/^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead)) && PS_VALUE_EXECUTOR_RE.test(maskQuotedSpans(raw))) return "SHELL";
7839
- if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead)) return "read";
7868
+ if (PS_VALUE_LEAD_RE.test(lead) && PS_VALUE_EXECUTOR_RE.test(maskQuotedSpans(raw))) return "SHELL";
7869
+ if (SHELL_NEUTRAL_BUILTINS.has(lead)) return "read";
7870
+ if (PS_VALUE_LEAD_RE.test(lead)) {
7871
+ const stages = splitShellSegments(raw, true).slice(1);
7872
+ return worstShellOp(stages.map((stage) => classifyPipelineStage(stage, depth)), "read");
7873
+ }
7840
7874
  if (isInertValueExpression(raw)) return "read";
7841
7875
  const rawLower = raw.toLowerCase();
7842
7876
  const sqlClient = SHELL_SQL_CLIENTS.has(lead) || /\|\s*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlcmd|sqlite3|mongosh?|clickhouse-client|bq|cockroach|usql)\b/.test(rawLower);
@@ -7888,7 +7922,26 @@ function classifyShellWords(masked, raw, lead) {
7888
7922
  if (lead === "git" && /\bgit\s+(?:status|diff|log|show|blame|clone|fetch|pull|ls-files|remote|branch(?!\s+-[dD])|describe|rev-parse|stash\s+(?:list|show)|tag$)\b/.test(value)) return "read";
7889
7923
  if (lead === "gh" && /\bgh\s+(?:pr|issue|repo|run|release|workflow)\s+(?:view|list|status|checks|diff|download)\b/.test(value)) return "read";
7890
7924
  if (lead === "gh" && /\bgh\s+auth\s+status\b/.test(value) && !/--show-token|\s-t\b/.test(value)) return "read";
7891
- if (/^(?:gcloud\s+run\s+services\s+(?:describe|logs\s+read)|kubectl\s+(?:get|describe|logs|top|config\s+(?:current-context|get-contexts|view(?!\s+--raw)))|docker\s+(?:ps|images|logs|inspect|version|info)|terraform\s+(?:plan|validate|fmt|show|state\s+list|output)|aws\s+(?:s3\s+ls|sts\s+get-caller-identity)|az\s+account\s+show|npm\s+(?:ls|list|view|outdated|--version)|node\s+--version|python3?\s+--version)\b/.test(value)) return "read";
7925
+ if (lead === "gh" && /\bgh\s+api(?=\s|$)/.test(value)) {
7926
+ const method = /(?:\s-x|\s--method)[\s=]+["']?([a-z]+)/i.exec(segment);
7927
+ const hasField = /\s(?:-f|-F|--field|--raw-field|--input)(?:[\s=]|$)/.test(segment);
7928
+ if (method && method[1].toUpperCase() !== "GET" || hasField) return "write";
7929
+ const header = /\s(?:-H|--header)(?:[\s=]|$)/.exec(segment);
7930
+ if (header && /\$\(|`|\$env:|\(\s*[A-Za-z][\w-]*\s/.test(segment.slice(header.index))) return "write";
7931
+ return pipesIntoInterpreter ? "SHELL" : "read";
7932
+ }
7933
+ const gcloudVerb = gcloudVerbWord(value);
7934
+ if (gcloudVerb) {
7935
+ if (GCLOUD_READ_VERB_RE.test(gcloudVerb)) {
7936
+ if (pipesIntoInterpreter) return "SHELL";
7937
+ const redirect = value.match(/(?:^|[^>0-9])>{1,2}\s*([^\s&|;]+)/);
7938
+ if (redirect && !/^\/dev\/null$|^nul$|^\$null$/i.test(redirect[1])) return "write";
7939
+ return "read";
7940
+ }
7941
+ if (gcloudVerb === "delete") return "DELETE";
7942
+ if (GCLOUD_WRITE_VERB_RE.test(gcloudVerb)) return pipesIntoInterpreter ? "SHELL" : "write";
7943
+ }
7944
+ if (/^(?:kubectl\s+(?:get|describe|logs|top|config\s+(?:current-context|get-contexts|view(?!\s+--raw)))|docker\s+(?:ps|images|logs|inspect|version|info)|terraform\s+(?:plan|validate|fmt|show|state\s+list|output)|aws\s+(?:s3\s+ls|sts\s+get-caller-identity)|az\s+account\s+show|npm\s+(?:ls|list|view|outdated|--version)|node\s+--version|python3?\s+--version)\b/.test(value)) return "read";
7892
7945
  if (/\b(rm\s+-[a-z]*r[a-z]*f|rm\s+-[a-z]*f[a-z]*r|del\s+\/[fqs]|remove-item\b.*\s-recurse\b|rmdir\s+\/s)\b/.test(value)) {
7893
7946
  return isWorkspaceScopedRemoval(segment) ? "write" : "DELETE";
7894
7947
  }
@@ -7919,6 +7972,35 @@ function classifyShellWords(masked, raw, lead) {
7919
7972
  if (SHELL_BLOCK_STRUCTURE_RE.test(value)) return "read";
7920
7973
  return "SHELL";
7921
7974
  }
7975
+ var GCLOUD_READ_VERB_RE = /^(?:describe|list|ls|get-iam-policy|get-value|get-ancestors|read|check|versions)$/;
7976
+ var GCLOUD_WRITE_VERB_RE = /^(?:create|update|patch|replace|enable|disable|deploy|apply|import|export|start|stop|restart|resume|suspend|scale|promote|migrate|rollback|add-[a-z-]+|remove-[a-z-]+|set-[a-z-]+|unset|clear|activate|revoke|copy)$/;
7977
+ function gcloudVerbWord(value) {
7978
+ const tokens = value.split("|")[0].trim().split(/\s+/);
7979
+ if (!/^gcloud(?:\.cmd|\.exe)?$/.test(tokens.shift() || "")) return null;
7980
+ let depth = 0;
7981
+ for (const token of tokens) {
7982
+ if (token.startsWith("-")) continue;
7983
+ if (GCLOUD_READ_VERB_RE.test(token) || GCLOUD_WRITE_VERB_RE.test(token) || token === "delete") return token;
7984
+ if (!/^[a-z][a-z0-9-]*$/.test(token) || ++depth > 6) return null;
7985
+ }
7986
+ return null;
7987
+ }
7988
+ var CLOUD_CLI_LEAD_RE = /^(?:gcloud(?:\.cmd|\.exe)?|gsutil|aws|az)$/i;
7989
+ var STORAGE_TRANSFER_RE = /\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/i;
7990
+ var PIPES_INTO_INTERPRETER_RE = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:pwsh|powershell|python3?|node|perl|ruby)\b/i;
7991
+ function storageUriIsDestination(segment, index = 0, all = []) {
7992
+ const lead = shellSegmentLead(segment);
7993
+ if (!CLOUD_CLI_LEAD_RE.test(lead)) return true;
7994
+ const words = maskQuotedSpans(segment).toLowerCase();
7995
+ if (PIPES_INTO_INTERPRETER_RE.test(words) || /\$\(|`|&\s*[$(]/.test(words) || PS_VALUE_EXECUTOR_RE.test(words)) return true;
7996
+ if (STORAGE_TRANSFER_RE.test(words)) return true;
7997
+ if (/^\s*(?:\$[\w:]+\s*=|[A-Za-z_]\w*=)/.test(segment) && all.slice(index + 1).some((later) => {
7998
+ const laterWords = maskQuotedSpans(later).toLowerCase();
7999
+ return PS_VALUE_EXECUTOR_RE.test(laterWords) || PIPES_INTO_INTERPRETER_RE.test(laterWords) || /(?:^|[\s;&|(])(?:sudo\s+)?(?:ba|z|da|k)?sh\s+-c\b|(?:^|[\s;&|(])(?:pwsh|powershell)(?:\.exe)?\s+-c(?:ommand)?\b|\beval\b|&\s*[$(]/.test(laterWords);
8000
+ })) return true;
8001
+ if (/^gcloud(?:\.cmd|\.exe)?\s+(?:--\S+\s+)*(?:alpha\s+|beta\s+)?[a-z-]+(?:\s+[a-z-]+){0,4}\s+export\b/.test(words.split("|")[0].trim())) return true;
8002
+ return false;
8003
+ }
7922
8004
  var SHELL_BLOCK_STRUCTURE_RE = /^(?:\s|[{}()=\-\d]|\+=|""|\$[\w:.]+|\b(?:try|catch|finally|if|then|else|elseif|elif|fi|do|done|esac|end|exit|return|break|continue)\b)*$/i;
7923
8005
  function classifyShellCommandOperation(command, depth = 0) {
7924
8006
  if (provenPowerShellReadSequence(command)) return "read";
@@ -8093,6 +8175,16 @@ function provenPowerShellReadSequence(command) {
8093
8175
  }
8094
8176
  return requests > 0;
8095
8177
  }
8178
+ function isLoopbackHost(hostname) {
8179
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
8180
+ 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);
8181
+ }
8182
+ 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;
8183
+ function commandStaysOnLoopback(command) {
8184
+ if (!command || RAW_NETWORK_PROGRAM_RE.test(command)) return false;
8185
+ const hosts = [...command.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/(?:[^/\s@'"]*@)?([^/\s:'"?#]+)/gi)].map((match) => match[1]);
8186
+ return hosts.length > 0 && hosts.every(isLoopbackHost);
8187
+ }
8096
8188
  function isInternalHost(hostname) {
8097
8189
  const host = hostname.toLowerCase();
8098
8190
  if (!host || host === "localhost" || host === "::1" || host.endsWith(".local") || host.endsWith(".internal")) return true;
@@ -8133,6 +8225,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8133
8225
  const matchText = buildOperationMatchText(toolName, context);
8134
8226
  const toolCaps = toolCapabilities(toolName || context.toolName || "");
8135
8227
  const localFileContentAction = isLocalFileContentAction(toolName, operation, context);
8228
+ const loopbackContentAction = context["destination.loopback"] === "true";
8229
+ const ruleNamesDestination = (rule) => (rule.constraints || []).some((c) => c.field.startsWith("destination."));
8230
+ const contentCeilingApplies = (rule) => ruleNeedsContentFact(rule) && (localFileContentAction || loopbackContentAction && !ruleNamesDestination(rule));
8136
8231
  const isBareInvocation = !!operation && operation.toLowerCase() === (toolName || "").toLowerCase();
8137
8232
  const isCodeExecTool = toolCaps.has("code_execution");
8138
8233
  const candidateOperations = isBareInvocation && declaredOperations.length > 0 && !isCodeExecTool ? [operation, ...declaredOperations] : [operation];
@@ -8187,7 +8282,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8187
8282
  const constraintDesc = rule.constraints?.map((c) => `${c.field} ${c.operator} ${c.value}`).join(", ");
8188
8283
  const matchedRule = `${rule.operations.join("/")} \u2192 ${rule.verdict}${constraintDesc ? ` (${constraintDesc})` : ""}`;
8189
8284
  if (isMonitorStage) {
8190
- const wouldOnlyWarn = rule.verdict === "block" && policy.enforcement !== "hard" && ruleNeedsContentFact(rule) && localFileContentAction;
8285
+ const wouldOnlyWarn = rule.verdict === "block" && policy.enforcement !== "hard" && contentCeilingApplies(rule);
8191
8286
  if ((rule.verdict === "block" || rule.verdict === "require_approval") && !wouldOnlyWarn) {
8192
8287
  const match = { policyId: policy.id, policyName: policy.name, verdict: rule.verdict, matchedRule };
8193
8288
  monitorMatches.push(match);
@@ -8205,13 +8300,13 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8205
8300
  reason: `Action policy "${policy.name}": ${rule.operations.join("/")} ${rule.verdict === "block" ? "blocked" : rule.verdict === "require_approval" ? "requires approval" : rule.verdict}`,
8206
8301
  ...rule.verdict === "require_approval" ? { approval: resolveApprovalOptions(rule.approval) } : {}
8207
8302
  };
8208
- if (rule.verdict === "block" && policy.enforcement !== "hard" && ruleNeedsContentFact(rule) && localFileContentAction) {
8303
+ if (rule.verdict === "block" && policy.enforcement !== "hard" && contentCeilingApplies(rule)) {
8209
8304
  matched = {
8210
8305
  ...matched,
8211
8306
  allowed: true,
8212
8307
  verdict: "log",
8213
8308
  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)`
8309
+ 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
8310
  };
8216
8311
  }
8217
8312
  if (matched.verdict === "require_approval") {
@@ -8365,11 +8460,17 @@ function inferBundledToolContext(toolName, args) {
8365
8460
  context["destination.domain"] = parsed.hostname;
8366
8461
  context["url.risk"] = hostType;
8367
8462
  const requestHasSecret = recordHttpRequestFacts(context, args, detectedUrl);
8368
- if (inbound) {
8463
+ const storageResourceOnly = isCodeExecTool && /^(?:gs|s3|az):\/\//i.test(detectedUrl) && (() => {
8464
+ const segments = splitShellSegments(commandText);
8465
+ const bucket = (detectedUrl.match(/^[a-z0-9]+:\/\/[^/\s"'<>`]+/i) || [detectedUrl])[0].toLowerCase();
8466
+ const carriers = segments.map((segment, index) => [segment, index]).filter(([segment]) => segment.toLowerCase().includes(bucket));
8467
+ return carriers.length > 0 && carriers.every(([segment, index]) => !storageUriIsDestination(segment, index, segments));
8468
+ })();
8469
+ if (inbound || storageResourceOnly) {
8369
8470
  context["source.domain"] = parsed.hostname;
8370
8471
  context["source.type"] = hostType;
8371
8472
  }
8372
- if (!inbound || requestHasSecret) {
8473
+ if (!inbound && !storageResourceOnly || requestHasSecret) {
8373
8474
  context["destination.type"] = hostType;
8374
8475
  }
8375
8476
  } catch {
@@ -8388,6 +8489,10 @@ function inferBundledToolContext(toolName, args) {
8388
8489
  context.amount = String(args.amount ?? args.price ?? args.total);
8389
8490
  }
8390
8491
  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";
8492
+ delete context["destination.loopback"];
8493
+ if (context["destination.domain"] && isLoopbackHost(context["destination.domain"]) && (!isCodeExecTool || commandStaysOnLoopback(commandText))) {
8494
+ context["destination.loopback"] = "true";
8495
+ }
8391
8496
  delete context["destination.repository"];
8392
8497
  if (isCodeExecTool) {
8393
8498
  const repository = explicitGitHubWriteRepository(String(args.command || args.cmd || args.code || args.script || args.input || ""));
@@ -16,7 +16,10 @@ export interface NativeDialogView {
16
16
  headline: string;
17
17
  subtitle: string;
18
18
  kind?: string;
19
+ /** The action: the whole script when the explanation carried it, else the short "What" line. */
19
20
  action?: string;
21
+ /** Plain-words line per step of a shell script ("Exports Firestore data … to storage bucket …"). */
22
+ steps: string[];
20
23
  policy?: string;
21
24
  scope?: string;
22
25
  agent?: string;
@@ -12,6 +12,8 @@ function line(value, limit) {
12
12
  const head = Math.ceil((limit - 3) * 0.6);
13
13
  return `${text.slice(0, head)} … ${text.slice(-(limit - head - 3))}`;
14
14
  }
15
+ /** The full script is shown in a scrolling box; this only guards against a runaway payload. */
16
+ const MAX_DIALOG_SCRIPT = 6000;
15
17
  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
18
  const CEILING_NOTE_SHORT = 'set to Block; on your workstation it asks you first.';
17
19
  /** Parse the `Rule:` line the block explanation writes into the name the console lists and its scope. */
@@ -39,6 +41,20 @@ function promptFacts(reason) {
39
41
  const facts = { agent, ide: field('IDE') };
40
42
  if (rule)
41
43
  Object.assign(facts, ruleParts(rule));
44
+ const script = field('Script');
45
+ if (script)
46
+ facts.script = script.length > MAX_DIALOG_SCRIPT ? `${script.slice(0, MAX_DIALOG_SCRIPT - 1)}…` : script;
47
+ const doesAt = rows.findIndex(row => /^Does:\s*$/.test(row));
48
+ if (doesAt >= 0) {
49
+ const steps = [];
50
+ for (const row of rows.slice(doesAt + 1)) {
51
+ if (!row.startsWith('- '))
52
+ break;
53
+ steps.push(row.slice(2).trim());
54
+ }
55
+ if (steps.length)
56
+ facts.steps = steps;
57
+ }
42
58
  if (what) {
43
59
  // summarizeAction ends with the kind in parentheses: "… (shell command)".
44
60
  const kind = /^(.*)\s\(([a-z][a-z /-]{2,30})\)$/i.exec(what);
@@ -94,7 +110,9 @@ function nativeDialogView(reason, options = {}) {
94
110
  ? 'A security policy paused this action for an admin or manager.'
95
111
  : 'A security policy paused this action. Review the details and choose what to do next.',
96
112
  kind: f.kind,
97
- action: f.action ? line(f.action, 280) : undefined,
113
+ // The whole script when we have it (the box scrolls); the short line otherwise.
114
+ action: f.script ? f.script.replace(/ ⏎ /g, '\n') : f.action ? line(f.action, 280) : undefined,
115
+ steps: (f.steps || []).slice(0, 14).map(step => line(step, 220)),
98
116
  policy: f.policy ? line(f.policy, 160) : undefined,
99
117
  scope: f.scope ? line(f.scope, 220) : undefined,
100
118
  agent: f.agent ? line(f.agent, 80) : undefined,
@@ -117,6 +135,7 @@ function nativeApprovalMessage(reason, options = {}) {
117
135
  f.policy ? `Policy: ${line(f.policy, 160)}` : '',
118
136
  f.scope ? ` ${line(f.scope, 200)}` : '',
119
137
  f.action ? `Action: ${f.kind ? `${f.kind} — ` : ''}${line(f.action, 230)}` : '',
138
+ ...(f.steps || []).slice(0, 6).map((step, index) => `${index === 0 ? 'Does: ' : ' '}${index + 1}. ${line(step, 160)}`),
120
139
  f.agent ? `Agent: ${line(f.agent, 80)}` : '',
121
140
  f.ide ? `IDE: ${line(f.ide, 40)}` : '',
122
141
  f.tool ? `Tool: ${line(f.tool, 230)}` : '',
@@ -19,7 +19,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.summarizeAction = summarizeAction;
20
20
  exports.buildBlockExplanation = buildBlockExplanation;
21
21
  exports.oneLineExplanation = oneLineExplanation;
22
+ const commandNarrative_1 = require("./commandNarrative");
22
23
  const MAX_WHAT = 160;
24
+ /** The full script travels to the approval dialog; anything longer is still cut, but far later. */
25
+ const MAX_SCRIPT = 6000;
26
+ /** Newlines flattened so the multi-line explanation grammar (one `Field:` per row) still parses. */
27
+ function scriptLine(command) {
28
+ const flat = command.replace(/\r?\n/g, ' ⏎ ').trim();
29
+ return flat.length > MAX_SCRIPT ? `${flat.slice(0, MAX_SCRIPT - 1)}…` : flat;
30
+ }
23
31
  function str(v) {
24
32
  return typeof v === 'string' && v.trim() ? v : undefined;
25
33
  }
@@ -170,7 +178,10 @@ function buildBlockExplanation(input) {
170
178
  // "Blocked ..." reason must not turn a pending approval into a final denial.
171
179
  const reason = src.source === 'builtin' && (input.outcome === 'ask' || input.outcome === 'approval')
172
180
  ? src.reason.replace(/^Blocked\b/, 'Matched') : src.reason;
173
- why = src.explanation ? `${reason} — ${src.explanation}` : reason;
181
+ // Catalog findings often carry the same words as reason and explanation
182
+ // ("…: gcloud access-token print." / "gcloud access-token print"); say it once.
183
+ const explanation = src.explanation?.trim().replace(/\.$/, '');
184
+ why = explanation && !reason.toLowerCase().includes(explanation.toLowerCase()) ? `${reason} — ${src.explanation}` : reason;
174
185
  unblock = [
175
186
  `Open the rule in the console: ${consoleLink}`,
176
187
  `An admin can set "${label}" to Ask the developer, Warn only, or Off — for everyone or only for ${input.developerName || 'this machine'}.`,
@@ -226,9 +237,18 @@ function buildBlockExplanation(input) {
226
237
  if (input.note)
227
238
  why = `${why} ${input.note}`.trim();
228
239
  const headline = headlineFor(input.outcome, noun);
240
+ // Shell / terminal: say what each step of the script does, in plain words,
241
+ // and carry the whole script when the short "What" line had to cut it.
242
+ const command = (input.event === 'shell' || input.event === 'terminal')
243
+ ? (str(input.toolArgs?.command) || str(input.toolArgs?.cmd) || str(input.toolArgs?.commandLine) || str(input.toolArgs?.script))
244
+ : undefined;
245
+ const steps = command ? (0, commandNarrative_1.describeShellCommandLines)(command) : [];
246
+ const script = command && command.replace(/\s+/g, ' ').trim().length > MAX_WHAT - 1 ? scriptLine(command) : undefined;
229
247
  const userMessage = [
230
248
  headline,
231
249
  `What: ${what}`,
250
+ ...(steps.length ? ['Does:', ...steps.map(step => ` - ${step}`)] : []),
251
+ ...(script ? [`Script: ${script}`] : []),
232
252
  `Rule: ${rule}`,
233
253
  `Why: ${why}`,
234
254
  stepsTitle(input.outcome),
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What a shell script DOES, one plain-words line per step — for the approval
3
+ * dialog and the IDE message. A developer asked to approve
4
+ *
5
+ * $prefix="gs://b/prod-export"; gcloud firestore export $prefix --project acme-prod; gcloud firestore import $prefix --project acme-staging
6
+ *
7
+ * should read "Exports Firestore data from project acme-prod to bucket b ·
8
+ * Imports Firestore data into project acme-staging from bucket b", not a
9
+ * truncated command line.
10
+ *
11
+ * Fixed templates only, keyed on the program and its verb — no model, no
12
+ * scoring, nothing that could decide anything. This module never influences a
13
+ * verdict; it only words one. A program the grammar does not know is reported
14
+ * as exactly that ("Runs `foo bar`"), never guessed. Pure: no I/O.
15
+ */
16
+ export interface CommandStep {
17
+ /** The segment as written (trimmed). */
18
+ segment: string;
19
+ /** Plain-words description of what it does. */
20
+ text: string;
21
+ /** The grammar recognised the program and its verb. */
22
+ known: boolean;
23
+ }
24
+ /** Quote-aware split on `;`, `&&`, `||`, newlines. Pipes stay inside a step. */
25
+ export declare function splitCommandSteps(command: string): string[];
26
+ /** One plain-words line per step, in order. */
27
+ export declare function describeShellCommand(command: string): CommandStep[];
28
+ /** The step lines as the explanation prints them (`- …`). */
29
+ export declare function describeShellCommandLines(command: string): string[];