javi-forge 1.28.1 → 1.29.0

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.
@@ -0,0 +1,828 @@
1
+ // javi-forge-managed: claude-pretooluse v1
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ // host fail-open residual: spawn, parse/start, external termination, and timeout
7
+ // failures before this guarded main path continue through Claude's permissions.
8
+ export const MANAGED_MARKER = "// javi-forge-managed: claude-pretooluse v1";
9
+ export const INPUT_LIMIT_BYTES = 1_048_576;
10
+ export const SUPPORTED_TOOLS = Object.freeze(["Bash", "PowerShell", "Read", "Write", "Edit"]);
11
+ export const POLICY_REGISTRY = Object.freeze({ schemaVersion: 1, policyVersion: 1, diagnosticsMaxBytes: 240 });
12
+ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
13
+ const POSIX_ABSOLUTE = /^\//;
14
+ const WINDOWS_DRIVE = /^[a-zA-Z]:[\\/]/;
15
+ const WINDOWS_UNC = /^\\\\[^\\]+\\[^\\]+/;
16
+ function fail(reason) { throw new Error(reason); }
17
+ function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
18
+ function normalizeWindowsAlias(input) {
19
+ if (/^\\\\\?\\GLOBALROOT\\/i.test(input) || /^\\\\\.\\(?![a-z]:\\)/i.test(input)) fail("unsupported-device-path");
20
+ if (/^\\\\\?\\UNC\\/i.test(input)) return `\\\\${input.slice(8)}`;
21
+ if (/^\\\\\?\\[a-z]:\\/i.test(input)) return input.slice(4);
22
+ if (/^\\\?\?\\[a-z]:\\/i.test(input)) return input.slice(4);
23
+ if (/^\\\\\.\\[a-z]:\\/i.test(input)) return input.slice(4);
24
+ if (/^\\\\\?\\/i.test(input) || /^\\\?\?\\/i.test(input)) fail("unsupported-device-path");
25
+ return input;
26
+ }
27
+ function lexicalNormalize(input, platform) {
28
+ const windows = platform === "win32" || WINDOWS_DRIVE.test(input) || WINDOWS_UNC.test(input) || /^\\(?:\\\?|\?\?|\\\.)\\/i.test(input);
29
+ let value = windows ? normalizeWindowsAlias(input) : input;
30
+ value = value.replaceAll("\\", "/");
31
+ const root = value.startsWith("//")
32
+ ? `//${value.split("/").filter(Boolean).slice(0, 2).join("/")}`
33
+ : /^[a-zA-Z]:\//.test(value)
34
+ ? value.slice(0, 3)
35
+ : "/";
36
+ const body = value.slice(root.length);
37
+ const parts = [];
38
+ for (const part of body.split("/")) {
39
+ if (!part || part === ".") continue;
40
+ if (part === "..") parts.pop();
41
+ else parts.push(part);
42
+ }
43
+ value = `${root}${root.endsWith("/") || parts.length === 0 ? "" : "/"}${parts.join("/")}`;
44
+ if (windows || platform === "darwin") value = value.normalize("NFC").toLowerCase();
45
+ return value;
46
+ }
47
+ function nativeRealpath(input) {
48
+ let candidate = input;
49
+ const suffix = [];
50
+ while (!fs.existsSync(candidate)) {
51
+ const parent = path.dirname(candidate);
52
+ if (parent === candidate) return input;
53
+ suffix.unshift(path.basename(candidate));
54
+ candidate = parent;
55
+ }
56
+ try {
57
+ return path.join(fs.realpathSync.native(candidate), ...suffix);
58
+ } catch {
59
+ fail("path-resolution-failed");
60
+ }
61
+ }
62
+ export function canonicalizePolicyPath(input, options = {}) {
63
+ if (typeof input !== "string" || input.includes("\0")) fail("invalid-event");
64
+ const platform = options.platform ?? process.platform;
65
+ let expanded = input;
66
+ if (options.base) {
67
+ expanded = expanded.replace(/^\$\{CLAUDE_PROJECT_DIR\}|^\$CLAUDE_PROJECT_DIR/, options.projectRoot ?? PROJECT_ROOT);
68
+ expanded = expanded.replace(/^~(?=[\\/]|$)|^\$HOME(?=[\\/]|$)/, os.homedir());
69
+ if (!POSIX_ABSOLUTE.test(expanded) && !WINDOWS_DRIVE.test(expanded) && !WINDOWS_UNC.test(expanded)) {
70
+ expanded = path.resolve(options.base, expanded);
71
+ }
72
+ }
73
+ // Strip Windows device aliases (\??\, \\?\, \\.\) BEFORE native realpath:
74
+ // on a real win32 host nativeRealpath resolves an unstripped \??\ against the
75
+ // current drive (D:\??\C:\...), so the alias must be canonicalized first or a
76
+ // \??\-prefixed path to a secret would evade sensitive/managed detection.
77
+ const windowsInput = platform === "win32" || WINDOWS_DRIVE.test(expanded) || WINDOWS_UNC.test(expanded) || /^\\(?:\\\?|\?\?|\\\.)\\/i.test(expanded);
78
+ if (windowsInput) expanded = normalizeWindowsAlias(expanded);
79
+ const native = platform === process.platform && path.isAbsolute(expanded) ? nativeRealpath(expanded) : expanded;
80
+ return lexicalNormalize(native, platform);
81
+ }
82
+ function policyPathKeys(input, options = {}) {
83
+ const platform = options.platform ?? process.platform;
84
+ const lexical = lexicalNormalize(input, platform);
85
+ if (platform !== process.platform || !path.isAbsolute(input)) return [lexical];
86
+ const real = canonicalizePolicyPath(input, options);
87
+ return real === lexical ? [lexical] : [lexical, real];
88
+ }
89
+ function isAbsolutePolicyPath(value) { return POSIX_ABSOLUTE.test(value) || WINDOWS_DRIVE.test(value) || WINDOWS_UNC.test(value) || /^\\(?:\\\?|\?\?|\\\.)\\/i.test(value); }
90
+ export function isSensitivePolicyKey(key, platform = process.platform) {
91
+ const parts = key.split("/").filter(Boolean);
92
+ const basename = parts.at(-1) ?? "";
93
+ if (/^\.env(?:\..+)?$/i.test(basename) && !/^\.env\.(?:example|sample|template)$/i.test(basename)) return true;
94
+ if ([".npmrc", ".pypirc", ".netrc", ".git-credentials"].includes(basename.toLowerCase())) return true;
95
+ if (parts.some((part) => part === ".ssh" || part === ".gnupg")) return true;
96
+ if (key.endsWith("/.aws/credentials") || key.endsWith("/.kube/config") || key.endsWith("/.config/gcloud/application_default_credentials.json")) return true;
97
+ return platform === "win32" || platform === "darwin" ? basename.toLowerCase() === "serviceaccountkey.json" : basename === "serviceAccountKey.json";
98
+ }
99
+ function isManaged(key) {
100
+ const project = canonicalizePolicyPath(PROJECT_ROOT);
101
+ if (!key.startsWith(`${project}/`) && key !== project) return false;
102
+ const relative = key.slice(project.length + 1);
103
+ // On case-insensitive platforms lexicalNormalize folds the key to lowercase,
104
+ // so the mixed-case CLAUDE.md literals must be matched case-insensitively too
105
+ // (the other literals are already lowercase). Otherwise CLAUDE.md and
106
+ // .claude/CLAUDE.md lose managed-config protection on macOS/Windows.
107
+ const foldedClaudeMd = (process.platform === "win32" || process.platform === "darwin") && (relative === "claude.md" || relative === ".claude/claude.md");
108
+ return foldedClaudeMd || relative === ".claude/settings.json" || relative === ".claude/settings.local.json" || relative === ".claude/CLAUDE.md" || relative === "CLAUDE.md" || relative === ".javi-forge/ci.yaml" || relative.startsWith(".claude/hooks/") || relative.startsWith(".claude/agents/") || relative.startsWith(".claude/skills/");
109
+ }
110
+ function evaluateFile(toolName, filePath) {
111
+ const keys = policyPathKeys(filePath);
112
+ if (keys.some((key) => isSensitivePolicyKey(key))) return { allowed: false, ruleId: "path.sensitive" };
113
+ if (toolName !== "Read" && keys.some(isManaged)) return { allowed: false, ruleId: "path.managed-config" };
114
+ return { allowed: true };
115
+ }
116
+ function lex(command, powershell = false) {
117
+ const commands = [[]];
118
+ const separators = [];
119
+ let token = "", quote = "", escaped = false;
120
+ const pushToken = () => { if (token) commands.at(-1).push(token); token = ""; };
121
+ const split = (separator) => { pushToken(); if (commands.at(-1).length) { separators.push(separator); commands.push([]); } };
122
+ for (let index = 0; index < command.length; index++) {
123
+ const char = command[index];
124
+ if (escaped) { token += char; escaped = false; continue; }
125
+ if (char === "\\" && quote !== "'" && !powershell) { if (quote === '"' && !/[$`"\\\n]/.test(command[index + 1] ?? "")) token += "\\"; else escaped = true; continue; }
126
+ if (char === "`" && quote !== "'" && powershell) { escaped = true; continue; }
127
+ if (quote) { if (char === quote) quote = ""; else token += char; continue; }
128
+ if (char === "'" || char === '"') { quote = char; continue; }
129
+ if (char === "|" && command[index + 1] === "|") { split("||"); index++; continue; }
130
+ if (char === "|" && command[index + 1] === "&") { split("|"); index++; continue; } // bash `|&` pipes stdout+stderr; a real pipe for policy
131
+ if (char === "&" && command[index + 1] === "&") { split("&&"); index++; continue; }
132
+ if (char === "|" || char === ";" || char === "\n" || char === "\r") { split(char === "|" ? "|" : ";"); continue; }
133
+ if (char === ">" || char === "<") { pushToken(); commands.at(-1).push(char); continue; }
134
+ if (/\s/.test(char)) { pushToken(); continue; }
135
+ token += char;
136
+ }
137
+ if (quote || escaped) fail("unlexable-command");
138
+ pushToken();
139
+ if (!commands.at(-1).length) commands.pop();
140
+ return { commands, separators };
141
+ }
142
+ // The pre-redesign boolean helpers (parseEnvSplit/hasChmodRecursive/hasBase64Decode
143
+ // and their isLongPrefix/splitEnvString internals) were replaced by the semantic
144
+ // state machines below; only ENV_ESCAPES survives, shared with splitEnvSemantics.
145
+ const ENV_ESCAPES = Object.freeze({ f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", $: "$", _: " ", '"': '"', "'": "'", "\\": "\\" });
146
+ // =====================================================================
147
+ // Utility profile registry + shared semantic primitives (tasks 2.1-2.2)
148
+ //
149
+ // Fixed, host-independent GNU Coreutils 9.4 and Apple dated-snapshot
150
+ // profile bindings plus the shared primitives the per-profile state
151
+ // machines (tasks 2.3-2.5) consume: literal identity normalization,
152
+ // exact/unique-prefix long-option matching, the consumed-argument
153
+ // recorder, and the danger-dominant profile-union reducer.
154
+ //
155
+ // WU2-B (tasks 2.3-2.6) adds the three bounded state machines, the env
156
+ // split-string character machine with its cumulative split-work bound,
157
+ // and the protected-sink adapter wired into evaluateBash. Identity
158
+ // rejection evidence (`non-literal-identity`, `unsupported-utility`) and
159
+ // the machine-produced evidence codes are pinned by the semantic corpus.
160
+ // =====================================================================
161
+ const OVERALL_CLASS = Object.freeze({ SAFE: "safe", DANGEROUS: "dangerous", AMBIGUOUS: "ambiguous" });
162
+ const PROFILE_STATUS = Object.freeze({ ACCEPTED_SAFE: "accepted-safe", ACCEPTED_DANGEROUS: "accepted-dangerous", REJECTED: "rejected-by-profile", UNSUPPORTED: "unsupported" });
163
+ const UTILITY = Object.freeze({ ENV: "env", CHMOD: "chmod", BASE64: "base64", UNSUPPORTED: "unsupported" });
164
+ const SINK = Object.freeze({ WRAPPER: "wrapper-extraction", CRITICAL_CHMOD: "critical-chmod", BASE64_SHELL: "base64-to-shell" });
165
+ function deepFreeze(value) {
166
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
167
+ Object.freeze(value);
168
+ for (const key of Object.getOwnPropertyNames(value)) deepFreeze(value[key]);
169
+ }
170
+ return value;
171
+ }
172
+ const GNU_COREUTILS_9_4_SOURCE = { publisher: "GNU", artifact: "GNU Coreutils manual (doc/coreutils.texi)", version: "Coreutils 9.4", sourceReference: "GNU Coreutils 9.4 release tarball / gnu.org Coreutils manual, 9.4 node set" };
173
+ const APPLE_CHMOD_SOURCE = { publisher: "Apple", artifact: "chmod(1)", version: "2017-01-07", sourceReference: "Apple public man page (xcode-man-pages mirror), chmod.1, dated January 7, 2017" };
174
+ const APPLE_BINTRANS_SOURCE = { publisher: "Apple", artifact: "bintrans(1)", version: "2022-04-18", sourceReference: "Apple public man page (xcode-man-pages mirror), bintrans.1, dated April 18, 2022" };
175
+ const GNU_CHMOD_TABLE = {
176
+ longOptions: [
177
+ { name: "changes", type: "flag" }, { name: "help", type: "flag" }, { name: "no-preserve-root", type: "flag" },
178
+ { name: "preserve-root", type: "flag" }, { name: "quiet", type: "flag" }, { name: "recursive", type: "flag" },
179
+ { name: "reference", type: "arg" }, { name: "silent", type: "flag" }, { name: "verbose", type: "flag" }, { name: "version", type: "flag" },
180
+ ],
181
+ shortOptions: { R: { type: "flag" }, c: { type: "flag" }, f: { type: "flag" }, v: { type: "flag" } },
182
+ };
183
+ const GNU_BASE64_TABLE = {
184
+ longOptions: [
185
+ { name: "decode", type: "flag" }, { name: "help", type: "flag" }, { name: "ignore-garbage", type: "flag" },
186
+ { name: "version", type: "flag" }, { name: "wrap", type: "arg" },
187
+ ],
188
+ shortOptions: { d: { type: "flag" }, i: { type: "flag" }, w: { type: "arg" } },
189
+ };
190
+ export const UTILITY_PROFILE_REGISTRY = deepFreeze([
191
+ {
192
+ id: "gnu-env-v1", utility: "env", mode: "default",
193
+ source: { ...GNU_COREUTILS_9_4_SOURCE, section: "env invocation" },
194
+ longOptions: [
195
+ { name: "argv0", type: "arg" }, { name: "chdir", type: "arg" }, { name: "debug", type: "flag" }, { name: "help", type: "flag" },
196
+ { name: "ignore-environment", type: "flag" }, { name: "null", type: "flag" }, { name: "split-string", type: "arg" },
197
+ { name: "unset", type: "arg" }, { name: "version", type: "flag" },
198
+ ],
199
+ shortOptions: { 0: { type: "flag" }, a: { type: "arg" }, C: { type: "arg" }, i: { type: "flag" }, S: { type: "arg" }, u: { type: "arg" }, v: { type: "flag" } },
200
+ },
201
+ { id: "gnu-chmod-default-v1", utility: "chmod", mode: "default", source: { ...GNU_COREUTILS_9_4_SOURCE, section: "chmod invocation" }, ...GNU_CHMOD_TABLE },
202
+ { id: "gnu-chmod-posix-v1", utility: "chmod", mode: "posixly-correct", source: { ...GNU_COREUTILS_9_4_SOURCE, section: "chmod invocation" }, ...GNU_CHMOD_TABLE },
203
+ {
204
+ id: "apple-chmod-v1", utility: "chmod", mode: "apple", source: { ...APPLE_CHMOD_SOURCE, section: "SYNOPSIS" },
205
+ longOptions: [],
206
+ shortOptions: { C: { type: "flag" }, E: { type: "flag" }, H: { type: "flag" }, I: { type: "flag" }, L: { type: "flag" }, N: { type: "flag" }, P: { type: "flag" }, R: { type: "flag" }, f: { type: "flag" }, h: { type: "flag" }, i: { type: "flag" }, v: { type: "flag" } },
207
+ },
208
+ { id: "gnu-base64-default-v1", utility: "base64", mode: "default", source: { ...GNU_COREUTILS_9_4_SOURCE, section: "base64 invocation" }, ...GNU_BASE64_TABLE },
209
+ { id: "gnu-base64-posix-v1", utility: "base64", mode: "posixly-correct", source: { ...GNU_COREUTILS_9_4_SOURCE, section: "base64 invocation" }, ...GNU_BASE64_TABLE },
210
+ {
211
+ id: "apple-base64-v1", utility: "base64", mode: "apple", source: { ...APPLE_BINTRANS_SOURCE, section: "base64" },
212
+ longOptions: [
213
+ { name: "break", type: "arg" }, { name: "decode", type: "flag" }, { name: "help", type: "flag" }, { name: "ignore-garbage", type: "flag" },
214
+ { name: "input", type: "arg" }, { name: "output", type: "arg" }, { name: "wrap", type: "arg" },
215
+ ],
216
+ shortOptions: { D: { type: "flag" }, b: { type: "arg" }, d: { type: "flag" }, h: { type: "flag" }, i: { type: "arg" }, o: { type: "arg" }, w: { type: "arg" } },
217
+ },
218
+ ]);
219
+ const NON_LITERAL_IDENTITY_MARKERS = /[$`*?\[\]{};|&<>()\n\r\0]/;
220
+ export function normalizeLiteralUtilityIdentity(rawToken) {
221
+ // Lexical-only: split on "/" without path/realpath/PATH/alias resolution.
222
+ // Basename compare is case-insensitive and host-independent so inherited
223
+ // deny families still fire for CHMOD/ENV/BASE64 on case-insensitive
224
+ // filesystems, mirroring the darwin/win32 path folding lexicalNormalize
225
+ // already applies. The canonical lowercase utility feeds profile lookup.
226
+ const token = typeof rawToken === "string" ? rawToken : "";
227
+ const literal = token.length > 0 && !NON_LITERAL_IDENTITY_MARKERS.test(token);
228
+ const component = token.split("/").filter(Boolean);
229
+ const basename = literal ? (component.at(-1) ?? "") : "";
230
+ const canonical = basename.toLowerCase();
231
+ const utility = literal && (canonical === "env" || canonical === "chmod" || canonical === "base64") ? canonical : UTILITY.UNSUPPORTED;
232
+ return { rawToken: token, basename, utility, literal, pathQualified: token.includes("/") };
233
+ }
234
+ export function matchLongOption(token, longOptions) {
235
+ // Exact/unique-prefix matching over the committed long names. Accepts
236
+ // only when exactly one committed name starts with the supplied name
237
+ // and the attached `=value` form is permitted by that option's type.
238
+ // Returns null for zero/multiple matches or a disallowed argument form
239
+ // (the profile machine records that as rejected-by-profile).
240
+ if (typeof token !== "string" || !token.startsWith("--") || token === "--") return null;
241
+ const equal = token.indexOf("=");
242
+ const supplied = token.slice(2, equal < 0 ? undefined : equal);
243
+ if (!supplied) return null;
244
+ const value = equal < 0 ? null : token.slice(equal + 1);
245
+ const matches = longOptions.filter((option) => option.name.startsWith(supplied));
246
+ if (matches.length !== 1) return null;
247
+ const option = matches[0];
248
+ if (value !== null && option.type !== "arg") return null;
249
+ return { option, name: option.name, value };
250
+ }
251
+ function consumedArgument(option, tokenIndex, source, role, value) {
252
+ return { option, tokenIndex, source, role, value };
253
+ }
254
+ export function reduceProfileUnion(results = []) {
255
+ const acceptedFacts = results.filter((result) => result && (result.status === PROFILE_STATUS.ACCEPTED_SAFE || result.status === PROFILE_STATUS.ACCEPTED_DANGEROUS));
256
+ const utility = results[0]?.applicability?.utility ?? UTILITY.UNSUPPORTED;
257
+ // Union classification uses "unsupported", not OVERALL_CLASS.AMBIGUOUS:
258
+ // ambiguity is decided later by the protected-sink adapter, never by the union.
259
+ let classification = "unsupported";
260
+ if (acceptedFacts.some((result) => result.status === PROFILE_STATUS.ACCEPTED_DANGEROUS)) classification = "dangerous";
261
+ else if (acceptedFacts.length > 0) classification = "safe";
262
+ return { classification, utility, results, acceptedFacts };
263
+ }
264
+ function identityEvidence(identity) {
265
+ return [{
266
+ status: PROFILE_STATUS.UNSUPPORTED,
267
+ applicability: { profileId: "unsupported", utility: identity.utility, mode: "unsupported", applicable: false },
268
+ evidence: { code: identity.literal ? "unsupported-utility" : "non-literal-identity", phase: "identity" },
269
+ }];
270
+ }
271
+ function profilesFor(utility) {
272
+ return UTILITY_PROFILE_REGISTRY.filter((profile) => profile.utility === utility);
273
+ }
274
+ function profileApplicability(profile) {
275
+ return { profileId: profile.id, utility: profile.utility, mode: profile.mode, applicable: true };
276
+ }
277
+ function classifyLongOption(token, longOptions) {
278
+ const match = matchLongOption(token, longOptions);
279
+ if (match) return { kind: "match", option: match.option, name: match.name, value: match.value };
280
+ const equal = token.indexOf("=");
281
+ const supplied = token.slice(2, equal < 0 ? undefined : equal);
282
+ const matches = supplied ? longOptions.filter((option) => option.name.startsWith(supplied)) : [];
283
+ return { kind: matches.length > 1 ? "ambiguous" : "unknown" };
284
+ }
285
+ const ASSIGNMENT_TOKEN = /^[A-Za-z_][A-Za-z0-9_]*=/;
286
+ const SHORT_BUNDLE = /^-[^-]/;
287
+ const CRITICAL_TARGET_CORPUS = Object.freeze(["/", "/*", "~", "$HOME", "${HOME}", ".", "..", PROJECT_ROOT]);
288
+ const isCriticalTarget = (token) => CRITICAL_TARGET_CORPUS.includes(token);
289
+ const OCTAL_MODE_SHAPE = /^0?[0-7]{3,4}$/;
290
+ const SYMBOLIC_MODE_SHAPE = /^[ugoa]*[+=-][rwxXstugo]+(?:,[ugoa]*[+=-][rwxXstugo]+)*$/;
291
+ const isModeShaped = (token) => OCTAL_MODE_SHAPE.test(token) || SYMBOLIC_MODE_SHAPE.test(token);
292
+ const ENV_SPLIT_WHITESPACE = " \t\n\v\f\r";
293
+ function splitEnvSemantics(input) {
294
+ const words = [];
295
+ let word = "", quote = "", started = false, stopped = false;
296
+ const push = () => { if (started) words.push(word); word = ""; started = false; };
297
+ for (let index = 0; index < input.length; index++) {
298
+ const char = input[index];
299
+ if (quote === "'") { if (char === "'") quote = ""; else word += char; continue; }
300
+ if (quote === '"' && char === '"') { quote = ""; continue; }
301
+ if (!quote && (char === "'" || char === '"')) { quote = char; started = true; continue; }
302
+ if (char === "\\") {
303
+ const escape = input[index + 1];
304
+ if (escape === undefined) return { error: "env-unsupported-escape" };
305
+ index++;
306
+ if (escape === "c") { if (quote) return { error: "env-unsupported-escape" }; stopped = true; break; }
307
+ if (!(escape in ENV_ESCAPES)) return { error: "env-unsupported-escape" };
308
+ if (escape === "_") { if (quote) { word += " "; started = true; } else push(); continue; }
309
+ word += ENV_ESCAPES[escape]; started = true;
310
+ continue;
311
+ }
312
+ if (char === "$") return { error: "env-active-expansion" };
313
+ if (!quote && ENV_SPLIT_WHITESPACE.includes(char)) { push(); continue; }
314
+ if (!quote && char === "#" && !started) break;
315
+ word += char; started = true;
316
+ }
317
+ if (quote) return { error: "env-unclosed-quote" };
318
+ push();
319
+ return { words, stopped };
320
+ }
321
+ function runEnvMachine(profile, tokens) {
322
+ const applicability = profileApplicability(profile);
323
+ const unsupported = (code, phase, tokenIndex) => ({ status: PROFILE_STATUS.UNSUPPORTED, applicability, evidence: tokenIndex === undefined ? { code, phase } : { code, phase, tokenIndex } });
324
+ const queue = tokens.slice(1);
325
+ // Cumulative split-work bound: splitOps <= 32 AND splitBytes <= 8N over the
326
+ // outer argv byte count, enforced across every nested -S splice.
327
+ const splitByteBudget = 8 * queue.reduce((total, token) => total + Buffer.byteLength(token, "utf8"), 0);
328
+ const facts = { utility: UTILITY.ENV, wrapperOptions: [], assignments: [], consumedArguments: [], delimiter: { seen: false }, eventualExecutable: null, eventualArgv: [], activeExpansion: false, terminatedByControlEscape: false };
329
+ let splitOps = 0, splitBytes = 0, index = 0;
330
+ const takeSplit = (value, tokenIndex, source) => {
331
+ splitOps += 1;
332
+ splitBytes += Buffer.byteLength(value, "utf8");
333
+ if (splitOps > 32 || splitBytes > splitByteBudget) return unsupported("split-work-limit", "split", tokenIndex);
334
+ const parsed = splitEnvSemantics(value);
335
+ if (parsed.error) return unsupported(parsed.error, "split", tokenIndex);
336
+ facts.splitInput ??= consumedArgument("split-string", tokenIndex, source, "split-string", value);
337
+ if (parsed.stopped) facts.terminatedByControlEscape = true;
338
+ queue.splice(index, 0, ...parsed.words);
339
+ return null;
340
+ };
341
+ while (index < queue.length) {
342
+ const token = queue[index];
343
+ if (token === "--") { facts.delimiter = { seen: true, tokenIndex: index }; index++; break; }
344
+ if (token === "-") { facts.wrapperOptions.push("-"); index++; continue; } // GNU: a bare - implies -i, never the command
345
+ if (ASSIGNMENT_TOKEN.test(token)) { facts.assignments.push(token); index++; continue; }
346
+ if (token.startsWith("--")) {
347
+ const match = classifyLongOption(token, profile.longOptions);
348
+ if (match.kind !== "match") return unsupported("env-unsupported-option", "wrapper", index);
349
+ const optionIndex = index;
350
+ index++;
351
+ if (match.option.type !== "arg") { facts.wrapperOptions.push(match.name); continue; }
352
+ let value = match.value, source = "attached";
353
+ if (value === null) { if (index >= queue.length) return unsupported("env-missing-argument", "option", optionIndex); value = queue[index]; queue.splice(index, 1); source = "next-token"; }
354
+ if (match.name === "split-string") { const stop = takeSplit(value, optionIndex, source); if (stop) return stop; continue; }
355
+ facts.consumedArguments.push(consumedArgument(match.name, optionIndex, source, "other", value));
356
+ continue;
357
+ }
358
+ if (SHORT_BUNDLE.test(token)) {
359
+ const bundleIndex = index;
360
+ index++;
361
+ let stop = null;
362
+ for (let position = 1; position < token.length; position++) {
363
+ const short = token[position];
364
+ const spec = profile.shortOptions[short];
365
+ if (!spec) { stop = unsupported("env-unsupported-option", "wrapper", bundleIndex); break; }
366
+ if (spec.type === "flag") { facts.wrapperOptions.push(short); continue; }
367
+ let value, source;
368
+ if (position + 1 < token.length) { value = token.slice(position + 1); source = "attached"; }
369
+ else if (index < queue.length) { value = queue[index]; queue.splice(index, 1); source = "next-token"; }
370
+ else { stop = unsupported("env-missing-argument", "option", bundleIndex); break; }
371
+ if (short === "S") stop = takeSplit(value, bundleIndex, source);
372
+ else facts.consumedArguments.push(consumedArgument(short, bundleIndex, source, "other", value));
373
+ break;
374
+ }
375
+ if (stop) return stop;
376
+ continue;
377
+ }
378
+ break;
379
+ }
380
+ if (index < queue.length) { facts.eventualExecutable = queue[index]; facts.eventualArgv = queue.slice(index + 1); }
381
+ return { status: PROFILE_STATUS.ACCEPTED_SAFE, applicability, facts };
382
+ }
383
+ function conservativePossibleTargets(tokens) {
384
+ return tokens.slice(1).filter(isCriticalTarget);
385
+ }
386
+ function assessChmodProfile(applicability, state) {
387
+ // Low 777 (world rwx) is the danger; setuid/setgid/sticky prefixes (4777, 2777,
388
+ // 1777, 00777, …) on a critical root are equally dangerous, so match any leading
389
+ // special/zero octal digits before 777.
390
+ const mode777 = state.mode !== undefined && /^[0-7]{0,2}777$/.test(state.mode);
391
+ const roles = { targets: state.targets, possibleTargets: [] };
392
+ if (state.mode !== undefined) roles.mode = state.mode;
393
+ if (state.reference !== undefined) roles.reference = state.reference;
394
+ const dangerous = state.targets.some(isCriticalTarget) && (state.recursive || mode777);
395
+ return { status: dangerous ? PROFILE_STATUS.ACCEPTED_DANGEROUS : PROFILE_STATUS.ACCEPTED_SAFE, applicability, facts: { utility: UTILITY.CHMOD, recursive: state.recursive, mode777, roles, consumedArguments: state.consumedArguments, delimiter: state.delimiter } };
396
+ }
397
+ function runGnuChmodMachine(profile, tokens) {
398
+ const applicability = profileApplicability(profile);
399
+ const posix = profile.mode === "posixly-correct";
400
+ const consumedArguments = [];
401
+ const operands = [];
402
+ let delimiter = { seen: false };
403
+ let recursive = false, reference, recognizing = true;
404
+ const rejected = (reasonCode) => ({ status: PROFILE_STATUS.REJECTED, applicability, reasonCode, partialRoles: { targets: [...operands], possibleTargets: conservativePossibleTargets(tokens) } });
405
+ for (let index = 1; index < tokens.length; index++) {
406
+ const token = tokens[index];
407
+ if (recognizing && token === "--") { delimiter = { seen: true, tokenIndex: index }; recognizing = false; continue; }
408
+ if (recognizing && token.startsWith("--")) {
409
+ const match = classifyLongOption(token, profile.longOptions);
410
+ if (match.kind === "ambiguous") return rejected("ambiguous-long-option");
411
+ if (match.kind !== "match") return rejected("unknown-long-option");
412
+ if (match.option.type === "arg") {
413
+ let value = match.value, source = "attached";
414
+ if (value === null) { if (index + 1 >= tokens.length) return rejected("missing-option-argument"); value = tokens[++index]; source = "next-token"; }
415
+ if (match.name === "reference") reference = value;
416
+ consumedArguments.push(consumedArgument(match.name, index, source, match.name === "reference" ? "reference" : "other", value));
417
+ } else if (match.name === "recursive") recursive = true;
418
+ continue;
419
+ }
420
+ if (recognizing && SHORT_BUNDLE.test(token)) {
421
+ for (let position = 1; position < token.length; position++) {
422
+ if (!profile.shortOptions[token[position]]) return rejected("unknown-short-option");
423
+ if (token[position] === "R") recursive = true;
424
+ }
425
+ continue;
426
+ }
427
+ operands.push(token);
428
+ if (posix) recognizing = false;
429
+ }
430
+ if (reference !== undefined) {
431
+ const modeCandidate = operands.findIndex(isModeShaped);
432
+ if (modeCandidate >= 0) {
433
+ const targets = operands.filter((_, position) => position !== modeCandidate);
434
+ return { status: PROFILE_STATUS.REJECTED, applicability, reasonCode: "mixed-mode-reference", partialRoles: { mode: operands[modeCandidate], reference, targets, possibleTargets: [...targets] } };
435
+ }
436
+ return assessChmodProfile(applicability, { recursive, mode: undefined, reference, targets: [...operands], consumedArguments, delimiter });
437
+ }
438
+ return assessChmodProfile(applicability, { recursive, mode: operands[0], reference: undefined, targets: operands.slice(1), consumedArguments, delimiter });
439
+ }
440
+ function runAppleChmodMachine(profile, tokens) {
441
+ const applicability = profileApplicability(profile);
442
+ const partialRoles = { targets: [], possibleTargets: conservativePossibleTargets(tokens) };
443
+ if (tokens.slice(1).some((token) => token.startsWith("--"))) return { status: PROFILE_STATUS.REJECTED, applicability, reasonCode: "long-option-unsupported", partialRoles };
444
+ const aclEvidence = (tokenIndex) => ({ status: PROFILE_STATUS.UNSUPPORTED, applicability, evidence: { code: "chmod-acl-mode", phase: "roles", tokenIndex }, partialRoles });
445
+ const operands = [];
446
+ let recursive = false, recognizing = true;
447
+ for (let index = 1; index < tokens.length; index++) {
448
+ const token = tokens[index];
449
+ if (recognizing && SHORT_BUNDLE.test(token)) {
450
+ for (let position = 1; position < token.length; position++) {
451
+ if (token[position] === "a") return aclEvidence(index);
452
+ if (!profile.shortOptions[token[position]]) return { status: PROFILE_STATUS.REJECTED, applicability, reasonCode: "unknown-short-option", partialRoles };
453
+ if (token[position] === "R") recursive = true;
454
+ }
455
+ continue;
456
+ }
457
+ recognizing = false;
458
+ if (/^[+=]a|^-a/.test(token)) return aclEvidence(index);
459
+ operands.push(token);
460
+ }
461
+ return assessChmodProfile(applicability, { recursive, mode: operands[0], reference: undefined, targets: operands.slice(1), consumedArguments: [], delimiter: { seen: false } });
462
+ }
463
+ function runGnuBase64Machine(profile, tokens) {
464
+ const applicability = profileApplicability(profile);
465
+ const posix = profile.mode === "posixly-correct";
466
+ const booleanOptions = [];
467
+ const operands = [];
468
+ const consumedArguments = [];
469
+ let delimiter = { seen: false };
470
+ let decode = false, recognizing = true;
471
+ const rejected = (reasonCode) => ({ status: PROFILE_STATUS.REJECTED, applicability, reasonCode });
472
+ for (let index = 1; index < tokens.length; index++) {
473
+ const token = tokens[index];
474
+ if (recognizing && token === "--") { delimiter = { seen: true, tokenIndex: index }; recognizing = false; continue; }
475
+ if (recognizing && token.startsWith("--")) {
476
+ const match = classifyLongOption(token, profile.longOptions);
477
+ if (match.kind === "ambiguous") return rejected("ambiguous-long-option");
478
+ if (match.kind !== "match") return rejected("unknown-long-option");
479
+ if (match.option.type === "arg") {
480
+ let value = match.value, source = "attached";
481
+ if (value === null) { if (index + 1 >= tokens.length) return rejected("missing-option-argument"); value = tokens[++index]; source = "next-token"; }
482
+ consumedArguments.push(consumedArgument(match.name, index, source, match.name === "wrap" ? "wrap" : "other", value));
483
+ } else if (match.name === "decode") decode = true;
484
+ else booleanOptions.push(match.name);
485
+ continue;
486
+ }
487
+ if (recognizing && SHORT_BUNDLE.test(token)) {
488
+ for (let position = 1; position < token.length; position++) {
489
+ const short = token[position];
490
+ const spec = profile.shortOptions[short];
491
+ if (!spec) return rejected("unknown-short-option");
492
+ if (spec.type === "flag") { if (short === "d") decode = true; else booleanOptions.push(short); continue; }
493
+ let value, source;
494
+ if (position + 1 < token.length) { value = token.slice(position + 1); source = "attached"; }
495
+ else if (index + 1 < tokens.length) { value = tokens[++index]; source = "next-token"; }
496
+ else return rejected("missing-option-argument");
497
+ consumedArguments.push(consumedArgument(short, index, source, short === "w" ? "wrap" : "other", value));
498
+ break;
499
+ }
500
+ continue;
501
+ }
502
+ operands.push(token);
503
+ if (posix) recognizing = false;
504
+ }
505
+ return { status: PROFILE_STATUS.ACCEPTED_SAFE, applicability, facts: { utility: UTILITY.BASE64, decode, booleanOptions, operands, consumedArguments, delimiter } };
506
+ }
507
+ const APPLE_BASE64_ARGUMENT_ROLES = Object.freeze({ b: "wrap", i: "input", o: "output", w: "wrap", break: "wrap", input: "input", output: "output", wrap: "wrap" });
508
+ function runAppleBase64Machine(profile, tokens) {
509
+ const applicability = profileApplicability(profile);
510
+ const booleanOptions = [];
511
+ const operands = [];
512
+ const consumedArguments = [];
513
+ let decode = false, recognizing = true;
514
+ const rejected = (reasonCode) => ({ status: PROFILE_STATUS.REJECTED, applicability, reasonCode });
515
+ for (let index = 1; index < tokens.length; index++) {
516
+ const token = tokens[index];
517
+ if (recognizing && token === "--") return rejected("delimiter-unsupported");
518
+ if (recognizing && token.startsWith("--")) {
519
+ const equal = token.indexOf("=");
520
+ const name = token.slice(2, equal < 0 ? undefined : equal);
521
+ const option = profile.longOptions.find((entry) => entry.name === name);
522
+ if (!option) return rejected("unknown-long-option");
523
+ const attached = equal < 0 ? null : token.slice(equal + 1);
524
+ if (attached !== null && option.type !== "arg") return rejected("unknown-long-option");
525
+ if (option.type === "arg") {
526
+ let value = attached, source = "attached";
527
+ if (value === null) { if (index + 1 >= tokens.length) return rejected("missing-option-argument"); value = tokens[++index]; source = "next-token"; }
528
+ consumedArguments.push(consumedArgument(name, index, source, APPLE_BASE64_ARGUMENT_ROLES[name] ?? "other", value));
529
+ } else if (name === "decode") decode = true;
530
+ else booleanOptions.push(name);
531
+ continue;
532
+ }
533
+ if (recognizing && SHORT_BUNDLE.test(token)) {
534
+ for (let position = 1; position < token.length; position++) {
535
+ const short = token[position];
536
+ const spec = profile.shortOptions[short];
537
+ if (!spec) return rejected("unknown-short-option");
538
+ if (spec.type === "flag") { if (short === "d" || short === "D") decode = true; if (short !== "d") booleanOptions.push(short); continue; }
539
+ let value, source;
540
+ if (position + 1 < token.length) { value = token.slice(position + 1); source = "attached"; }
541
+ else if (index + 1 < tokens.length) { value = tokens[++index]; source = "next-token"; }
542
+ else return rejected("missing-option-argument");
543
+ consumedArguments.push(consumedArgument(short, index, source, APPLE_BASE64_ARGUMENT_ROLES[short] ?? "other", value));
544
+ break;
545
+ }
546
+ continue;
547
+ }
548
+ recognizing = false;
549
+ operands.push(token);
550
+ }
551
+ return { status: PROFILE_STATUS.ACCEPTED_SAFE, applicability, facts: { utility: UTILITY.BASE64, decode, booleanOptions, operands, consumedArguments, delimiter: { seen: false } } };
552
+ }
553
+ export function normalizeEnvInvocation(tokens = []) {
554
+ const identity = normalizeLiteralUtilityIdentity(tokens[0] ?? "");
555
+ if (identity.utility !== UTILITY.ENV) return identityEvidence(identity);
556
+ return profilesFor(UTILITY.ENV).map((profile) => runEnvMachine(profile, tokens));
557
+ }
558
+ export function normalizeChmodInvocation(tokens = []) {
559
+ const identity = normalizeLiteralUtilityIdentity(tokens[0] ?? "");
560
+ if (identity.utility !== UTILITY.CHMOD) return identityEvidence(identity);
561
+ return profilesFor(UTILITY.CHMOD).map((profile) => (profile.mode === "apple" ? runAppleChmodMachine(profile, tokens) : runGnuChmodMachine(profile, tokens)));
562
+ }
563
+ export function normalizeBase64Invocation(tokens = []) {
564
+ const identity = normalizeLiteralUtilityIdentity(tokens[0] ?? "");
565
+ if (identity.utility !== UTILITY.BASE64) return identityEvidence(identity);
566
+ return profilesFor(UTILITY.BASE64).map((profile) => (profile.mode === "apple" ? runAppleBase64Machine(profile, tokens) : runGnuBase64Machine(profile, tokens)));
567
+ }
568
+ function firstUnsupportedProfileId(results) {
569
+ return results.find((result) => result.status === PROFILE_STATUS.UNSUPPORTED)?.applicability.profileId ?? "unsupported";
570
+ }
571
+ function ambiguityDecision(context) {
572
+ // Fixed enums only (reason/utility/profile/sink); carried non-enumerable so
573
+ // the public Decision shape stays exactly { allowed, ruleId }.
574
+ const decision = { allowed: false, ruleId: "utility-ambiguity" };
575
+ Object.defineProperty(decision, "ambiguity", { value: Object.freeze(context), enumerable: false });
576
+ return decision;
577
+ }
578
+ function adaptProtectedSink(utility, results, union, sink) {
579
+ if (union.classification === "dangerous") return { allowed: false, ruleId: sink.dangerousRuleId };
580
+ if (union.classification !== "unsupported" || !sink.applicable) return null;
581
+ return ambiguityDecision({ utility, profile: firstUnsupportedProfileId(results), sink: sink.id });
582
+ }
583
+ function reduceWrappers(input, powershell = false) {
584
+ let tokens = [...input];
585
+ if (powershell) while (tokens[0] === "&") tokens.shift();
586
+ for (let hops = 0; ; hops++) {
587
+ if (hops > 32) return { tokens: null, ambiguity: { utility: UTILITY.UNSUPPORTED, profile: "unsupported", sink: SINK.WRAPPER } };
588
+ while (ASSIGNMENT_TOKEN.test(tokens[0] ?? "")) tokens.shift();
589
+ if (normalizeLiteralUtilityIdentity(tokens[0] ?? "").utility === UTILITY.ENV) {
590
+ const [result] = normalizeEnvInvocation(tokens);
591
+ if (result.status !== PROFILE_STATUS.ACCEPTED_SAFE) return { tokens: null, ambiguity: { utility: UTILITY.ENV, profile: result.applicability.profileId, sink: SINK.WRAPPER } };
592
+ if (result.facts.eventualExecutable === null) return { tokens: [] };
593
+ tokens = [result.facts.eventualExecutable, ...result.facts.eventualArgv];
594
+ continue;
595
+ }
596
+ const wrapper = (tokens[0] ?? "").toLowerCase();
597
+ if (!["sudo", "command", "builtin", "nohup"].includes(wrapper)) break;
598
+ tokens.shift();
599
+ for (;;) {
600
+ if (tokens[0] === "--" || ASSIGNMENT_TOKEN.test(tokens[0] ?? "")) { tokens.shift(); continue; }
601
+ if (wrapper === "sudo" && /^(?:-u|-g|-h|-p|-C|-D|-R|-T|-r|-t|--user|--group|--host|--prompt|--chdir|--chroot|--command-timeout|--role|--type)$/.test(tokens[0] ?? "")) { tokens.splice(0, 2); continue; }
602
+ if (tokens[0]?.startsWith("-") && tokens[0] !== "--") { tokens.shift(); continue; }
603
+ break;
604
+ }
605
+ }
606
+ return { tokens };
607
+ }
608
+ function hasSensitiveLiteral(tokens, cwd) {
609
+ return tokens.some((token) => {
610
+ if ((token.startsWith("-") && !/^-(?:LiteralPath|Path):/i.test(token)) || !/[\\/.~$]/.test(token)) return false;
611
+ try {
612
+ return isSensitivePolicyKey(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot: PROJECT_ROOT }));
613
+ } catch {
614
+ return false;
615
+ }
616
+ });
617
+ }
618
+ function hasManagedLiteral(tokens, cwd) {
619
+ return tokens.some((token) => {
620
+ if ((token.startsWith("-") && !/^-(?:LiteralPath|Path):/i.test(token)) || !/[\\/.]/.test(token)) return false;
621
+ try {
622
+ return isManaged(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot: PROJECT_ROOT }));
623
+ } catch {
624
+ return false;
625
+ }
626
+ });
627
+ }
628
+ function bashSubstitutions(command) {
629
+ const bodies = [];
630
+ let quote = "", escaped = false;
631
+ for (let index = 0; index < command.length; index++) {
632
+ const char = command[index];
633
+ if (escaped) { escaped = false; continue; }
634
+ if (char === "\\" && quote !== "'") { escaped = true; continue; }
635
+ if (char === "'" && quote !== '"') { quote = quote === "'" ? "" : "'"; continue; }
636
+ if (char === '"' && quote !== "'") { quote = quote === '"' ? "" : '"'; continue; }
637
+ if (quote === "'") continue;
638
+ if (char === "`") {
639
+ let body = "", closed = false;
640
+ for (++index; index < command.length; index++) { if (command[index] === "\\" && index + 1 < command.length) body += command[++index]; else if (command[index] === "`") { closed = true; break; } else body += command[index]; }
641
+ if (!closed) fail("unlexable-command");
642
+ bodies.push(body);
643
+ } else if (char === "$" && command[index + 1] === "(") {
644
+ let depth = 1, innerQuote = "", innerEscaped = false, end = index + 2;
645
+ for (; end < command.length && depth; end++) {
646
+ const inner = command[end];
647
+ if (innerEscaped) { innerEscaped = false; continue; }
648
+ if (inner === "\\" && innerQuote !== "'") { innerEscaped = true; continue; }
649
+ if (inner === "'" && innerQuote !== '"' && innerQuote !== "`") innerQuote = innerQuote === "'" ? "" : "'";
650
+ else if (inner === '"' && innerQuote !== "'" && innerQuote !== "`") innerQuote = innerQuote === '"' ? "" : '"';
651
+ else if (inner === "`" && innerQuote !== "'") innerQuote = innerQuote === "`" ? "" : "`";
652
+ else if (!innerQuote && inner === "(") depth++;
653
+ else if (!innerQuote && inner === ")") depth--;
654
+ }
655
+ if (depth) fail("unlexable-command");
656
+ bodies.push(command.slice(index + 2, end - 1)); index = end - 1;
657
+ }
658
+ }
659
+ return bodies;
660
+ }
661
+ function evaluateBash(command, cwd, depth = 0) {
662
+ if (depth > 4) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
663
+ try { for (const body of bashSubstitutions(command)) { const nested = evaluateBash(body, cwd, depth + 1); if (!nested.allowed) return nested; } } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
664
+ if (/^\s*:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:\s*$/.test(command)) return { allowed: false, ruleId: "shell.destructive-root" };
665
+ let parsed;
666
+ try { parsed = lex(command); } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
667
+ for (let index = 0; index < parsed.commands.length; index++) {
668
+ const reduced = reduceWrappers(parsed.commands[index]);
669
+ if (reduced.ambiguity) return ambiguityDecision(reduced.ambiguity);
670
+ const tokens = reduced.tokens;
671
+ const executable = (tokens[0] ?? "").toLowerCase();
672
+ const identity = normalizeLiteralUtilityIdentity(tokens[0] ?? "");
673
+ const rmOptions = tokens.filter((token) => token.startsWith("-")).join("");
674
+ if ((executable === "rm" && /r/i.test(rmOptions) && /f/i.test(rmOptions) && tokens.some(isCriticalTarget)) || /^mkfs/.test(executable) || (executable === "dd" && tokens.some((token) => /^of=\/dev\/(?:sd|nvme|vd|disk)/.test(token)))) return { allowed: false, ruleId: "shell.destructive-root" };
675
+ if (identity.utility === UTILITY.CHMOD) {
676
+ const results = normalizeChmodInvocation(tokens);
677
+ const union = reduceProfileUnion(results);
678
+ const possibleCritical = results.some((result) => (result.partialRoles?.possibleTargets ?? []).some(isCriticalTarget));
679
+ const decision = adaptProtectedSink(UTILITY.CHMOD, results, union, { id: SINK.CRITICAL_CHMOD, dangerousRuleId: "shell.destructive-root", applicable: possibleCritical });
680
+ if (decision) return decision;
681
+ }
682
+ if (parsed.separators[index] === "|") {
683
+ const downstream = reduceWrappers(parsed.commands[index + 1] ?? []).tokens ?? [];
684
+ const shellSink = /^(?:sh|bash|zsh|dash|ksh)$/.test((downstream[0] ?? "").toLowerCase());
685
+ if (identity.utility === UTILITY.BASE64) {
686
+ const results = normalizeBase64Invocation(tokens).map((result) => (shellSink && result.status === PROFILE_STATUS.ACCEPTED_SAFE && result.facts.decode ? { ...result, status: PROFILE_STATUS.ACCEPTED_DANGEROUS } : result));
687
+ const union = reduceProfileUnion(results);
688
+ const decision = adaptProtectedSink(UTILITY.BASE64, results, union, { id: SINK.BASE64_SHELL, dangerousRuleId: "shell.pipe-to-shell", applicable: shellSink });
689
+ if (decision) return decision;
690
+ } else if (/^(?:curl|wget)$/.test(executable) && shellSink) return { allowed: false, ruleId: "shell.pipe-to-shell" };
691
+ }
692
+ if (["cat", "less", "more", "head", "tail", "bat", "grep", "rg", "sed", "awk", "source", ".", "cp", "install"].includes(executable) && hasSensitiveLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "shell.sensitive-read" };
693
+ if (tokens.some((token) => token === "<") && hasSensitiveLiteral(tokens, cwd)) return { allowed: false, ruleId: "shell.sensitive-read" };
694
+ if (executable === "git" && tokens[1]?.toLowerCase() === "push" && tokens.some((token) => ["-f", "--force", "--force-with-lease"].includes(token.toLowerCase()))) return { allowed: false, ruleId: "shell.force-push" };
695
+ if (["rm", "mv", "cp", "install", "truncate", "touch", "chmod", "chown", "tee"].includes(executable) && hasManagedLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "shell.managed-config-tamper" };
696
+ if ((/^(?:sed|perl)$/.test(executable) && tokens.some((token) => token.startsWith("-i")) && hasManagedLiteral(tokens, cwd)) || (tokens.includes(">") && hasManagedLiteral(tokens, cwd))) return { allowed: false, ruleId: "shell.managed-config-tamper" };
697
+ if (/^(?:powershell|pwsh)(?:\.exe)?$/i.test(executable) && tokens.some((token) => /^-(?:enc|encodedcommand)$/i.test(token))) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
698
+ if (/^(?:bash|sh|zsh|dash|ksh)$/.test(executable)) {
699
+ const flag = tokens.findIndex((token) => /^-[^-]*c[^-]*$/.test(token));
700
+ if (flag >= 0) { const body = tokens[flag + 1]; if (!body || /\$(?!\()/.test(body)) return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; const nested = evaluateBash(body, cwd, depth + 1); if (!nested.allowed) return nested; }
701
+ }
702
+ }
703
+ return { allowed: true };
704
+ }
705
+ function evaluatePowerShell(command, cwd) {
706
+ const parsed = lex(command, true);
707
+ for (let index = 0; index < parsed.commands.length; index++) {
708
+ const reduced = reduceWrappers(parsed.commands[index], true);
709
+ if (reduced.ambiguity) return ambiguityDecision(reduced.ambiguity);
710
+ const tokens = reduced.tokens;
711
+ const executable = (tokens[0] ?? "").toLowerCase();
712
+ if ((["remove-item", "rm", "del", "erase", "rmdir", "rd"].includes(executable) && tokens.some((token) => /^-(?:r|recurse)$/i.test(token)) && tokens.some((token) => /^-(?:fo|force)$/i.test(token)) && tokens.some((token) => /^(?:[a-z]:\\?|[/~]|\$HOME)$/i.test(token))) || ["format-volume", "clear-disk", "initialize-disk"].includes(executable)) return { allowed: false, ruleId: "powershell.destructive-root" };
713
+ if (parsed.separators[index] === "|" && /^(?:invoke-webrequest|iwr|curl|wget|invoke-restmethod|irm)$/.test(executable) && /^(?:invoke-expression|iex)$/.test(((reduceWrappers(parsed.commands[index + 1] ?? [], true).tokens ?? [])[0] ?? "").toLowerCase())) return { allowed: false, ruleId: "powershell.pipe-to-shell" };
714
+ if (["get-content", "gc", "cat", "type", "select-string", "copy-item", "cp", "copy"].includes(executable) && hasSensitiveLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "powershell.sensitive-read" };
715
+ if (executable === "git" && tokens[1]?.toLowerCase() === "push" && tokens.some((token) => ["-f", "--force", "--force-with-lease"].includes(token.toLowerCase()))) return { allowed: false, ruleId: "powershell.force-push" };
716
+ if (["set-content", "add-content", "out-file", "clear-content", "remove-item", "move-item", "copy-item", "rename-item", "new-item"].includes(executable) && hasManagedLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "powershell.managed-config-tamper" };
717
+ if (tokens.includes(">") && hasManagedLiteral(tokens, cwd)) return { allowed: false, ruleId: "powershell.managed-config-tamper" };
718
+ if (/^(?:powershell|pwsh)(?:\.exe)?$/i.test(executable) && tokens.some((token) => /^-(?:enc|encodedcommand)$/i.test(token))) return { allowed: false, ruleId: "powershell.obfuscated-interpreter" };
719
+ }
720
+ return { allowed: true };
721
+ }
722
+ export function evaluateEvent(input) {
723
+ if (!isObject(input) || input.hook_event_name !== "PreToolUse" || !SUPPORTED_TOOLS.includes(input.tool_name) || !isObject(input.tool_input)) fail("invalid-event");
724
+ if (input.tool_name === "Bash" || input.tool_name === "PowerShell") {
725
+ if (typeof input.tool_input.command !== "string") fail("invalid-event");
726
+ const cwd = typeof input.cwd === "string" && isAbsolutePolicyPath(input.cwd) ? input.cwd : PROJECT_ROOT;
727
+ return input.tool_name === "Bash" ? evaluateBash(input.tool_input.command, cwd) : evaluatePowerShell(input.tool_input.command, cwd);
728
+ }
729
+ if (typeof input.tool_input.file_path !== "string" || !isAbsolutePolicyPath(input.tool_input.file_path)) fail("invalid-event");
730
+ return evaluateFile(input.tool_name, input.tool_input.file_path);
731
+ }
732
+ export function parseAndEvaluateInput(input) {
733
+ if (!Buffer.isBuffer(input) || input.length === 0) fail("invalid-json");
734
+ if (input.length > INPUT_LIMIT_BYTES) fail("oversized-input");
735
+ let parsed;
736
+ try {
737
+ parsed = JSON.parse(input.toString("utf8"));
738
+ } catch {
739
+ fail("invalid-json");
740
+ }
741
+ if (!isObject(parsed)) fail("invalid-json");
742
+ return evaluateEvent(parsed);
743
+ }
744
+ function diagnostic(error) {
745
+ const id = error instanceof Error && /^[a-z-]+$/.test(error.message) ? error.message : "internal-error";
746
+ const messages = {
747
+ "invalid-json": "input is not a valid JSON object",
748
+ "invalid-event": "input does not match the supported event schema",
749
+ "oversized-input": "stdin exceeds 1048576 bytes",
750
+ "missing-policy": "embedded policy registry is unavailable",
751
+ "internal-error": "policy evaluation could not complete",
752
+ };
753
+ return `javi-forge PreToolUse failed closed [${id}]: ${messages[id] ?? messages["internal-error"]}`;
754
+ }
755
+ function denialDiagnostic(toolName, decision) {
756
+ const tool = SUPPORTED_TOOLS.includes(toolName) ? toolName : "supported tool";
757
+ if (decision.ambiguity) return `javi-forge PreToolUse denied ${tool} [${decision.ruleId}]: ${decision.ambiguity.utility} ${decision.ambiguity.profile} ${decision.ambiguity.sink} semantics denied as ambiguous`;
758
+ return `javi-forge PreToolUse denied ${tool} [${decision.ruleId}]: global guard policy denied the invocation`;
759
+ }
760
+ function truncateUtf8(message, maxBytes) {
761
+ let output = message;
762
+ while (Buffer.byteLength(output) > maxBytes) output = output.slice(0, -1);
763
+ return output;
764
+ }
765
+ function denyAndExit(message) {
766
+ try {
767
+ fs.writeSync(2, `${truncateUtf8(message, POLICY_REGISTRY.diagnosticsMaxBytes)}\n`);
768
+ } finally {
769
+ process.stdin.destroy();
770
+ process.stdin.unref?.();
771
+ process.exit(2);
772
+ }
773
+ }
774
+ export function readBoundedStdin(stream = process.stdin) {
775
+ return new Promise((resolve, reject) => {
776
+ const chunks = [];
777
+ let bytes = 0;
778
+ let settled = false;
779
+ const cleanup = () => {
780
+ stream.removeListener("data", onData);
781
+ stream.removeListener("end", onEnd);
782
+ stream.removeListener("error", onError);
783
+ stream.removeListener("aborted", onAborted);
784
+ };
785
+ const finish = (callback) => {
786
+ if (settled) return;
787
+ settled = true;
788
+ cleanup();
789
+ callback();
790
+ };
791
+ const onData = (chunk) => {
792
+ bytes += chunk.length;
793
+ if (bytes > INPUT_LIMIT_BYTES) {
794
+ finish(() => reject(new Error("oversized-input")));
795
+ return;
796
+ }
797
+ chunks.push(chunk);
798
+ };
799
+ const onEnd = () => finish(() => resolve(Buffer.concat(chunks)));
800
+ const onError = () => finish(() => reject(new Error("stdin-error")));
801
+ const onAborted = () => finish(() => reject(new Error("stdin-error")));
802
+ stream.on("data", onData);
803
+ stream.on("end", onEnd);
804
+ stream.on("error", onError);
805
+ stream.on("aborted", onAborted);
806
+ });
807
+ }
808
+ export async function main() {
809
+ try {
810
+ const fault = process.argv.find((arg) => arg.startsWith("--javi-forge-test-fault="))?.split("=")[1];
811
+ if (fault === "missing-policy") throw new Error("missing-policy");
812
+ if (POLICY_REGISTRY.schemaVersion !== 1 || POLICY_REGISTRY.policyVersion !== 1) throw new Error("missing-policy");
813
+ const input = await readBoundedStdin();
814
+ let parsed;
815
+ try {
816
+ parsed = JSON.parse(input.toString("utf8"));
817
+ } catch {
818
+ throw new Error("invalid-json");
819
+ }
820
+ if (fault === "evaluator-throw") throw new Error("internal-error");
821
+ const decision = evaluateEvent(parsed);
822
+ if (!decision.allowed) denyAndExit(denialDiagnostic(parsed.tool_name, decision));
823
+ process.exitCode = 0;
824
+ } catch (error) {
825
+ denyAndExit(diagnostic(error));
826
+ }
827
+ }
828
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();