fullcourtdefense-cli 1.35.7 → 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.
- package/dist/actionPolicyEngine.d.ts +37 -0
- package/dist/actionPolicyEngine.js +180 -10
- package/dist/commands/deterministicGuard.d.ts +32 -0
- package/dist/commands/deterministicGuard.js +208 -18
- package/dist/commands/hook.js +17 -1
- package/dist/commands/mcpGateway.js +2 -0
- package/dist/commands/workloadProtect.js +6 -2
- package/dist/shellAst.d.ts +117 -0
- package/dist/shellAst.js +590 -0
- package/dist/shellAstLoader.d.ts +32 -0
- package/dist/shellAstLoader.js +271 -0
- package/dist/telemetry.d.ts +18 -0
- package/dist/telemetry.js +1 -0
- package/dist/vendor/tree-sitter/LICENSE.tree-sitter-bash +21 -0
- package/dist/vendor/tree-sitter/LICENSE.web-tree-sitter +21 -0
- package/dist/vendor/tree-sitter/VERSIONS.json +4 -0
- package/dist/vendor/tree-sitter/tree-sitter-bash.wasm +0 -0
- package/dist/vendor/tree-sitter/tree-sitter.cjs +4031 -0
- package/dist/vendor/tree-sitter/tree-sitter.wasm +0 -0
- package/dist/version.json +1 -1
- package/package.json +7 -4
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
7946
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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 =
|
|
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
|
}
|