fullcourtdefense-cli 1.35.6 → 1.35.8

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.
@@ -234,6 +234,43 @@ export declare function toolCapabilities(value: string): Set<string>;
234
234
  */
235
235
  export type SecretKind = 'github_token' | 'aws_access_key' | 'google_api_key' | 'openai_key' | 'slack_token' | 'jwt' | 'private_key' | 'assignment' | 'high_entropy' | 'none';
236
236
  export declare function detectSecretKind(raw: string): SecretKind;
237
+ export type ShellAstMode = 'off' | 'shadow' | 'primary';
238
+ /** Structural subset of shellAst.ts ShellFacts that the engine reads. */
239
+ export interface ShellAstFactsLike {
240
+ complete: boolean;
241
+ commands: Array<{
242
+ name: string;
243
+ argv: string[];
244
+ literal: boolean[];
245
+ text: string;
246
+ pipeline: number;
247
+ cwdScope: 'scoped' | 'unscoped' | 'unknown';
248
+ depth?: {
249
+ subshell: number;
250
+ function: number;
251
+ loop: number;
252
+ conditional: number;
253
+ substitution: number;
254
+ };
255
+ wrapper?: {
256
+ kind: string;
257
+ program: string;
258
+ inner?: ShellAstFactsLike;
259
+ };
260
+ }>;
261
+ }
262
+ export interface ShellAstOperationComparison {
263
+ mode: ShellAstMode;
264
+ complete: boolean;
265
+ regex: string;
266
+ ast?: string;
267
+ agree: boolean;
268
+ }
269
+ export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
270
+ export declare function setShellAstMode(mode: ShellAstMode): void;
271
+ export declare function getShellAstMode(): ShellAstMode;
272
+ /** Shadow telemetry: the comparison recorded by the most recent top-level shell classification (consumed once). */
273
+ export declare function takeShellAstOperationComparison(): ShellAstOperationComparison | undefined;
237
274
  /**
238
275
  * Check a tool call against action policies.
239
276
  * Returns the most restrictive matching verdict (pure — callers do their own logging).
@@ -31,6 +31,7 @@ __export(actionPolicyEngine_exports, {
31
31
  detectSecretKind: () => detectSecretKind,
32
32
  effectiveApprovalScope: () => effectiveApprovalScope,
33
33
  evaluateActionPolicies: () => evaluateActionPolicies,
34
+ getShellAstMode: () => getShellAstMode,
34
35
  inferBundledToolContext: () => inferBundledToolContext,
35
36
  inferToolContext: () => inferToolContext,
36
37
  inventoryToolMatchesActionPolicy: () => inventoryToolMatchesActionPolicy,
@@ -40,6 +41,9 @@ __export(actionPolicyEngine_exports, {
40
41
  resolveApprovalOptions: () => resolveApprovalOptions,
41
42
  roleExceptionError: () => roleExceptionError,
42
43
  ruleNeedsContentFact: () => ruleNeedsContentFact,
44
+ setShellAstMode: () => setShellAstMode,
45
+ setShellAstProvider: () => setShellAstProvider,
46
+ takeShellAstOperationComparison: () => takeShellAstOperationComparison,
43
47
  toolCapabilities: () => toolCapabilities
44
48
  });
45
49
  module.exports = __toCommonJS(actionPolicyEngine_exports);
@@ -7726,7 +7730,17 @@ function shellCommandWords(command, depth = 0) {
7726
7730
  }
7727
7731
  return parts.join(" ; ");
7728
7732
  }
7729
- function isWorkspaceScopedRemoval(command) {
7733
+ function cdScopeOfSegment(segment) {
7734
+ const m = /^\s*(?:(?:then|do|else)\s+)?cd\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/i.exec(segment);
7735
+ if (!m) return void 0;
7736
+ const target = m[1].replace(/^["']|["']$/g, "").trim().replace(/\\/g, "/");
7737
+ const lower = target.toLowerCase();
7738
+ if (!lower) return "unscoped";
7739
+ if (/^(?:\/+\*?|~\/*\*?|\.{1,2}\/?|-|\$home\/?|\$\{home\}\/?|%userprofile%\/?|\$env:userprofile\/?|[a-z]:\/*\*?)$/.test(lower)) return "unscoped";
7740
+ if (/^(?:\$\{?[a-z_][a-z0-9_]*\}?|%[a-z_][a-z0-9_]*%|\$env:[a-z_][a-z0-9_]*)\/?$/.test(lower)) return "unscoped";
7741
+ return lower.split("/").some((part) => part && part !== "." && part !== "..") ? "scoped" : "unscoped";
7742
+ }
7743
+ function isWorkspaceScopedRemoval(command, cwdScoped = false) {
7730
7744
  const segments = splitShellSegments(command, true);
7731
7745
  const removal = segments.find((seg) => /\b(rm|del|remove-item|rmdir)\b/i.test(seg)) || command;
7732
7746
  const tokens = removal.trim().split(/\s+/).slice(1);
@@ -7734,7 +7748,8 @@ function isWorkspaceScopedRemoval(command) {
7734
7748
  if (targets.length === 0) return false;
7735
7749
  return targets.every((raw) => {
7736
7750
  const t = raw.replace(/^["']|["']$/g, "");
7737
- if (!t || t === "*" || t === "." || t === ".." || /^\.\/?\*$/.test(t) || /^\.\\\*$/.test(t)) return false;
7751
+ if (t === "*" || /^\.\/?\*$/.test(t) || /^\.\\\*$/.test(t)) return cwdScoped;
7752
+ if (!t || t === "." || t === "..") return false;
7738
7753
  if (/^[\/\\]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t)) return false;
7739
7754
  if (/\$|%|\$\{|\$\(|`/.test(t)) return false;
7740
7755
  if (/(^|[\/\\])\.\.([\/\\]|$)/.test(t)) return false;
@@ -7860,7 +7875,7 @@ function isInertValueExpression(raw) {
7860
7875
  return /^(?:""|-?\d)/.test(body);
7861
7876
  }
7862
7877
  var worstShellOp = (ops, floor = "read") => ops.reduce((w, op) => (SHELL_OP_RANK[op] || 0) > (SHELL_OP_RANK[w] || 0) ? op : w, floor);
7863
- function classifyShellSegment(segment, depth = 0) {
7878
+ function classifyShellSegment(segment, depth = 0, cwdScoped = false) {
7864
7879
  const raw = segment.trim();
7865
7880
  if (!raw) return "read";
7866
7881
  if (PS_SESSION_VARIABLE_ASSIGN_RE.test(raw)) return "SHELL";
@@ -7892,9 +7907,9 @@ function classifyShellSegment(segment, depth = 0) {
7892
7907
  }
7893
7908
  return worstShellOp([classifyShellWords(maskQuotedSpans(raw), raw, lead)], "SHELL");
7894
7909
  }
7895
- return classifyShellWords(maskQuotedSpans(raw), raw, lead);
7910
+ return classifyShellWords(maskQuotedSpans(raw), raw, lead, cwdScoped);
7896
7911
  }
7897
- function classifyShellWords(masked, raw, lead) {
7912
+ function classifyShellWords(masked, raw, lead, cwdScoped = false) {
7898
7913
  const value = masked.trim().toLowerCase();
7899
7914
  if (!value) return "read";
7900
7915
  const pipesIntoInterpreter = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:pwsh|powershell|python3?|node|perl|ruby)\b/.test(value);
@@ -7942,8 +7957,10 @@ function classifyShellWords(masked, raw, lead) {
7942
7957
  if (GCLOUD_WRITE_VERB_RE.test(gcloudVerb)) return pipesIntoInterpreter ? "SHELL" : "write";
7943
7958
  }
7944
7959
  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";
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)) {
7946
- return isWorkspaceScopedRemoval(segment) ? "write" : "DELETE";
7960
+ const rmFlags = /\brm\s+((?:--?[a-z][a-z-]*\s+)+)/.exec(value) || /\brm\s+((?:--?[a-z][a-z-]*\s+)+)/.exec(maskQuotedSpans(raw.replace(/["'](--?[A-Za-z][A-Za-z-]*)["']/g, "$1")).toLowerCase());
7961
+ const rmRecursiveForce = Boolean(rmFlags && /(?:^|\s)(?:-[a-z]*r[a-z]*|--recursive)\s/.test(rmFlags[1]) && /(?:^|\s)(?:-[a-z]*f[a-z]*|--force)\s/.test(rmFlags[1]));
7962
+ if (rmRecursiveForce || /\b(del\s+\/[fqs]|remove-item\b.*\s-recurse\b|rmdir\s+\/s)\b/.test(value)) {
7963
+ return isWorkspaceScopedRemoval(segment, cwdScoped) ? "write" : "DELETE";
7947
7964
  }
7948
7965
  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)) {
7949
7966
  return "DELETE";
@@ -8002,12 +8019,159 @@ function storageUriIsDestination(segment, index = 0, all = []) {
8002
8019
  return false;
8003
8020
  }
8004
8021
  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;
8022
+ var shellAstProvider;
8023
+ var shellAstMode = "shadow";
8024
+ var lastShellAstOperationComparison;
8025
+ function setShellAstProvider(provider) {
8026
+ shellAstProvider = provider;
8027
+ }
8028
+ function setShellAstMode(mode) {
8029
+ shellAstMode = mode;
8030
+ }
8031
+ function getShellAstMode() {
8032
+ return shellAstMode;
8033
+ }
8034
+ function takeShellAstOperationComparison() {
8035
+ const value = lastShellAstOperationComparison;
8036
+ lastShellAstOperationComparison = void 0;
8037
+ return value;
8038
+ }
8039
+ function shellAstFactsFullyComplete(facts) {
8040
+ if (!facts || !facts.complete) return false;
8041
+ for (const command of facts.commands) if (command.wrapper?.inner && !shellAstFactsFullyComplete(command.wrapper.inner)) return false;
8042
+ return true;
8043
+ }
8044
+ var AST_TRANSPARENT_WRAPPERS = /* @__PURE__ */ new Set(["sudo", "doas", "pkexec", "run0", "gosu", "runuser", "env", "nice", "nohup", "ionice", "chrt", "stdbuf", "timeout", "command", "builtin", "time", "exec"]);
8045
+ function astRemovalOperation(command, inContainer) {
8046
+ let argv = command.argv;
8047
+ let literal2 = command.literal;
8048
+ let name = command.name;
8049
+ for (let guard = 0; guard < 4 && AST_TRANSPARENT_WRAPPERS.has(name); guard++) {
8050
+ let i2 = 1;
8051
+ while (i2 < argv.length && (argv[i2].startsWith("-") && argv[i2] !== "-" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i2]))) {
8052
+ if (/^-(?:u|g|h|C|p|r|t|U|D)$/.test(argv[i2])) i2++;
8053
+ i2++;
8054
+ }
8055
+ if (i2 >= argv.length || !literal2[i2]) return void 0;
8056
+ argv = argv.slice(i2);
8057
+ literal2 = literal2.slice(i2);
8058
+ name = argv[0].replace(/^.*\//, "").replace(/\.exe$/i, "").toLowerCase();
8059
+ }
8060
+ if (name !== "rm") return void 0;
8061
+ let recursive = false;
8062
+ let force = false;
8063
+ const targets = [];
8064
+ let flagsDone = false;
8065
+ for (let i2 = 1; i2 < argv.length; i2++) {
8066
+ const word = argv[i2];
8067
+ if (!flagsDone && word === "--") {
8068
+ flagsDone = true;
8069
+ continue;
8070
+ }
8071
+ if (!flagsDone && /^--/.test(word)) {
8072
+ if (/^--recursive$/i.test(word)) recursive = true;
8073
+ if (/^--force$/i.test(word)) force = true;
8074
+ continue;
8075
+ }
8076
+ if (!flagsDone && /^-[A-Za-z]+$/.test(word)) {
8077
+ if (/[rR]/.test(word)) recursive = true;
8078
+ if (/f/.test(word)) force = true;
8079
+ continue;
8080
+ }
8081
+ targets.push(word);
8082
+ }
8083
+ if (!recursive || !force) return void 0;
8084
+ if (targets.length === 0) return "write";
8085
+ const scoped = targets.every((raw) => {
8086
+ const t = raw.trim().replace(/\\/g, "/");
8087
+ if (/^(?:\*|\.\/+\*)$/.test(t)) return inContainer || command.cwdScope === "scoped";
8088
+ if (!t || t === "." || t === "..") return false;
8089
+ if (/^[\/]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t)) return false;
8090
+ if (/\$|%|`/.test(t)) return false;
8091
+ if (/(^|\/)\.\.(\/|$)/.test(t)) return false;
8092
+ return true;
8093
+ });
8094
+ return scoped ? "write" : "DELETE";
8095
+ }
8096
+ function classifyShellFromAst(facts, depth, inContainer = false) {
8097
+ const ops = [];
8098
+ let pipeline = 0;
8099
+ let pipelineText = [];
8100
+ let pipelineScoped = false;
8101
+ let pipelineFloor = "read";
8102
+ const flush = () => {
8103
+ if (pipelineText.length) ops.push(worstShellOp([classifyShellSegment(pipelineText.join(" | "), depth, pipelineScoped)], pipelineFloor));
8104
+ pipelineText = [];
8105
+ pipeline = 0;
8106
+ pipelineFloor = "read";
8107
+ };
8108
+ const floorFor = (command) => command.depth && (command.depth.loop > 0 || command.depth.function > 0) ? "SHELL" : "read";
8109
+ for (const command of facts.commands) {
8110
+ if (command.wrapper?.inner) {
8111
+ const inner = classifyShellFromAst(command.wrapper.inner, depth + 1, inContainer || command.wrapper.kind === "container");
8112
+ const rank = SHELL_OP_RANK[inner] || 0;
8113
+ ops.push(command.wrapper.kind === "container" && rank > 1 && rank < 4 ? "SHELL" : inner);
8114
+ }
8115
+ const removal = astRemovalOperation(command, inContainer);
8116
+ if (removal) {
8117
+ flush();
8118
+ ops.push(worstShellOp([removal], floorFor(command)));
8119
+ continue;
8120
+ }
8121
+ if (command.pipeline && command.pipeline === pipeline) {
8122
+ pipelineText.push(command.text);
8123
+ continue;
8124
+ }
8125
+ flush();
8126
+ if (command.pipeline) {
8127
+ pipeline = command.pipeline;
8128
+ pipelineText = [command.text];
8129
+ pipelineScoped = command.cwdScope === "scoped";
8130
+ pipelineFloor = floorFor(command);
8131
+ continue;
8132
+ }
8133
+ ops.push(worstShellOp([classifyShellSegment(command.text, depth, command.cwdScope === "scoped")], floorFor(command)));
8134
+ }
8135
+ flush();
8136
+ return worstShellOp(ops, "read");
8137
+ }
8005
8138
  function classifyShellCommandOperation(command, depth = 0) {
8006
8139
  if (provenPowerShellReadSequence(command)) return "read";
8007
8140
  if (shellUrlActionText(command) !== command) return "write";
8008
8141
  const segments = splitShellSegments(command);
8009
8142
  if (segments.length === 0) return "SHELL";
8010
- return worstShellOp(segments.map((segment) => classifyShellSegment(segment, depth)), "read");
8143
+ let cwdScoped = false;
8144
+ const ops = segments.map((segment) => {
8145
+ const op = classifyShellSegment(segment, depth, cwdScoped);
8146
+ const cd = cdScopeOfSegment(segment);
8147
+ if (cd) cwdScoped = cd === "scoped";
8148
+ return op;
8149
+ });
8150
+ return worstShellOp(ops, "read");
8151
+ }
8152
+ function reconcileShellOperation(command, regexOp) {
8153
+ if (shellAstMode === "off" || !shellAstProvider) return regexOp;
8154
+ let facts;
8155
+ try {
8156
+ facts = shellAstProvider(command);
8157
+ } catch {
8158
+ facts = void 0;
8159
+ }
8160
+ const complete = shellAstFactsFullyComplete(facts);
8161
+ let astOp;
8162
+ if (complete && facts) {
8163
+ astOp = provenPowerShellReadSequence(command) ? "read" : shellUrlActionText(command) !== command ? "write" : classifyShellFromAst(facts, 0);
8164
+ }
8165
+ lastShellAstOperationComparison = { mode: shellAstMode, complete, regex: regexOp, ast: astOp, agree: !complete || astOp === regexOp };
8166
+ return shellAstMode === "primary" && complete && astOp ? astOp : regexOp;
8167
+ }
8168
+ function classifyShellCommandForTool(command) {
8169
+ return reconcileShellOperation(command, classifyShellCommandOperation(command));
8170
+ }
8171
+ function shellCommandTextOf(toolName, args) {
8172
+ if (!CODE_EXEC_TOOL_NAME_RE.test(toolName.toLowerCase().replace(/[_\-.]/g, " "))) return void 0;
8173
+ const text = String(args.command || args.cmd || args.code || args.script || args.input || "");
8174
+ return text || void 0;
8011
8175
  }
8012
8176
  function isPowerShellReadFormatter(segment) {
8013
8177
  if (/^(?:Select-Object\s+[\w., *-]+|Format-Table(?:\s+[\w., *-]+)?|Format-List(?:\s+[\w., *-]+)?|Out-Null|ConvertFrom-Json)$/i.test(segment)) return true;
@@ -8378,6 +8542,8 @@ function inferToolContext(toolName, args) {
8378
8542
  }
8379
8543
  const updated = (0, import_detectorRuntime.updatedDetectorFacts)(toolName, detectorArgs);
8380
8544
  if (!updated) return inferBundledToolContext(toolName, args);
8545
+ const shellText = shellCommandTextOf(toolName, args);
8546
+ const operation = shellText && updated.operation !== toolName ? reconcileShellOperation(shellText, updated.operation) : updated.operation;
8381
8547
  const context = { ...updated.context };
8382
8548
  for (const key of RESERVED_INPUT_FACTS) delete context[key];
8383
8549
  const argsText = stringifyArgs(args);
@@ -8391,7 +8557,7 @@ function inferToolContext(toolName, args) {
8391
8557
  context["destination.type"] = context["request.destination.type"];
8392
8558
  context["destination.domain"] = context["request.destination.domain"];
8393
8559
  }
8394
- return { operation: updated.operation, context };
8560
+ return { operation, context };
8395
8561
  }
8396
8562
  function inferBundledToolContext(toolName, args) {
8397
8563
  const context = {};
@@ -8432,7 +8598,7 @@ function inferBundledToolContext(toolName, args) {
8432
8598
  }
8433
8599
  }
8434
8600
  if (operation === toolName && CODE_EXEC_TOOL_NAME_RE.test(nameWords)) {
8435
- operation = classifyShellCommandOperation(commandText);
8601
+ operation = classifyShellCommandForTool(commandText);
8436
8602
  }
8437
8603
  const pathArg = args.path || args.filepath || args.file || args.filename || "";
8438
8604
  if (typeof pathArg === "string" && pathArg.length > 0) {
@@ -8610,6 +8776,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8610
8776
  detectSecretKind,
8611
8777
  effectiveApprovalScope,
8612
8778
  evaluateActionPolicies,
8779
+ getShellAstMode,
8613
8780
  inferBundledToolContext,
8614
8781
  inferToolContext,
8615
8782
  inventoryToolMatchesActionPolicy,
@@ -8619,5 +8786,8 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8619
8786
  resolveApprovalOptions,
8620
8787
  roleExceptionError,
8621
8788
  ruleNeedsContentFact,
8789
+ setShellAstMode,
8790
+ setShellAstProvider,
8791
+ takeShellAstOperationComparison,
8622
8792
  toolCapabilities
8623
8793
  });
@@ -106,6 +106,38 @@ export interface SharedCommandRule {
106
106
  * see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
107
107
  */
108
108
  export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
109
+ export type ShellAstMode = 'off' | 'shadow' | 'primary';
110
+ /** Structural subset of shellAst.ts ShellFacts that the guard reads. */
111
+ export interface ShellAstFactsLike {
112
+ complete: boolean;
113
+ incompleteReason?: string;
114
+ dynamicExecution?: boolean;
115
+ commands: Array<{
116
+ name: string;
117
+ argv: string[];
118
+ literal: boolean[];
119
+ cwdScope: 'scoped' | 'unscoped' | 'unknown';
120
+ wrapper?: {
121
+ kind: string;
122
+ program: string;
123
+ inner?: ShellAstFactsLike;
124
+ };
125
+ }>;
126
+ }
127
+ export interface ShellAstComparison {
128
+ mode: ShellAstMode;
129
+ complete: boolean;
130
+ /** Destructive-rm item id the regex floor produced (undefined = clean). */
131
+ regex?: string;
132
+ /** Destructive-rm item id the AST produced (undefined = clean). */
133
+ ast?: string;
134
+ agree: boolean;
135
+ }
136
+ export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
137
+ export declare function setShellAstMode(mode: ShellAstMode): void;
138
+ export declare function getShellAstMode(): ShellAstMode;
139
+ /** Shadow telemetry: the comparison recorded by the most recent shell scan (consumed once). */
140
+ export declare function takeShellAstComparison(): ShellAstComparison | undefined;
109
141
  /**
110
142
  * Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
111
143
  * space. Detectors that ask "does this EXECUTE" must look here; the raw line
@@ -35,6 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
37
37
  exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
38
+ exports.setShellAstProvider = setShellAstProvider;
39
+ exports.setShellAstMode = setShellAstMode;
40
+ exports.getShellAstMode = getShellAstMode;
41
+ exports.takeShellAstComparison = takeShellAstComparison;
38
42
  exports.unquotedCode = unquotedCode;
39
43
  exports.stripInertDataSegments = stripInertDataSegments;
40
44
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
@@ -722,25 +726,211 @@ function credentialCommandReason(value, options) {
722
726
  // stall. Root targets (`/`, `/*`) stay blocked even inside containers (the
723
727
  // Patria case: an agent nuking a running container's filesystem).
724
728
  const CONTAINER_EXEC_BEFORE = /\b(?:docker|podman|nerdctl|kubectl)(?:\.exe)?\s+(?:compose\s+)?(?:exec|run)\b[^;&|]*$/i;
725
- function destructiveCommandReason(value) {
729
+ let shellAstProvider;
730
+ let shellAstMode = 'shadow';
731
+ let lastShellAstComparison;
732
+ function setShellAstProvider(provider) {
733
+ shellAstProvider = provider;
734
+ }
735
+ function setShellAstMode(mode) { shellAstMode = mode; }
736
+ function getShellAstMode() { return shellAstMode; }
737
+ /** Shadow telemetry: the comparison recorded by the most recent shell scan (consumed once). */
738
+ function takeShellAstComparison() {
739
+ const value = lastShellAstComparison;
740
+ lastShellAstComparison = undefined;
741
+ return value;
742
+ }
743
+ function shellAstFactsFullyComplete(facts) {
744
+ if (!facts || !facts.complete)
745
+ return false;
746
+ for (const command of facts.commands)
747
+ if (command.wrapper?.inner && !shellAstFactsFullyComplete(command.wrapper.inner))
748
+ return false;
749
+ return true;
750
+ }
751
+ const AST_TRANSPARENT_WRAPPERS = new Set(['sudo', 'doas', 'pkexec', 'run0', 'gosu', 'runuser', 'env', 'nice', 'nohup', 'ionice', 'chrt', 'stdbuf', 'timeout', 'command', 'builtin', 'time', 'exec']);
752
+ /**
753
+ * The destructive-rm decision from structure: which program really runs
754
+ * (through `sudo` / `env` / `time` / `exec`), which words are its flags after
755
+ * quote resolution (`rm "-rf" /` ≡ `rm -rf /`, `-r -f` ≡ `-rf`, `--recursive
756
+ * --force`), and what each target is once the cwd scope is known. Inner scripts
757
+ * of `bash -c` / `docker run … sh -c` are included; inside a container a bare
758
+ * `*` is the container workdir (cleanup), the root `/` is still the root.
759
+ */
760
+ function astDestructiveRm(facts, inContainer = false) {
761
+ for (const command of facts.commands) {
762
+ if (command.wrapper?.inner) {
763
+ const hit = astDestructiveRm(command.wrapper.inner, inContainer || command.wrapper.kind === 'container');
764
+ if (hit)
765
+ return hit;
766
+ }
767
+ let argv = command.argv;
768
+ let literal = command.literal;
769
+ let name = command.name;
770
+ // Unwrap transparent wrappers: `sudo -u root rm -rf /`, `env X=1 rm -rf /`.
771
+ for (let guard = 0; guard < 4 && AST_TRANSPARENT_WRAPPERS.has(name); guard++) {
772
+ let i = 1;
773
+ while (i < argv.length && ((argv[i].startsWith('-') && argv[i] !== '-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]))) {
774
+ // `sudo -u root`: the option value follows on the next word.
775
+ if (/^-(?:u|g|h|C|p|r|t|U|D)$/.test(argv[i]))
776
+ i++;
777
+ i++;
778
+ }
779
+ if (i >= argv.length || !literal[i]) {
780
+ name = '';
781
+ break;
782
+ }
783
+ argv = argv.slice(i);
784
+ literal = literal.slice(i);
785
+ name = argv[0].replace(/^.*\//, '').replace(/\.exe$/i, '').toLowerCase();
786
+ }
787
+ if (name !== 'rm')
788
+ continue;
789
+ let recursive = false;
790
+ let force = false;
791
+ const targets = [];
792
+ let flagsDone = false;
793
+ for (let i = 1; i < argv.length; i++) {
794
+ const word = argv[i];
795
+ if (!flagsDone && word === '--') {
796
+ flagsDone = true;
797
+ continue;
798
+ }
799
+ if (!flagsDone && /^--/.test(word)) {
800
+ if (/^--recursive$/i.test(word))
801
+ recursive = true;
802
+ if (/^--force$/i.test(word))
803
+ force = true;
804
+ continue;
805
+ }
806
+ if (!flagsDone && /^-[A-Za-z]+$/.test(word)) {
807
+ if (/[rR]/.test(word))
808
+ recursive = true;
809
+ if (/f/.test(word))
810
+ force = true;
811
+ continue;
812
+ }
813
+ targets.push({ value: word, literal: literal[i] });
814
+ }
815
+ if (!recursive || !force)
816
+ continue;
817
+ for (const target of targets) {
818
+ const t = target.value.trim().replace(/\\/g, '/');
819
+ const lower = t.toLowerCase();
820
+ if (/^\/+\*?$/.test(t))
821
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
822
+ if (/^(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/+\*?)?$/.test(lower))
823
+ return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
824
+ if (/^(?:\*|\.\/+\*)$/.test(t)) {
825
+ if (inContainer || command.cwdScope === 'scoped')
826
+ continue;
827
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of the current directory tree (rm -rf *)' };
828
+ }
829
+ }
830
+ }
831
+ return undefined;
832
+ }
833
+ const RM_SCOPE_ITEMS = new Set(['rm_rf_root', 'rm_rf_home']);
834
+ /**
835
+ * Destructive-command decision with the AST in the loop. Returns the regex
836
+ * verdict in 'off'/'shadow' (recording the comparison in shadow), and in
837
+ * 'primary' lets a fully complete AST decide the two rm-scope items — clearing
838
+ * a regex false positive or adding a structurally proven hit the regex missed —
839
+ * while every other destructive rule keeps its regex verdict.
840
+ */
841
+ function destructiveCommandDecision(value) {
842
+ const regexHit = destructiveCommandReason(value);
843
+ if (shellAstMode === 'off' || !shellAstProvider)
844
+ return regexHit;
845
+ let facts;
846
+ try {
847
+ facts = shellAstProvider(value);
848
+ }
849
+ catch {
850
+ facts = undefined;
851
+ }
852
+ const complete = shellAstFactsFullyComplete(facts);
853
+ const astHit = complete && facts ? astDestructiveRm(facts) : undefined;
854
+ const regexRm = regexHit && RM_SCOPE_ITEMS.has(regexHit.itemId) ? regexHit.itemId : undefined;
855
+ lastShellAstComparison = { mode: shellAstMode, complete, regex: regexRm, ast: astHit?.itemId, agree: !complete || regexRm === astHit?.itemId };
856
+ if (shellAstMode !== 'primary' || !complete)
857
+ return regexHit;
858
+ if (astHit)
859
+ return astHit;
860
+ if (regexRm) {
861
+ // The AST cleared the rm-scope hit; other destructive rules still apply.
862
+ return destructiveCommandReason(value, { skipRmScope: true });
863
+ }
864
+ return regexHit;
865
+ }
866
+ // Every `cd <target>` statement start (cwd persists across `&&`, `;`, `|`,
867
+ // newlines and `then`/`do`); the caller keeps the LAST one before the `rm`.
868
+ const CD_STATEMENT = /(?:^|[;&|\n(]|\bthen\b|\bdo\b)\s*cd\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi;
869
+ /**
870
+ * Bare `rm -rf *` wipes whatever the CURRENT directory is. When the same
871
+ * script has already `cd`'d into a scoped directory — a literal subpath such
872
+ * as `x`, `./build`, `"$SP/chk"`, `/tmp/work` — the star is that directory's
873
+ * contents: everyday scratch cleanup (`mkdir -p x && cd x && rm -rf * && unzip …`
874
+ * on a bank workstation, 2026-09-24). It stays destructive when the cd target
875
+ * is a filesystem root, a home root, a drive root, `..`/`-`/`.`, or a BARE
876
+ * variable (`cd "$X"` with X unset lands in $HOME). No cd at all keeps the
877
+ * conservative reading: the agent's cwd is usually the project.
878
+ */
879
+ function cdScopedBeforeBareStar(before) {
880
+ let last = null;
881
+ CD_STATEMENT.lastIndex = 0;
882
+ for (let m = CD_STATEMENT.exec(before); m; m = CD_STATEMENT.exec(before))
883
+ last = m;
884
+ if (!last)
885
+ return false;
886
+ // A `)` after the cd closes a subshell: `(cd x && …); rm -rf *` runs the rm
887
+ // back in the ORIGINAL directory, which the cd never changed.
888
+ if (before.slice(last.index + last[0].length).includes(')'))
889
+ return false;
890
+ const target = last[1].replace(/^["']|["']$/g, '').trim().replace(/\\/g, '/');
891
+ if (!target)
892
+ return false;
893
+ const lower = target.toLowerCase();
894
+ if (/^(?:\/+\*?|~\/*\*?|\.{1,2}\/?|-|\$home\/?|\$\{home\}\/?|%userprofile%\/?|\$env:userprofile\/?|[a-z]:\/*\*?)$/.test(lower))
895
+ return false;
896
+ if (/^(?:\$\{?[a-z_][a-z0-9_]*\}?|%[a-z_][a-z0-9_]*%|\$env:[a-z_][a-z0-9_]*)\/?$/.test(lower))
897
+ return false;
898
+ // At least one real path component that is not `.`/`..`.
899
+ return lower.split('/').some(part => part && part !== '.' && part !== '..');
900
+ }
901
+ function destructiveCommandReason(value, opts = {}) {
726
902
  return matchCommand(value, (text, lower) => {
727
- // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
728
- // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
729
- // outer command — is caught too. Path-aware: only the root `/`, the root
730
- // wildcard `/*`, and a bare `*` are treated as destructive — scoped paths
731
- // (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
732
- if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
733
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
734
- // Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
735
- // A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
736
- if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
737
- return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
903
+ if (!opts.skipRmScope) {
904
+ // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
905
+ // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
906
+ // outer command — is caught too. Path-aware: only the root `/`, the root
907
+ // wildcard `/*`, and a bare `*` are treated as destructive — scoped paths
908
+ // (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
909
+ if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
910
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
911
+ // Split or long flags: `rm -r -f /`, `rm -f -r /`, `rm --recursive --force /`
912
+ // (the structural parser saw these first — 2026-09-24; the floor must too).
913
+ const splitFlags = /\b(?:sudo\s+)?rm\s+((?:-(?:[a-z]+|-recursive|-force|-no-preserve-root)\s+){2,})["']?\/\*?["']?(?:\s|$|[;&|)])/.exec(lower);
914
+ if (splitFlags && /(?:^|\s)-(?:[a-z]*r|-recursive)/.test(splitFlags[1]) && /(?:^|\s)-(?:[a-z]*f|-force)/.test(splitFlags[1]))
915
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
916
+ // Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
917
+ // A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
918
+ if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
919
+ return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
920
+ }
921
+ // Bare `*` deletes the current directory tree — destructive on the HOST,
922
+ // but inside a container exec/run it is the container workdir (cleanup),
923
+ // and after an explicit `cd <subdir>` it is that scratch folder's contents.
924
+ // EVERY bare-star rm on the line is judged: `(cd x && rm -rf *); rm -rf *`
925
+ // has one scoped and one unscoped removal, and the unscoped one wipes.
926
+ const bareStars = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/g;
927
+ for (let bareStar = bareStars.exec(lower); bareStar; bareStar = bareStars.exec(lower)) {
928
+ const before = lower.slice(0, bareStar.index);
929
+ if (!CONTAINER_EXEC_BEFORE.test(before) && !cdScopedBeforeBareStar(before)) {
930
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of the current directory tree (rm -rf *)' };
931
+ }
932
+ }
738
933
  }
739
- // Bare `*` deletes the current directory tree — destructive on the HOST,
740
- // but inside a container exec/run it is the container workdir (cleanup).
741
- const bareStar = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/.exec(lower);
742
- if (bareStar && !CONTAINER_EXEC_BEFORE.test(lower.slice(0, bareStar.index)))
743
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
744
934
  // Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
745
935
  // is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
746
936
  // handles `"C:\"`. rd/rmdir are the same wipe under a different verb — the
@@ -1353,7 +1543,7 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1353
1543
  if (finding)
1354
1544
  return finding;
1355
1545
  }
1356
- const destructive = destructiveCommandReason(value);
1546
+ const destructive = destructiveCommandDecision(value);
1357
1547
  if (destructive) {
1358
1548
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
1359
1549
  }