deepdebug-local-agent 1.0.23 → 1.2.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.
- package/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +76 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +76 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/dlp.md +78 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +37 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +34 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/next-version.mjs +75 -0
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/dlp/engine.js +111 -0
- package/src/dlp/index.js +137 -0
- package/src/dlp/rules.js +97 -0
- package/src/dlp/validators.js +123 -0
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +322 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/dlp/engine.test.js +173 -0
- package/test/dlp/fixtures.js +126 -0
- package/test/dlp/index.test.js +166 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/next-version.test.js +48 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec-policy.js — ALLOWLIST for shell execution (replaces the substring blocklist).
|
|
3
|
+
*
|
|
4
|
+
* A command is accepted only when every segment of it (split on && || ; | and
|
|
5
|
+
* newlines) belongs to a declared command family and its arguments validate:
|
|
6
|
+
*
|
|
7
|
+
* git-read read-only git: status, log, diff, show, rev-parse, ls-files,
|
|
8
|
+
* blame, grep, branch/tag/remote listing, config --get, fetch …
|
|
9
|
+
* pkg package-manager install / test / build / lint for an ecosystem
|
|
10
|
+
* DETECTED in the workspace (npm/yarn/pnpm, mvn/mvnw, gradle/
|
|
11
|
+
* gradlew, pip/poetry/uv, go, cargo, dotnet)
|
|
12
|
+
* test test runners (jest, vitest, mocha, pytest, node --test, …)
|
|
13
|
+
* lint linters / formatters / type checkers (eslint, prettier, tsc, …)
|
|
14
|
+
* file-read cat, head, tail, ls, find (no -exec/-delete), grep/rg, wc, …
|
|
15
|
+
* file-write mkdir, touch, cp, mv, rm, tee, redirections — inside the root
|
|
16
|
+
* nav cd <dir inside the workspace root>
|
|
17
|
+
*
|
|
18
|
+
* Shell features that turn data into code are refused outright: $VAR / $( ) /
|
|
19
|
+
* backticks, subshells and grouping, background &, process substitution,
|
|
20
|
+
* here-docs, leading ~ and comments. Every path-like argument (absolute, `..`,
|
|
21
|
+
* `~`) must resolve inside the workspace root (sandbox.js).
|
|
22
|
+
*
|
|
23
|
+
* Anything else is rejected with a reason — unless the request carries a human
|
|
24
|
+
* approval token issued by PolicyGate (REQUIRE_HUMAN) for exactly that command
|
|
25
|
+
* (approval-token.js), or it is a signed platform-authored tool command.
|
|
26
|
+
*
|
|
27
|
+
* Mode (LOCAL_AGENT_EXEC_POLICY_MODE): off | warn | enforce (default since the
|
|
28
|
+
* 2026-10-11 security review; before, warn let every non-allowlisted command run).
|
|
29
|
+
*/
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { isInsideRoot, confineToRoot } from "./sandbox.js";
|
|
33
|
+
import { resolveMode, logDecision, NonceCache } from "./request-signing.js";
|
|
34
|
+
import { verifyApprovalToken, MAX_APPROVAL_LIFETIME_SECONDS } from "./approval-token.js";
|
|
35
|
+
|
|
36
|
+
/** Single-use approval jti's, kept for the maximum approval lifetime (+ skew). */
|
|
37
|
+
export const approvalNonces = new NonceCache({ ttlMs: (MAX_APPROVAL_LIFETIME_SECONDS + 120) * 1000, max: 20_000 });
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────── tokenizer ───────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Splits a shell command into segments of argv + redirections, refusing
|
|
43
|
+
* expansion/grouping syntax. Returns { ok, segments } or { ok: false, reason }.
|
|
44
|
+
*/
|
|
45
|
+
export function tokenize(command) {
|
|
46
|
+
if (typeof command !== "string" || !command.trim()) return { ok: false, reason: "empty command" };
|
|
47
|
+
if (command.length > 8000) return { ok: false, reason: "command too long" };
|
|
48
|
+
const segments = [];
|
|
49
|
+
let argv = [];
|
|
50
|
+
let quoted = [];
|
|
51
|
+
let redirects = [];
|
|
52
|
+
let word = "";
|
|
53
|
+
let inWord = false;
|
|
54
|
+
let quotedWord = false;
|
|
55
|
+
let pendingRedirect = null;
|
|
56
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
57
|
+
|
|
58
|
+
const endWord = () => {
|
|
59
|
+
if (!inWord) return null;
|
|
60
|
+
if (pendingRedirect) {
|
|
61
|
+
pendingRedirect.target = word;
|
|
62
|
+
redirects.push(pendingRedirect);
|
|
63
|
+
pendingRedirect = null;
|
|
64
|
+
} else {
|
|
65
|
+
argv.push(word);
|
|
66
|
+
quoted.push(quotedWord);
|
|
67
|
+
}
|
|
68
|
+
word = ""; inWord = false; quotedWord = false;
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
const endSegment = (sep) => {
|
|
72
|
+
endWord();
|
|
73
|
+
if (pendingRedirect) return fail("redirection without a target");
|
|
74
|
+
if (argv.length === 0) return fail(sep ? `empty command before '${sep}'` : "empty command");
|
|
75
|
+
segments.push({ argv, quoted, redirects, sep: sep || null });
|
|
76
|
+
argv = []; quoted = []; redirects = [];
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < command.length; i++) {
|
|
81
|
+
const c = command[i];
|
|
82
|
+
const n = command[i + 1];
|
|
83
|
+
if (c === "'") {
|
|
84
|
+
const j = command.indexOf("'", i + 1);
|
|
85
|
+
if (j < 0) return fail("unterminated single quote");
|
|
86
|
+
word += command.slice(i + 1, j); inWord = true; quotedWord = true; i = j; continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === '"') {
|
|
89
|
+
let j = i + 1;
|
|
90
|
+
let buf = "";
|
|
91
|
+
for (; j < command.length && command[j] !== '"'; j++) {
|
|
92
|
+
const d = command[j];
|
|
93
|
+
if (d === "$" || d === "`") return fail("variable or command expansion is not allowed");
|
|
94
|
+
if (d === "\\" && (command[j + 1] === '"' || command[j + 1] === "\\")) { buf += command[j + 1]; j++; continue; }
|
|
95
|
+
buf += d;
|
|
96
|
+
}
|
|
97
|
+
if (j >= command.length) return fail("unterminated double quote");
|
|
98
|
+
word += buf; inWord = true; quotedWord = true; i = j; continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === "\\") {
|
|
101
|
+
if (n === undefined) return fail("trailing backslash");
|
|
102
|
+
if (n === "\n") { i++; continue; }
|
|
103
|
+
word += n; inWord = true; i++; continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === "$" || c === "`") return fail("variable or command expansion is not allowed");
|
|
106
|
+
if (c === "(" || c === ")") return fail("subshells and grouping are not allowed");
|
|
107
|
+
if (!inWord && (c === "{" || c === "}")) return fail("subshells and grouping are not allowed");
|
|
108
|
+
if (!inWord && (c === "#")) return fail("comments are not allowed");
|
|
109
|
+
if (!inWord && c === "~") return fail("home-directory (~) paths are not allowed");
|
|
110
|
+
if (!inWord && c === "!") return fail("history expansion / negation is not allowed");
|
|
111
|
+
if (c === " " || c === "\t") { endWord(); continue; }
|
|
112
|
+
if (c === "\n" || c === ";") { const e = endSegment(";"); if (e) return e; continue; }
|
|
113
|
+
if (c === "&") {
|
|
114
|
+
if (n === "&") { const e = endSegment("&&"); if (e) return e; i++; continue; }
|
|
115
|
+
if (n === ">") return fail("'&>' redirection is not allowed");
|
|
116
|
+
return fail("background execution (&) is not allowed");
|
|
117
|
+
}
|
|
118
|
+
if (c === "|") {
|
|
119
|
+
if (n === "|") { const e = endSegment("||"); if (e) return e; i++; continue; }
|
|
120
|
+
if (n === "&") return fail("'|&' is not allowed");
|
|
121
|
+
const e = endSegment("|"); if (e) return e; continue;
|
|
122
|
+
}
|
|
123
|
+
if (c === ">" || c === "<") {
|
|
124
|
+
let fd = c === "<" ? "0" : "1";
|
|
125
|
+
if (inWord && !quotedWord && /^[0-9]$/.test(word)) { fd = word; word = ""; inWord = false; }
|
|
126
|
+
else endWord();
|
|
127
|
+
if (pendingRedirect) return fail("redirection without a target");
|
|
128
|
+
if (c === "<") {
|
|
129
|
+
if (n === "<") return fail("here-documents are not allowed");
|
|
130
|
+
if (n === "(") return fail("process substitution is not allowed");
|
|
131
|
+
pendingRedirect = { fd, op: "<" };
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (n === "(") return fail("process substitution is not allowed");
|
|
135
|
+
if (n === "&") {
|
|
136
|
+
const m = command.slice(i + 2).match(/^([0-9])/);
|
|
137
|
+
if (!m) return fail("invalid fd duplication");
|
|
138
|
+
redirects.push({ fd, op: ">&", target: m[1] });
|
|
139
|
+
i += 2; continue;
|
|
140
|
+
}
|
|
141
|
+
if (n === ">") { pendingRedirect = { fd, op: ">>" }; i++; continue; }
|
|
142
|
+
if (n === "|") return fail("'>|' is not allowed");
|
|
143
|
+
pendingRedirect = { fd, op: ">" };
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
word += c; inWord = true;
|
|
147
|
+
}
|
|
148
|
+
endWord();
|
|
149
|
+
if (pendingRedirect) return fail("redirection without a target");
|
|
150
|
+
if (argv.length === 0) {
|
|
151
|
+
if (segments.length === 0) return fail("empty command");
|
|
152
|
+
return fail("command ends with an operator");
|
|
153
|
+
}
|
|
154
|
+
segments.push({ argv, quoted, redirects, sep: null });
|
|
155
|
+
return { ok: true, segments };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────── ecosystem detection ──────────────────────────
|
|
159
|
+
|
|
160
|
+
const MARKERS = [
|
|
161
|
+
["node", ["package.json"]],
|
|
162
|
+
["maven", ["pom.xml", "mvnw"]],
|
|
163
|
+
["gradle", ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradlew"]],
|
|
164
|
+
["python", ["requirements.txt", "requirements-dev.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile", "tox.ini"]],
|
|
165
|
+
["go", ["go.mod"]],
|
|
166
|
+
["rust", ["Cargo.toml"]],
|
|
167
|
+
];
|
|
168
|
+
const detectCache = new Map();
|
|
169
|
+
|
|
170
|
+
/** Ecosystems present in the root or its immediate sub-directories (multi-repo). */
|
|
171
|
+
export function detectEcosystems(root, { now = Date.now() } = {}) {
|
|
172
|
+
const hit = detectCache.get(root);
|
|
173
|
+
if (hit && hit.exp > now) return hit.set;
|
|
174
|
+
const set = new Set();
|
|
175
|
+
const scan = (dir) => {
|
|
176
|
+
let names;
|
|
177
|
+
try { names = fs.readdirSync(dir); } catch { return; }
|
|
178
|
+
for (const [eco, files] of MARKERS) if (files.some(f => names.includes(f))) set.add(eco);
|
|
179
|
+
if (names.some(f => /\.(csproj|fsproj|sln)$/.test(f))) set.add("dotnet");
|
|
180
|
+
return names;
|
|
181
|
+
};
|
|
182
|
+
const top = scan(root) || [];
|
|
183
|
+
for (const name of top) {
|
|
184
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
185
|
+
try { if (fs.statSync(path.join(root, name)).isDirectory()) scan(path.join(root, name)); } catch { /* skip */ }
|
|
186
|
+
}
|
|
187
|
+
detectCache.set(root, { set, exp: now + 60_000 });
|
|
188
|
+
if (detectCache.size > 500) detectCache.clear();
|
|
189
|
+
return set;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ──────────────────────────────── helpers ────────────────────────────────
|
|
193
|
+
|
|
194
|
+
const ok = (family) => ({ ok: true, family });
|
|
195
|
+
const no = (reason) => ({ ok: false, reason });
|
|
196
|
+
const isOpt = (a) => a.startsWith("-") && a !== "-";
|
|
197
|
+
|
|
198
|
+
// Working directories a relative path may be resolved against while one command
|
|
199
|
+
// string is evaluated: the root plus every `cd` / `git -C` target seen so far
|
|
200
|
+
// (a path must be safe against ALL of them). Evaluation is synchronous, so a
|
|
201
|
+
// module-level list is safe.
|
|
202
|
+
let CWDS = null;
|
|
203
|
+
const cwds = (root) => (CWDS && CWDS.length ? CWDS : [root]);
|
|
204
|
+
|
|
205
|
+
/** Realpath-confined check (symlinks resolved) of `t` against every possible cwd. */
|
|
206
|
+
function inside(root, t) {
|
|
207
|
+
if (t === "/dev/null") return true;
|
|
208
|
+
if (typeof t !== "string" || t.startsWith("~")) return false;
|
|
209
|
+
return cwds(root).every(c => isInsideRoot(root, path.resolve(c, t)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const GLOB = /[*?[]/;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Every non-flag token (and every --opt=value) that is path-like — absolute,
|
|
216
|
+
* containing a separator or `..`, or EXISTING on disk relative to a possible
|
|
217
|
+
* cwd — is realpath-resolved (symlinks followed) and must stay inside the
|
|
218
|
+
* root. For a glob, the directory part before the first wildcard is checked
|
|
219
|
+
* (the shell-expanded case is also checked in evaluateCommand).
|
|
220
|
+
*/
|
|
221
|
+
function pathInside(root, token) {
|
|
222
|
+
if (token === "/dev/null") return true;
|
|
223
|
+
let t = token;
|
|
224
|
+
if (t.startsWith("-")) {
|
|
225
|
+
const eq = t.indexOf("=");
|
|
226
|
+
if (eq < 0) return true;
|
|
227
|
+
t = t.slice(eq + 1);
|
|
228
|
+
}
|
|
229
|
+
if (!t) return true;
|
|
230
|
+
if (t.startsWith("~")) return false;
|
|
231
|
+
if (GLOB.test(t)) {
|
|
232
|
+
const segs = t.split(/[\\/]/);
|
|
233
|
+
const i = segs.findIndex(x => GLOB.test(x));
|
|
234
|
+
const prefix = segs.slice(0, i).join("/") || (path.isAbsolute(t) ? "/" : ".");
|
|
235
|
+
return inside(root, prefix);
|
|
236
|
+
}
|
|
237
|
+
const pathy = path.isAbsolute(t) || /[\\/]/.test(t) || t.split(/[\\/]/).includes("..")
|
|
238
|
+
|| cwds(root).some(c => { try { return fs.existsSync(path.resolve(c, t)); } catch { return false; } });
|
|
239
|
+
if (!pathy) return true;
|
|
240
|
+
return inside(root, t);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkPaths(root, args, skip = new Set()) {
|
|
244
|
+
for (let i = 0; i < args.length; i++) {
|
|
245
|
+
if (skip.has(i)) continue;
|
|
246
|
+
if (!pathInside(root, args[i])) return no(`path '${args[i]}' is outside the workspace root`);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* An UNQUOTED glob is expanded by the shell before any tool sees it, so its
|
|
253
|
+
* matches must be safe too: wildcards only in the last segment, and no
|
|
254
|
+
* symlink in that directory may resolve outside the root.
|
|
255
|
+
*/
|
|
256
|
+
function checkShellGlob(root, t) {
|
|
257
|
+
const segs = t.split("/");
|
|
258
|
+
const i = segs.findIndex(x => GLOB.test(x));
|
|
259
|
+
if (i < 0) return null;
|
|
260
|
+
if (i !== segs.length - 1) return no(`unquoted glob '${t}' may only use wildcards in its last path segment (quote it to let the tool expand it)`);
|
|
261
|
+
const dir = segs.slice(0, i).join("/") || (t.startsWith("/") ? "/" : ".");
|
|
262
|
+
if (!inside(root, dir)) return no(`path '${t}' is outside the workspace root`);
|
|
263
|
+
for (const c of cwds(root)) {
|
|
264
|
+
const abs = path.resolve(c, dir);
|
|
265
|
+
let names = [];
|
|
266
|
+
try { names = fs.readdirSync(abs); } catch { continue; }
|
|
267
|
+
for (const n of names) {
|
|
268
|
+
const full = path.join(abs, n);
|
|
269
|
+
try { if (fs.lstatSync(full).isSymbolicLink() && !isInsideRoot(root, full)) return no(`glob '${t}' matches symlink '${n}' that points outside the workspace root`); } catch { /* ignore */ }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── protected paths: writing these can make later builds/tests run attacker code ──
|
|
276
|
+
const PROTECTED_DIRS = new Set([".git", ".husky", ".githooks", ".mvn", ".gradle", ".yarn", ".cargo", ".devcontainer", ".vscode"]);
|
|
277
|
+
const PROTECTED_FILES = new Set([
|
|
278
|
+
"package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
|
279
|
+
".npmrc", ".yarnrc", ".yarnrc.yml", ".pnpmfile.cjs", "Makefile", "makefile", "GNUmakefile",
|
|
280
|
+
"pom.xml", "mvnw", "mvnw.cmd", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts",
|
|
281
|
+
"gradle.properties", "gradlew", "gradlew.bat", "init.gradle", "setup.py", "setup.cfg", "pyproject.toml",
|
|
282
|
+
"conftest.py", "tox.ini", "noxfile.py", "pytest.ini", "Pipfile", "requirements.txt", "requirements-dev.txt",
|
|
283
|
+
"Cargo.toml", "build.rs", ".cargo", "Directory.Build.props", "Directory.Build.targets", "global.json",
|
|
284
|
+
"NuGet.config", "nuget.config", ".pre-commit-config.yaml", ".envrc", ".babelrc", "Dockerfile", "docker-compose.yml",
|
|
285
|
+
"go.mod", "go.work", "Taskfile.yml", "justfile", "Rakefile", "Gemfile",
|
|
286
|
+
]);
|
|
287
|
+
const PROTECTED_FILE_RE = /^(\.?(jest|vitest|vite|webpack|rollup|babel|eslint|prettier|playwright|cypress|karma|nyc|c8|mocha|ava|tsup|esbuild|stylelint|postcss|tailwind|next|nuxt|svelte|astro)(rc)?(\.config)?(\.[\w-]+)*\.(c|m)?(j|t)sx?|\.(eslintrc|prettierrc|mocharc|babelrc|nycrc|c8rc|lintstagedrc|huskyrc)(\..+)?|.*\.(csproj|fsproj|vbproj|props|targets|gradle|gradle\.kts))$/;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* True when `t` (realpath-resolved, symlinks followed) is inside .git/, a hook
|
|
291
|
+
* dir, or is build/test configuration that executes code. Such files may only
|
|
292
|
+
* change through the platform's signed apply-patch tool, never through exec.
|
|
293
|
+
*/
|
|
294
|
+
function isProtected(root, t) {
|
|
295
|
+
let realRoot;
|
|
296
|
+
try { realRoot = confineToRoot(root, "."); } catch { return true; }
|
|
297
|
+
return cwds(root).some(c => {
|
|
298
|
+
let real;
|
|
299
|
+
try { real = confineToRoot(root, path.resolve(c, t)); } catch { return true; }
|
|
300
|
+
const rel = path.relative(realRoot, real);
|
|
301
|
+
if (!rel) return false;
|
|
302
|
+
const parts = rel.split(path.sep);
|
|
303
|
+
if (parts.some(x => PROTECTED_DIRS.has(x))) return true;
|
|
304
|
+
for (let i = 0; i + 1 < parts.length; i++) if (parts[i] === "gradle" && parts[i + 1] === "wrapper") return true;
|
|
305
|
+
const base = parts[parts.length - 1];
|
|
306
|
+
return PROTECTED_FILES.has(base) || PROTECTED_FILE_RE.test(base);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function writeTargetError(root, tool, t) {
|
|
311
|
+
if (!inside(root, t)) return no(`${tool} target '${t}' is outside the workspace root`);
|
|
312
|
+
if (isProtected(root, t)) return no(`${tool} may not modify '${t}' (.git/, hooks and build/test configuration can only change through the signed apply-patch tool)`);
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const NPM_SCRIPT = /^(test|tests|build|lint|typecheck|type-check|check|compile|format|fmt|prettier|eslint|tsc|verify|coverage|ci)([:._-][\w:.-]+)?$/;
|
|
317
|
+
const REGISTRY_PKG = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.^~<>=*|-]+)?$/i;
|
|
318
|
+
const PY_PKG = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,._-]+\])?([<>=!~]=?[A-Za-z0-9.*+!-]+(,[<>=!~]=?[A-Za-z0-9.*+!-]+)*)?$/;
|
|
319
|
+
const TEST_TOOLS = new Set(["jest", "vitest", "mocha", "ava", "playwright", "cypress", "karma"]);
|
|
320
|
+
/** Coverage tools WRAP another command: that command is validated recursively. */
|
|
321
|
+
const COVERAGE_WRAPPERS = new Set(["c8", "nyc"]);
|
|
322
|
+
const LINT_TOOLS = new Set(["eslint", "prettier", "tsc", "stylelint", "biome", "vue-tsc", "ts-prune", "depcheck"]);
|
|
323
|
+
|
|
324
|
+
// ───────────────────────────── family: git ─────────────────────────────
|
|
325
|
+
|
|
326
|
+
const GIT_DANGEROUS_OPT = /^(--output|--ext-diff|--textconv|--upload-pack|--receive-pack|--exec|--config-env|--open-files-in-pager|-O|--template|--run|--git-dir|--work-tree|--namespace|--super-prefix)(=|$)/;
|
|
327
|
+
const GIT_SAFE_CONFIG = /^(core\.quotepath|color\.[\w.]+|core\.abbrev|log\.showsignature|diff\.renames|i18n\.logoutputencoding)=[\w.-]*$/i;
|
|
328
|
+
const GIT_READ = new Set(["status", "log", "diff", "show", "rev-parse", "ls-files", "ls-tree", "blame", "describe",
|
|
329
|
+
"shortlog", "grep", "cat-file", "for-each-ref", "merge-base", "rev-list", "show-ref", "name-rev", "whatchanged",
|
|
330
|
+
"diff-tree", "diff-files", "diff-index", "count-objects", "version", "help", "var"]);
|
|
331
|
+
const GIT_BRANCH_WRITE = /^(-d|-D|-m|-M|-c|-C|-f|-u|--delete|--move|--copy|--force|--set-upstream-to|--unset-upstream|--edit-description|--track|--no-track|--create-reflog)(=|$)/;
|
|
332
|
+
const GIT_BRANCH_LIST = /^(-l|--list|--contains|--no-contains|--merged|--no-merged|--points-at|--format|--sort)(=|$)/;
|
|
333
|
+
|
|
334
|
+
function gitFamily(argv, root) {
|
|
335
|
+
let i = 1;
|
|
336
|
+
for (; i < argv.length; i++) {
|
|
337
|
+
const a = argv[i];
|
|
338
|
+
if (a === "-C") {
|
|
339
|
+
if (!argv[i + 1] || !inside(root, argv[i + 1])) return no("git -C must point inside the workspace root");
|
|
340
|
+
CWDS = [...cwds(root), ...cwds(root).map(c => path.resolve(c, argv[i + 1]))];
|
|
341
|
+
i++; continue;
|
|
342
|
+
}
|
|
343
|
+
if (a === "-c") { if (!GIT_SAFE_CONFIG.test(argv[i + 1] || "")) return no(`git -c ${argv[i + 1] || ""} is not allowed`); i++; continue; }
|
|
344
|
+
if (a === "--no-pager" || a === "--no-optional-locks" || a === "-P") continue;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
const sub = argv[i];
|
|
348
|
+
const rest = argv.slice(i + 1);
|
|
349
|
+
if (!sub) return no("git without a subcommand");
|
|
350
|
+
for (const a of rest) if (GIT_DANGEROUS_OPT.test(a)) return no(`git option ${a.split("=")[0]} is not allowed`);
|
|
351
|
+
const pathErr = checkPaths(root, rest, sub === "grep" || sub === "log" ? patternIndexes(rest) : new Set());
|
|
352
|
+
if (pathErr) return pathErr;
|
|
353
|
+
if (GIT_READ.has(sub)) return ok("git-read");
|
|
354
|
+
switch (sub) {
|
|
355
|
+
case "branch": {
|
|
356
|
+
if (rest.some(a => GIT_BRANCH_WRITE.test(a))) return no("git branch modification requires approval");
|
|
357
|
+
const positional = rest.filter(a => !isOpt(a));
|
|
358
|
+
if (positional.length && !rest.some(a => GIT_BRANCH_LIST.test(a))) return no("creating a branch requires approval");
|
|
359
|
+
return ok("git-read");
|
|
360
|
+
}
|
|
361
|
+
case "tag": {
|
|
362
|
+
if (rest.some(a => /^(-d|--delete|-f|--force|-a|--annotate|-s|--sign|-m|--message|-F|--file)(=|$)/.test(a))) return no("creating or deleting tags requires approval");
|
|
363
|
+
const listing = rest.some(a => /^(-l|--list)$/.test(a));
|
|
364
|
+
if (listing || rest.every(a => isOpt(a))) return ok("git-read");
|
|
365
|
+
return no("creating tags requires approval");
|
|
366
|
+
}
|
|
367
|
+
case "remote":
|
|
368
|
+
if (rest.length === 0 || (rest.length === 1 && rest[0] === "-v")) return ok("git-read");
|
|
369
|
+
if ((rest[0] === "get-url" || rest[0] === "show") && rest.length <= 3) return ok("git-read");
|
|
370
|
+
return no("changing git remotes requires approval");
|
|
371
|
+
case "config":
|
|
372
|
+
if (rest.some(a => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(a)) && !rest.some(a => /^(--add|--unset|--unset-all|--replace-all|--edit|-e|--rename-section|--remove-section)$/.test(a)))
|
|
373
|
+
return ok("git-read");
|
|
374
|
+
return no("writing git config requires approval");
|
|
375
|
+
case "stash":
|
|
376
|
+
if (rest[0] === "list" || rest[0] === "show") return ok("git-read");
|
|
377
|
+
return no("git stash modification requires approval");
|
|
378
|
+
case "reflog":
|
|
379
|
+
if (rest.length === 0 || rest[0] === "show") return ok("git-read");
|
|
380
|
+
return no("git reflog modification requires approval");
|
|
381
|
+
case "worktree":
|
|
382
|
+
if (rest[0] === "list") return ok("git-read");
|
|
383
|
+
return no("git worktree modification requires approval");
|
|
384
|
+
case "fetch":
|
|
385
|
+
if (rest.every(a => /^(--all|--prune|-p|--tags|--no-tags|--quiet|-q|--depth=\d+|--unshallow|--dry-run|--verbose|-v)$/.test(a) || !isOpt(a)))
|
|
386
|
+
return ok("git-read");
|
|
387
|
+
return no("git fetch option not allowed");
|
|
388
|
+
default:
|
|
389
|
+
return no(`git ${sub} is not a read-only operation (requires approval)`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** For grep-like tools: index of the pattern positional (not a path). */
|
|
394
|
+
function patternIndexes(args) {
|
|
395
|
+
const skip = new Set();
|
|
396
|
+
for (let i = 0; i < args.length; i++) {
|
|
397
|
+
if (args[i] === "-e" || args[i] === "--regexp" || args[i] === "-f" || args[i] === "--grep" || args[i] === "--author" || args[i] === "-S" || args[i] === "-G") { skip.add(i + 1); continue; }
|
|
398
|
+
if (/^--(grep|author|committer|format|pretty|regexp)=/.test(args[i])) { skip.add(i); continue; }
|
|
399
|
+
}
|
|
400
|
+
if (![...skip].length) {
|
|
401
|
+
const first = args.findIndex(a => !isOpt(a));
|
|
402
|
+
if (first >= 0) skip.add(first);
|
|
403
|
+
}
|
|
404
|
+
return skip;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─────────────────────────── family: packages ───────────────────────────
|
|
408
|
+
|
|
409
|
+
function needEco(ecos, eco, tool) {
|
|
410
|
+
return ecos.has(eco) ? null : no(`${tool} is only allowed in a workspace with a detected ${eco} project`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Node runtime flags that load or evaluate arbitrary code (or open a debugger),
|
|
415
|
+
* refused anywhere in a package-manager / test-runner command line — including
|
|
416
|
+
* after `--`, where npm/yarn/pnpm pass them through to the script.
|
|
417
|
+
*/
|
|
418
|
+
const NODE_RUNTIME_FLAG = /^(--require|-r|--import|--loader|--experimental-loader|--inspect|--inspect-brk|--inspect-port|--inspect-wait|--eval|-e|--print|-p|--experimental-vm-modules|--experimental-policy|--env-file|--node-options|--preserve-symlinks-main)(=|$)/;
|
|
419
|
+
function nodeRuntimeFlag(args) {
|
|
420
|
+
const bad = args.find(a => NODE_RUNTIME_FLAG.test(a));
|
|
421
|
+
return bad ? no(`node runtime flag ${bad.split("=")[0]} is not allowed (it loads or evaluates arbitrary code)`) : null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function npmFamily(argv, root, ecos) {
|
|
425
|
+
const tool = path.basename(argv[0]);
|
|
426
|
+
const e = needEco(ecos, "node", tool); if (e) return e;
|
|
427
|
+
const [sub, ...rest] = argv.slice(1);
|
|
428
|
+
const dd = rest.indexOf("--");
|
|
429
|
+
const own = dd >= 0 ? rest.slice(0, dd) : rest;
|
|
430
|
+
const pathErr = checkPaths(root, rest); if (pathErr) return pathErr;
|
|
431
|
+
if (own.some(a => /^(--registry|--userconfig|--globalconfig|--prefix|-g|--global|--location=global|--script-shell|--node-options)(=|$)/.test(a)))
|
|
432
|
+
return no(`${tool} option not allowed`);
|
|
433
|
+
const namedPkgs = (args) => args.filter(a => !isOpt(a));
|
|
434
|
+
const rt = nodeRuntimeFlag(rest); if (rt) return rt;
|
|
435
|
+
const isScript = (s) => NPM_SCRIPT.test(s || "");
|
|
436
|
+
if (!sub && tool === "yarn") return ok("pkg");
|
|
437
|
+
switch (sub) {
|
|
438
|
+
case "ci": case "install": case "i": case "add":
|
|
439
|
+
// Only lockfile/manifest installs; adding a named package is a new
|
|
440
|
+
// (unpinned) dependency -> PolicyGate ADD_UNPINNED_DEPENDENCY approval.
|
|
441
|
+
if (sub === "add" || namedPkgs(own).length) return no(`installing a named package requires approval (ADD_UNPINNED_DEPENDENCY): ${namedPkgs(own).join(" ") || sub}`);
|
|
442
|
+
return ok("pkg");
|
|
443
|
+
case "test": case "t": case "build": case "lint":
|
|
444
|
+
return ok("pkg");
|
|
445
|
+
case "run": case "run-script":
|
|
446
|
+
return isScript(own.find(a => !isOpt(a))) ? ok("pkg") : no(`${tool} run: only test/build/lint/typecheck-style scripts are allowed`);
|
|
447
|
+
case "ls": case "list": case "outdated": case "why": case "-v": case "--version": case "view": case "info":
|
|
448
|
+
return ok("pkg");
|
|
449
|
+
case "audit":
|
|
450
|
+
return own.includes("fix") ? no("npm audit fix requires approval") : ok("pkg");
|
|
451
|
+
case "exec": case "dlx": {
|
|
452
|
+
const i = own.findIndex(a => !isOpt(a));
|
|
453
|
+
const wrapped = i >= 0 ? own.slice(i).concat(dd >= 0 ? rest.slice(dd + 1) : []) : (dd >= 0 ? rest.slice(dd + 1) : []);
|
|
454
|
+
if (!wrapped.length) return no(`${tool} ${sub} needs a command`);
|
|
455
|
+
const name = path.basename(wrapped[0]);
|
|
456
|
+
if (!TEST_TOOLS.has(name) && !LINT_TOOLS.has(name) && !COVERAGE_WRAPPERS.has(name)) return no(`${tool} ${sub}: ${wrapped[0]} is not an allowed test runner or linter`);
|
|
457
|
+
return evaluateArgv(wrapped, root, ecos, 1);
|
|
458
|
+
}
|
|
459
|
+
default:
|
|
460
|
+
if (tool === "yarn" && isScript(sub)) return ok("pkg");
|
|
461
|
+
return no(`${tool} ${sub} is not an allowed package-manager operation`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* npx <tool> …: the tool must itself be an allowed test runner / linter (or
|
|
467
|
+
* wrapper), and the WHOLE wrapped command is validated recursively.
|
|
468
|
+
*/
|
|
469
|
+
function npxFamily(argv, root, ecos, depth) {
|
|
470
|
+
const e = needEco(ecos, "node", "npx"); if (e) return e;
|
|
471
|
+
const rest = argv.slice(1);
|
|
472
|
+
let i = 0;
|
|
473
|
+
for (; i < rest.length && isOpt(rest[i]); i++) {
|
|
474
|
+
if (/^(-p|--package|-c|--call)(=|$)/.test(rest[i])) return no("npx --package/--call is not allowed");
|
|
475
|
+
if (!/^(--no-install|--no|-y|--yes|--quiet|-q)$/.test(rest[i])) return no(`npx option ${rest[i]} is not allowed`);
|
|
476
|
+
}
|
|
477
|
+
if (i >= rest.length) return no("npx needs a command");
|
|
478
|
+
const wrapped = rest.slice(i);
|
|
479
|
+
wrapped[0] = wrapped[0].replace(/^(@?[^@]+)@[^/]*$/, "$1");
|
|
480
|
+
const name = path.basename(wrapped[0]);
|
|
481
|
+
if (!TEST_TOOLS.has(name) && !LINT_TOOLS.has(name) && !COVERAGE_WRAPPERS.has(name)) return no(`${rest[i]} is not an allowed test runner or linter`);
|
|
482
|
+
return evaluateArgv(wrapped, root, ecos, depth + 1);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const MVN_GOAL = /^(clean|validate|compile|test-compile|test|package|verify|install|dependency:(tree|resolve|list|analyze|go-offline)|help:effective-pom|checkstyle:check|spotless:(check|apply)|surefire:test|failsafe:integration-test|jacoco:report|versions:display-dependency-updates|pmd:check|spotbugs:check)$/;
|
|
486
|
+
function mavenFamily(argv, root, ecos) {
|
|
487
|
+
const e = needEco(ecos, "maven", "maven"); if (e) return e;
|
|
488
|
+
const args = argv.slice(1);
|
|
489
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
490
|
+
for (let i = 0; i < args.length; i++) {
|
|
491
|
+
const a = args[i];
|
|
492
|
+
if (/^-D/.test(a)) {
|
|
493
|
+
const prop = a.slice(2).split("=")[0];
|
|
494
|
+
if (!/^[\w.-]+$/.test(prop) || /^(exec|maven\.repo\.local|settings|maven\.ext|env\.)/.test(prop)) return no(`maven property ${prop} is not allowed`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (/^(-q|--quiet|-B|--batch-mode|-e|--errors|-X|--debug|-o|--offline|-U|--update-snapshots|-am|--also-make|-amd|--also-make-dependents|-ntp|--no-transfer-progress|-fae|-ff|-V|-v|--version)$/.test(a)) continue;
|
|
498
|
+
if (/^(-T|--threads|-pl|--projects|-P|--activate-profiles|-f|--file|-rf|--resume-from)$/.test(a)) { i++; continue; }
|
|
499
|
+
if (/^(-s|--settings|-gs|--global-settings|-t|--toolchains)$/.test(a)) return no("custom maven settings are not allowed");
|
|
500
|
+
if (isOpt(a)) return no(`maven option ${a} is not allowed`);
|
|
501
|
+
if (!MVN_GOAL.test(a)) return no(`maven goal ${a} is not allowed`);
|
|
502
|
+
}
|
|
503
|
+
return ok("pkg");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const GRADLE_TASK = /^(:?[\w.-]+:)*(build|assemble|test|check|clean|compile\w*|classes|testClasses|jar|bootJar|lint\w*|dependencies|dependencyInsight|projects|tasks|properties|spotlessCheck|spotlessApply|ktlintCheck|ktlintFormat|detekt|jacocoTestReport|integrationTest|javadoc)$/;
|
|
507
|
+
function gradleFamily(argv, root, ecos) {
|
|
508
|
+
const e = needEco(ecos, "gradle", "gradle"); if (e) return e;
|
|
509
|
+
const args = argv.slice(1);
|
|
510
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
511
|
+
for (let i = 0; i < args.length; i++) {
|
|
512
|
+
const a = args[i];
|
|
513
|
+
if (/^(-I|--init-script|-c|--settings-file|-g|--gradle-user-home|--project-cache-dir)(=|$)/.test(a)) return no(`gradle option ${a} is not allowed`);
|
|
514
|
+
if (/^-[DP]/.test(a)) { if (!/^-[DP][\w.-]+(=[^\s]*)?$/.test(a)) return no(`gradle property ${a} is not allowed`); continue; }
|
|
515
|
+
if (/^(--tests|-x|--exclude-task|-p|--project-dir|--dependency|--configuration|--console|--max-workers)$/.test(a)) { i++; continue; }
|
|
516
|
+
if (/^(--no-daemon|--daemon|--offline|-q|--quiet|--info|-i|--debug|-d|--stacktrace|-s|--scan|--no-scan|--build-cache|--no-build-cache|--parallel|--continue|--refresh-dependencies|--warning-mode=\w+|--console=\w+|--max-workers=\d+|--version|-v|--rerun-tasks)$/.test(a)) continue;
|
|
517
|
+
if (isOpt(a)) return no(`gradle option ${a} is not allowed`);
|
|
518
|
+
if (!GRADLE_TASK.test(a)) return no(`gradle task ${a} is not allowed`);
|
|
519
|
+
}
|
|
520
|
+
return ok("pkg");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function pipArgs(args, root) {
|
|
524
|
+
const [sub, ...rest] = args;
|
|
525
|
+
if (rest.some(a => /^(-i|--index-url|--extra-index-url|-f|--find-links|--trusted-host|--target|-t|--prefix|--root|--user|--src|--config-settings|-C)(=|$)/.test(a)))
|
|
526
|
+
return no("pip index/target options are not allowed");
|
|
527
|
+
const pathErr = checkPaths(root, rest); if (pathErr) return pathErr;
|
|
528
|
+
switch (sub) {
|
|
529
|
+
case "list": case "show": case "freeze": case "check": case "--version": case "-V":
|
|
530
|
+
return ok("pkg");
|
|
531
|
+
case "install": {
|
|
532
|
+
for (let i = 0; i < rest.length; i++) {
|
|
533
|
+
const a = rest[i];
|
|
534
|
+
if (a === "-r" || a === "--requirement" || a === "-c" || a === "--constraint") { i++; continue; }
|
|
535
|
+
if (a === "-e" || a === "--editable") { const t = rest[i + 1]; if (!t || /:\/\//.test(t) || !inside(root, t)) return no("pip install -e must point inside the workspace"); i++; continue; }
|
|
536
|
+
if (/^(-U|--upgrade|-q|--quiet|--no-deps|--no-cache-dir|--pre|--no-input|--disable-pip-version-check|--break-system-packages|--require-hashes)$/.test(a)) continue;
|
|
537
|
+
if (isOpt(a)) return no(`pip option ${a} is not allowed`);
|
|
538
|
+
if (a === "." ) continue;
|
|
539
|
+
if (!PY_PKG.test(a)) return no(`'${a}' is not a plain package requirement (URLs / VCS / files are not allowed)`);
|
|
540
|
+
return no(`installing a named package requires approval (ADD_UNPINNED_DEPENDENCY): ${a}`);
|
|
541
|
+
}
|
|
542
|
+
return ok("pkg");
|
|
543
|
+
}
|
|
544
|
+
default:
|
|
545
|
+
return no(`pip ${sub || ""} is not allowed`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function pythonFamily(argv, root, ecos) {
|
|
550
|
+
const tool = path.basename(argv[0]);
|
|
551
|
+
const e = needEco(ecos, "python", tool); if (e) return e;
|
|
552
|
+
const args = argv.slice(1);
|
|
553
|
+
if (/^pip3?$/.test(tool)) return pipArgs(args, root);
|
|
554
|
+
if (/^python(3(\.\d+)?)?$/.test(tool)) {
|
|
555
|
+
if (args.length === 1 && /^(-V|--version)$/.test(args[0])) return ok("pkg");
|
|
556
|
+
if (args[0] !== "-m") return no("python may only run allowed modules (python -m pytest|unittest|pip|mypy|ruff|…)");
|
|
557
|
+
const mod = args[1];
|
|
558
|
+
const rest = args.slice(2);
|
|
559
|
+
if (mod === "pip") return pipArgs(rest, root);
|
|
560
|
+
const pathErr = checkPaths(root, rest); if (pathErr) return pathErr;
|
|
561
|
+
if (/^(pytest|unittest|mypy|ruff|flake8|black|pylint|isort|compileall|py_compile|coverage|tox|nox)$/.test(mod || "")) return ok(/^(pytest|unittest|coverage|tox|nox)$/.test(mod) ? "test" : "lint");
|
|
562
|
+
return no(`python -m ${mod || ""} is not allowed`);
|
|
563
|
+
}
|
|
564
|
+
if (tool === "poetry" || tool === "uv") {
|
|
565
|
+
// only the subcommand's own options here; `run` validates its command recursively
|
|
566
|
+
const own = args[0] === "run" ? [] : args;
|
|
567
|
+
const pathErr = checkPaths(root, own); if (pathErr) return pathErr;
|
|
568
|
+
} else {
|
|
569
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
570
|
+
}
|
|
571
|
+
if (tool === "poetry") {
|
|
572
|
+
if (/^(install|check|show|lock|--version)$/.test(args[0] || "")) return ok("pkg");
|
|
573
|
+
if (args[0] === "run") return args.length > 1 ? evaluateArgv(args.slice(1), root, ecos, 1) : no("poetry run needs a command");
|
|
574
|
+
return no(`poetry ${args[0] || ""} is not allowed`);
|
|
575
|
+
}
|
|
576
|
+
if (tool === "uv") {
|
|
577
|
+
if (/^(sync|lock|--version)$/.test(args[0] || "")) return ok("pkg");
|
|
578
|
+
if (args[0] === "pip") return pipArgs(args.slice(1), root);
|
|
579
|
+
if (args[0] === "run") return args.length > 1 ? evaluateArgv(args.slice(1), root, ecos, 1) : no("uv run needs a command");
|
|
580
|
+
return no(`uv ${args[0] || ""} is not allowed`);
|
|
581
|
+
}
|
|
582
|
+
if (tool === "tox" || tool === "nox") return ok("test");
|
|
583
|
+
return no(`${tool} is not allowed`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function goFamily(argv, root, ecos) {
|
|
587
|
+
const e = needEco(ecos, "go", "go"); if (e) return e;
|
|
588
|
+
const [sub, ...rest] = argv.slice(1);
|
|
589
|
+
if (rest.some(a => /^-(exec|toolexec|overlay|modfile)(=|$)/.test(a))) return no("go -exec/-toolexec/-overlay are not allowed");
|
|
590
|
+
const pathErr = checkPaths(root, rest); if (pathErr) return pathErr;
|
|
591
|
+
if (/^(build|test|vet|list|version|env|fmt)$/.test(sub || "")) return ok(sub === "test" ? "test" : "pkg");
|
|
592
|
+
if (sub === "mod" && /^(download|tidy|verify|graph|why)$/.test(rest[0] || "")) return ok("pkg");
|
|
593
|
+
return no(`go ${sub || ""} is not allowed`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function cargoFamily(argv, root, ecos) {
|
|
597
|
+
const e = needEco(ecos, "rust", "cargo"); if (e) return e;
|
|
598
|
+
const [sub, ...rest] = argv.slice(1);
|
|
599
|
+
if (rest.some(a => /^--config(=|$)/.test(a))) return no("cargo --config is not allowed");
|
|
600
|
+
const pathErr = checkPaths(root, rest); if (pathErr) return pathErr;
|
|
601
|
+
if (/^(build|test|check|clippy|fmt|doc|tree|metadata|--version|fetch|bench)$/.test(sub || "")) return ok(sub === "test" ? "test" : "pkg");
|
|
602
|
+
return no(`cargo ${sub || ""} is not allowed`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function dotnetFamily(argv, root, ecos) {
|
|
606
|
+
const e = needEco(ecos, "dotnet", "dotnet"); if (e) return e;
|
|
607
|
+
const [sub, ...rest] = argv.slice(1);
|
|
608
|
+
if (rest.some(a => /^(-s|--source|--configfile)(=|$)/.test(a))) return no("custom NuGet sources are not allowed");
|
|
609
|
+
const pathErr = checkPaths(root, rest); if (pathErr) return pathErr;
|
|
610
|
+
if (/^(build|test|restore|format|--info|--version|clean)$/.test(sub || "")) return ok(sub === "test" ? "test" : "pkg");
|
|
611
|
+
if (sub === "list" && rest.includes("package")) return ok("pkg");
|
|
612
|
+
return no(`dotnet ${sub || ""} is not allowed`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** A tool option / argument that points into .git/ or a hook dir (e.g. --outputFile=.git/hooks/x). */
|
|
616
|
+
function toolingTargetsProtectedDir(root, args) {
|
|
617
|
+
for (const a of args) {
|
|
618
|
+
const v = a.startsWith("-") ? (a.includes("=") ? a.slice(a.indexOf("=") + 1) : "") : a;
|
|
619
|
+
if (!v || !/[\\/]|^\.(git|husky|githooks)$/.test(v)) continue;
|
|
620
|
+
let real;
|
|
621
|
+
try { real = confineToRoot(root, path.resolve(cwds(root)[0], v)); } catch { continue; }
|
|
622
|
+
const rel = path.relative(confineToRoot(root, "."), real).split(path.sep);
|
|
623
|
+
if (rel.some(x => x === ".git" || x === ".husky" || x === ".githooks")) return no(`'${a}' points into .git/ or a hook directory`);
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ─────────────────────────── wrappers ───────────────────────────
|
|
629
|
+
// A wrapper (env, time, nice, timeout, c8, nyc, npx, poetry/uv run, npm exec)
|
|
630
|
+
// runs ANOTHER command: that command is validated recursively against the
|
|
631
|
+
// same policy. xargs builds its command from stdin and cannot be validated.
|
|
632
|
+
|
|
633
|
+
const MAX_WRAP_DEPTH = 4;
|
|
634
|
+
const COVERAGE_VALUE_FLAGS = /^(-r|--reporter|-o|--report-dir|--reports-dir|--temp-directory|--tempDirectory|-x|--exclude|-n|--include|-e|--extension|--src|--cwd|--config|--nycrc-path|--lines|--functions|--branches|--statements|--per-file|--check-coverage|--100|--skip-full|--all|--clean|--exclude-after-remap|--exclude-node-modules)$/;
|
|
635
|
+
const COVERAGE_BOOL_FLAGS = /^(--per-file|--check-coverage|--100|--skip-full|--all|--clean|--exclude-after-remap|--silent|--no-clean|--exclude-node-modules|--source-map|--produce-source-map|--instrument|--cache|--no-cache|-h|--help|-v|--version)$/;
|
|
636
|
+
|
|
637
|
+
function wrapperFamily(a, root, ecos, depth) {
|
|
638
|
+
const base = path.basename(a[0]);
|
|
639
|
+
const args = a.slice(1);
|
|
640
|
+
const recurse = (wrapped, what) => {
|
|
641
|
+
if (!wrapped.length) return no(`${base} needs a command to run`);
|
|
642
|
+
if (depth >= MAX_WRAP_DEPTH) return no("too many nested wrapper commands");
|
|
643
|
+
const r = evaluateArgv(wrapped, root, ecos, depth + 1);
|
|
644
|
+
return r.ok ? r : no(`${what || base} runs a command that is not allowed: ${r.reason}`);
|
|
645
|
+
};
|
|
646
|
+
switch (base) {
|
|
647
|
+
case "xargs":
|
|
648
|
+
return no("xargs builds commands from its input, which cannot be validated");
|
|
649
|
+
case "env": {
|
|
650
|
+
let i = 0;
|
|
651
|
+
for (; i < args.length; i++) {
|
|
652
|
+
const x = args[i];
|
|
653
|
+
if (x === "-i" || x === "--ignore-environment" || x === "-") continue;
|
|
654
|
+
if (x === "-u" || x === "--unset") { i++; continue; }
|
|
655
|
+
if (/^--unset=/.test(x)) continue;
|
|
656
|
+
if (isOpt(x)) return no(`env option ${x} is not allowed`);
|
|
657
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(x)) { if (!ENV_OK.test(x)) return no(`environment assignment ${x.split("=")[0]}= is not allowed`); continue; }
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
return recurse(args.slice(i));
|
|
661
|
+
}
|
|
662
|
+
case "time": {
|
|
663
|
+
let i = 0;
|
|
664
|
+
while (i < args.length && /^(-p|--portability)$/.test(args[i])) i++;
|
|
665
|
+
return recurse(args.slice(i));
|
|
666
|
+
}
|
|
667
|
+
case "nice": {
|
|
668
|
+
let i = 0;
|
|
669
|
+
for (; i < args.length; i++) {
|
|
670
|
+
if (args[i] === "-n" || args[i] === "--adjustment") { i++; continue; }
|
|
671
|
+
if (/^(-\d+|-n\d+|--adjustment=-?\d+)$/.test(args[i])) continue;
|
|
672
|
+
if (isOpt(args[i])) return no(`nice option ${args[i]} is not allowed`);
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
return recurse(args.slice(i));
|
|
676
|
+
}
|
|
677
|
+
case "timeout": {
|
|
678
|
+
let i = 0;
|
|
679
|
+
for (; i < args.length; i++) {
|
|
680
|
+
if (args[i] === "-s" || args[i] === "--signal" || args[i] === "-k" || args[i] === "--kill-after") { i++; continue; }
|
|
681
|
+
if (/^(--preserve-status|--foreground|-v|--verbose|--signal=\w+|--kill-after=[\d.]+[smhd]?)$/.test(args[i])) continue;
|
|
682
|
+
if (isOpt(args[i])) return no(`timeout option ${args[i]} is not allowed`);
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
if (!/^[\d.]+[smhd]?$/.test(args[i] || "")) return no("timeout needs a duration");
|
|
686
|
+
return recurse(args.slice(i + 1));
|
|
687
|
+
}
|
|
688
|
+
case "c8": case "nyc": {
|
|
689
|
+
if (!ecos.has("node")) return no(`${base} is only allowed in a workspace with a detected node project`);
|
|
690
|
+
let i = 0;
|
|
691
|
+
for (; i < args.length; i++) {
|
|
692
|
+
const x = args[i];
|
|
693
|
+
if (!isOpt(x)) break;
|
|
694
|
+
if (/^--[\w-]+=/.test(x) || /^-[a-zA-Z]=/.test(x)) { const pe = checkPaths(root, [x]); if (pe) return pe; continue; }
|
|
695
|
+
if (COVERAGE_BOOL_FLAGS.test(x)) continue;
|
|
696
|
+
if (COVERAGE_VALUE_FLAGS.test(x)) { const pe = checkPaths(root, [args[i + 1] || ""]); if (pe) return pe; i++; continue; }
|
|
697
|
+
return no(`${base} option ${x} is not allowed`);
|
|
698
|
+
}
|
|
699
|
+
const sub = args[i];
|
|
700
|
+
if (sub === "report" || sub === "check-coverage") {
|
|
701
|
+
const pe = checkPaths(root, args.slice(i + 1)); if (pe) return pe;
|
|
702
|
+
return ok("test");
|
|
703
|
+
}
|
|
704
|
+
const r = recurse(args.slice(i));
|
|
705
|
+
return r.ok ? ok("test") : r;
|
|
706
|
+
}
|
|
707
|
+
default:
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Test runners / linters invoked directly (their binary must exist). */
|
|
713
|
+
function standaloneRunner(argv, root, ecos) {
|
|
714
|
+
if (!argv.length) return no("empty command");
|
|
715
|
+
const tool = path.basename(argv[0]);
|
|
716
|
+
const args = argv.slice(1);
|
|
717
|
+
if (tool === "node") {
|
|
718
|
+
if (args.length === 1 && /^(-v|--version)$/.test(args[0])) return ok("pkg");
|
|
719
|
+
if (!args.includes("--test")) return no("node may only run the built-in test runner (node --test)");
|
|
720
|
+
if (args.some(a => /^(-e|--eval|-p|--print|-r|--require|--import|--loader|--experimental-loader)(=|$)/.test(a))) return no("node eval/preload options are not allowed");
|
|
721
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
722
|
+
return ok("test");
|
|
723
|
+
}
|
|
724
|
+
if (TEST_TOOLS.has(tool) || /^(pytest|py\.test)$/.test(tool)) {
|
|
725
|
+
if (args.some(a => /^[{[]/.test(a) || /^--[\w-]+=[{[]/.test(a))) return no(`${tool}: inline JSON configuration is not allowed`);
|
|
726
|
+
if (tool !== "pytest" && tool !== "py.test") { const rt = nodeRuntimeFlag(args); if (rt) return rt; }
|
|
727
|
+
if (tool === "pytest" || tool === "py.test") {
|
|
728
|
+
if (args.some(a => /^(-p|--pyargs|--rootdir|--confcutdir|-c|--config-file)(=|$)/.test(a))) return no("pytest -p/-c/--rootdir are not allowed");
|
|
729
|
+
}
|
|
730
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
731
|
+
const hook = toolingTargetsProtectedDir(root, args); if (hook) return hook;
|
|
732
|
+
return ok("test");
|
|
733
|
+
}
|
|
734
|
+
if (LINT_TOOLS.has(tool) || /^(ruff|flake8|black|mypy|pylint|isort|golangci-lint|gofmt|shellcheck|ktlint|hadolint|yamllint|markdownlint)$/.test(tool)) {
|
|
735
|
+
if (args.some(a => /^(--rulesdir|--resolve-plugins-relative-to|--plugin)(=|$)/.test(a) && tool === "eslint")) return no("eslint plugin/rules-dir options are not allowed");
|
|
736
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
737
|
+
const hook = toolingTargetsProtectedDir(root, args); if (hook) return hook;
|
|
738
|
+
return ok("lint");
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ─────────────────────────── family: files ───────────────────────────
|
|
744
|
+
|
|
745
|
+
const READ_TOOLS = new Set(["cat", "head", "tail", "wc", "ls", "tree", "stat", "file", "du", "diff", "cmp", "pwd",
|
|
746
|
+
"echo", "basename", "dirname", "sort", "uniq", "cut", "tr", "jq", "nl", "true", "which", "column", "md5sum", "sha256sum", "shasum"]);
|
|
747
|
+
const WRITE_TOOLS = new Set(["mkdir", "touch", "cp", "mv", "rm", "tee", "chmod"]);
|
|
748
|
+
|
|
749
|
+
function fileFamily(argv, root) {
|
|
750
|
+
const tool = argv[0];
|
|
751
|
+
const args = argv.slice(1);
|
|
752
|
+
if (tool === "cd") {
|
|
753
|
+
if (args.length !== 1 || !inside(root, args[0])) return no("cd must target a directory inside the workspace root");
|
|
754
|
+
return ok("nav");
|
|
755
|
+
}
|
|
756
|
+
if (tool === "grep" || tool === "egrep" || tool === "fgrep" || tool === "rg") {
|
|
757
|
+
if (tool === "rg" && args.some(a => /^(--pre|--pre-glob|--search-zip|-z)(=|$)/.test(a))) return no("rg --pre is not allowed");
|
|
758
|
+
const pathErr = checkPaths(root, args, patternIndexes(args)); if (pathErr) return pathErr;
|
|
759
|
+
return ok("file-read");
|
|
760
|
+
}
|
|
761
|
+
if (tool === "find") {
|
|
762
|
+
if (args.some(a => /^-(exec|execdir|ok|okdir|delete|fprint|fprint0|fprintf|fls)$/.test(a))) return no("find actions (-exec/-delete/-fprint) are not allowed");
|
|
763
|
+
const skip = new Set();
|
|
764
|
+
args.forEach((a, i) => { if (/^-(name|iname|path|ipath|regex|iregex|wholename|newer|type|size|mtime|mmin|maxdepth|mindepth|user|group|perm)$/.test(a)) skip.add(i + 1); });
|
|
765
|
+
const pathErr = checkPaths(root, args, skip); if (pathErr) return pathErr;
|
|
766
|
+
return ok("file-read");
|
|
767
|
+
}
|
|
768
|
+
if (tool === "sed") {
|
|
769
|
+
// Only print-mode sed (sed -n '10,20p' file): the w/e/r commands write files or run shells.
|
|
770
|
+
const scripts = args.filter(a => !isOpt(a));
|
|
771
|
+
if (!args.includes("-n") || !scripts.length || !/^(\d+|\$)(,(\d+|\$))?p$/.test(scripts[0])) return no("sed is only allowed as sed -n '<start>,<end>p' <file>");
|
|
772
|
+
if (args.some(a => /^(-i|--in-place)/.test(a))) return no("sed -i requires approval");
|
|
773
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
774
|
+
return ok("file-read");
|
|
775
|
+
}
|
|
776
|
+
if (tool === "tail" && args.some(a => /^(-f|-F|--follow)/.test(a))) return no("tail -f is not allowed");
|
|
777
|
+
if (READ_TOOLS.has(tool)) {
|
|
778
|
+
if (tool === "sort" && args.some(a => /^(-o|--output)/.test(a))) {
|
|
779
|
+
const pathErr = checkPaths(root, args); if (pathErr) return pathErr;
|
|
780
|
+
for (let i = 0; i < args.length; i++) {
|
|
781
|
+
const m = args[i].match(/^(?:-o|--output)=?(.*)$/);
|
|
782
|
+
if (!m) continue;
|
|
783
|
+
const target = m[1] || args[i + 1];
|
|
784
|
+
const we = writeTargetError(root, "sort -o", target || ""); if (we) return we;
|
|
785
|
+
}
|
|
786
|
+
return ok("file-write");
|
|
787
|
+
}
|
|
788
|
+
const pathErr = checkPaths(root, args, tool === "echo" ? new Set(args.map((_, i) => i)) : new Set()); if (pathErr) return pathErr;
|
|
789
|
+
return ok("file-read");
|
|
790
|
+
}
|
|
791
|
+
if (WRITE_TOOLS.has(tool)) {
|
|
792
|
+
if (args.some(a => a === "--no-preserve-root")) return no("--no-preserve-root is not allowed");
|
|
793
|
+
const targets = args.filter(a => !isOpt(a) && !(tool === "chmod" && /^[0-7]{3,4}$|^[ugoa]*[+=-][rwxX]+$/.test(a)));
|
|
794
|
+
if (!targets.length) return no(`${tool} needs a target inside the workspace root`);
|
|
795
|
+
for (const t of targets) {
|
|
796
|
+
const we = writeTargetError(root, tool, t); if (we) return we;
|
|
797
|
+
if (tool === "rm" || tool === "mv" || tool === "chmod") {
|
|
798
|
+
const r = path.resolve(root, t);
|
|
799
|
+
if (r === path.resolve(root) || /^\.\/?$/.test(t)) return no(`${tool} of the workspace root itself is not allowed`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return ok("file-write");
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ─────────────────────────────── evaluate ───────────────────────────────
|
|
808
|
+
|
|
809
|
+
const ENV_OK = /^(CI|FORCE_COLOR|NO_COLOR|NODE_ENV|TZ|LANG|LC_ALL|PYTHONDONTWRITEBYTECODE)=[\w.:=-]*$/;
|
|
810
|
+
|
|
811
|
+
/** Validates one argv. Returns { ok, family } or { ok: false, reason }. */
|
|
812
|
+
export function evaluateArgv(argv, root, ecos = detectEcosystems(root), depth = 0) {
|
|
813
|
+
let a = argv.slice();
|
|
814
|
+
while (a.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a[0])) {
|
|
815
|
+
if (!ENV_OK.test(a[0])) return no(`environment assignment ${a[0].split("=")[0]}= is not allowed`);
|
|
816
|
+
a = a.slice(1);
|
|
817
|
+
}
|
|
818
|
+
if (!a.length) return no("empty command");
|
|
819
|
+
const tool = a[0];
|
|
820
|
+
if (tool.includes("/") && !/^\.\/(mvnw|gradlew)$/.test(tool)) return no(`running '${tool}' by path is not allowed`);
|
|
821
|
+
const base = path.basename(tool);
|
|
822
|
+
if (base === "git") return gitFamily(a, root);
|
|
823
|
+
if (base === "npm" || base === "yarn" || base === "pnpm") return npmFamily(a, root, ecos);
|
|
824
|
+
if (base === "npx") return npxFamily(a, root, ecos, depth);
|
|
825
|
+
const w = wrapperFamily(a, root, ecos, depth);
|
|
826
|
+
if (w) return w;
|
|
827
|
+
if (base === "mvn" || base === "mvnw") return mavenFamily(a, root, ecos);
|
|
828
|
+
if (base === "gradle" || base === "gradlew") return gradleFamily(a, root, ecos);
|
|
829
|
+
if (/^(pip3?|python(3(\.\d+)?)?|poetry|uv|tox|nox)$/.test(base)) return pythonFamily(a, root, ecos);
|
|
830
|
+
if (base === "go") return goFamily(a, root, ecos);
|
|
831
|
+
if (base === "cargo") return cargoFamily(a, root, ecos);
|
|
832
|
+
if (base === "dotnet") return dotnetFamily(a, root, ecos);
|
|
833
|
+
const f = fileFamily(a, root) || standaloneRunner(a, root, ecos);
|
|
834
|
+
return f || no(`'${base}' is not in the command allowlist`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Validates a shell command string. Returns { allowed, families, reason }.
|
|
839
|
+
*/
|
|
840
|
+
export function evaluateCommand(command, root, { ecosystems } = {}) {
|
|
841
|
+
if (!root) return { allowed: false, reason: "no workspace root" };
|
|
842
|
+
const t = tokenize(command);
|
|
843
|
+
if (!t.ok) return { allowed: false, reason: t.reason };
|
|
844
|
+
const ecos = ecosystems || detectEcosystems(root);
|
|
845
|
+
const prev = CWDS;
|
|
846
|
+
CWDS = [root];
|
|
847
|
+
try {
|
|
848
|
+
const families = [];
|
|
849
|
+
let wrote = false;
|
|
850
|
+
for (const seg of t.segments) {
|
|
851
|
+
for (let i = 0; i < seg.argv.length; i++) {
|
|
852
|
+
if (!seg.quoted[i] && GLOB.test(seg.argv[i])) {
|
|
853
|
+
const g = checkShellGlob(root, seg.argv[i]);
|
|
854
|
+
if (g) return { allowed: false, reason: g.reason, families };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const r = evaluateArgv(seg.argv, root, ecos);
|
|
858
|
+
if (!r.ok) return { allowed: false, reason: r.reason, families };
|
|
859
|
+
// A command string that writes files and THEN runs a build/test/lint
|
|
860
|
+
// tool could plant code for that tool: refuse the combination.
|
|
861
|
+
if (wrote && EXECUTING.has(r.family)) {
|
|
862
|
+
return { allowed: false, reason: "a command that writes files may not run build/test/lint tools afterwards in the same command (split it into separate calls)", families };
|
|
863
|
+
}
|
|
864
|
+
families.push(r.family);
|
|
865
|
+
let segWrites = r.family === "file-write";
|
|
866
|
+
for (const rd of seg.redirects) {
|
|
867
|
+
if (rd.op === ">&") { if (!/^[12]$/.test(rd.target)) return { allowed: false, reason: "only 2>&1 / 1>&2 duplication is allowed" }; continue; }
|
|
868
|
+
if (rd.target === "/dev/null") continue;
|
|
869
|
+
if (!inside(root, rd.target)) return { allowed: false, reason: `redirection target '${rd.target}' is outside the workspace root` };
|
|
870
|
+
if (rd.op !== "<") {
|
|
871
|
+
const we = writeTargetError(root, "redirection", rd.target);
|
|
872
|
+
if (we) return { allowed: false, reason: we.reason, families };
|
|
873
|
+
families.push("file-write");
|
|
874
|
+
segWrites = true;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (segWrites) wrote = true;
|
|
878
|
+
// `cd` changes where later relative paths resolve: check against every candidate.
|
|
879
|
+
if (seg.argv[0] === "cd" && seg.argv[1]) CWDS = [...new Set([...CWDS, ...CWDS.map(c => path.resolve(c, seg.argv[1]))])];
|
|
880
|
+
}
|
|
881
|
+
return { allowed: true, families: [...new Set(families)] };
|
|
882
|
+
} finally {
|
|
883
|
+
CWDS = prev;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const EXECUTING = new Set(["pkg", "test", "lint"]);
|
|
888
|
+
|
|
889
|
+
// ──────────────────────────── request decision ────────────────────────────
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Platform-authored tool commands (built by visionhammer-agents, not typed by
|
|
893
|
+
* the model). Accepted only when the request signature VERIFIED — the signed
|
|
894
|
+
* body names the tool. Everything the model types (run_command) is not in here.
|
|
895
|
+
*/
|
|
896
|
+
export const PLATFORM_TOOLS = Object.freeze(new Set([
|
|
897
|
+
"create_branch", "git_commit", "git_push", "close_pr", "delete_branch", "publish_scope", "current_branch",
|
|
898
|
+
]));
|
|
899
|
+
|
|
900
|
+
export function resolveExecPolicyConfig(env = process.env) {
|
|
901
|
+
return { mode: resolveMode(env.LOCAL_AGENT_EXEC_POLICY_MODE, "enforce") };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export const execStats = { allowed: 0, platform: 0, approved: 0, warned: 0, rejected: 0 };
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Decides one exec request. `command` (shell string) or `argv` (no shell).
|
|
908
|
+
* Returns { allow: boolean, status?, reason?, via }.
|
|
909
|
+
*/
|
|
910
|
+
export function decideExec({ command, argv, root, headers = {}, body = {}, vhSignature, signingCfg }, cfg = resolveExecPolicyConfig()) {
|
|
911
|
+
if (cfg.mode === "off") return { allow: true, via: "policy-off" };
|
|
912
|
+
const verdict = argv
|
|
913
|
+
? (() => {
|
|
914
|
+
const prev = CWDS; CWDS = [root];
|
|
915
|
+
try { const r = evaluateArgv(argv, root); return r.ok ? { allowed: true, families: [r.family] } : { allowed: false, reason: r.reason }; }
|
|
916
|
+
finally { CWDS = prev; }
|
|
917
|
+
})()
|
|
918
|
+
: evaluateCommand(command, root);
|
|
919
|
+
if (verdict.allowed) { execStats.allowed++; return { allow: true, via: `allowlist:${verdict.families.join("+")}` }; }
|
|
920
|
+
|
|
921
|
+
if (body.commandOrigin === "platform" && PLATFORM_TOOLS.has(body.tool) && vhSignature?.verified) {
|
|
922
|
+
execStats.platform++;
|
|
923
|
+
return { allow: true, via: `platform-tool:${body.tool}` };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const approval = headers["x-vh-approval"] || body.approvalToken;
|
|
927
|
+
if (approval) {
|
|
928
|
+
const subject = argv ? JSON.stringify(argv) : command;
|
|
929
|
+
const a = verifyApprovalToken(approval, {
|
|
930
|
+
subject,
|
|
931
|
+
keys: signingCfg?.keys,
|
|
932
|
+
identity: signingCfg?.identity,
|
|
933
|
+
requestTenantId: vhSignature?.verified ? vhSignature.tenantId : undefined,
|
|
934
|
+
nonces: signingCfg?.approvalNonces || approvalNonces,
|
|
935
|
+
now: signingCfg?.now,
|
|
936
|
+
});
|
|
937
|
+
if (a.ok) { execStats.approved++; return { allow: true, via: `human-approval:${a.approvedBy || "unknown"}` }; }
|
|
938
|
+
verdict.reason = `${verdict.reason}; approval token rejected: ${a.reason}`;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const shown = (argv ? argv.join(" ") : command).slice(0, 120);
|
|
942
|
+
if (cfg.mode === "enforce") {
|
|
943
|
+
execStats.rejected++;
|
|
944
|
+
logDecision("warn", `exec-rej:${verdict.reason}`, `[exec-policy] REJECTED "${shown}": ${verdict.reason}`);
|
|
945
|
+
return { allow: false, status: 403, reason: verdict.reason, via: "rejected" };
|
|
946
|
+
}
|
|
947
|
+
execStats.warned++;
|
|
948
|
+
logDecision("warn", `exec-warn:${verdict.reason}`, `[exec-policy] WARN (would reject in enforce) "${shown}": ${verdict.reason}`);
|
|
949
|
+
return { allow: true, via: "warn", reason: verdict.reason };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Standard 403 body for a rejected command. */
|
|
953
|
+
export function rejectionBody(decision) {
|
|
954
|
+
return {
|
|
955
|
+
ok: false,
|
|
956
|
+
error: "command not allowed by the agent's execution policy",
|
|
957
|
+
reason: decision.reason,
|
|
958
|
+
hint: "Allowed: read-only git, package-manager install/test/build for the detected ecosystem, "
|
|
959
|
+
+ "test runners, linters, and file read/write inside the workspace root. Anything else needs "
|
|
960
|
+
+ "a PolicyGate human approval (REQUIRE_HUMAN).",
|
|
961
|
+
};
|
|
962
|
+
}
|