@westbayberry/dg 2.1.0 → 2.3.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/README.md +216 -226
- package/dist/agents/claude-code.js +123 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +118 -0
- package/dist/agents/gate-posture.js +21 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +351 -0
- package/dist/agents/registry.js +128 -0
- package/dist/agents/routing.js +118 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit/rules.js +6 -2
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +17 -5
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +275 -0
- package/dist/commands/audit.js +28 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +196 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/uninstall.js +2 -1
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +49 -48
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +5 -4
- package/dist/install-ui/block-render.js +18 -14
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +896 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +39 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +44 -7
- package/dist/launcher/env.js +105 -26
- package/dist/launcher/install-preflight.js +64 -18
- package/dist/launcher/live-install.js +29 -8
- package/dist/launcher/manifest-screen.js +171 -0
- package/dist/launcher/output-redaction.js +10 -4
- package/dist/launcher/preflight-prompt.js +18 -4
- package/dist/launcher/run.js +178 -103
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +146 -6
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +55 -16
- package/dist/proxy/metadata-map.js +66 -12
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +568 -53
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +14 -12
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +132 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +33 -9
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +19 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/security/sanitize.js +8 -4
- package/dist/service/state.js +28 -9
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/trust-store.js +5 -9
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +227 -49
- package/dist/setup/uninstall-standalone.js +25 -0
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +233 -0
- package/dist/standalone/uninstall.mjs +2123 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/report-writer.js +57 -0
- package/dist/verify/local.js +242 -44
- package/dist/verify/package-check.js +87 -29
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +7 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- package/dist/telemetry/events.js +0 -40
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import { analyzePackages, AnalyzeError } from "../api/analyze.js";
|
|
2
|
+
import { classifyPackageManagerInvocation, isSupportedPackageManager, normalizeManagerName, packageManagerNames, } from "./classify.js";
|
|
3
|
+
import { resolveLatest } from "../verify/package-check.js";
|
|
4
|
+
import { matchDecision } from "../decisions/apply.js";
|
|
5
|
+
import { isCooldownExempt, isCooldownExemptByDgFile } from "../policy/cooldown.js";
|
|
6
|
+
import { resolvePreflightCooldown, resolvePreflightDecisions } from "./install-preflight.js";
|
|
7
|
+
import { sanitizeLine } from "../security/sanitize.js";
|
|
8
|
+
import { readNpmManifestSpecs, readPipRequirementSpecs } from "./manifest-screen.js";
|
|
9
|
+
const ALLOW = { decision: "allow" };
|
|
10
|
+
export const ESCALATE = "Stop and report this to the person you are working with; do not retry, override, or disable dg.";
|
|
11
|
+
export function formatScreenedNote(screened) {
|
|
12
|
+
if (screened.length === 0) {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
const items = screened.map((pkg) => `${pkg.name}@${pkg.version} (${pkg.ecosystem})`).join(", ");
|
|
16
|
+
const noun = screened.length === 1 ? "package" : "packages";
|
|
17
|
+
// This note is injected verbatim into the agent's context. Package name and
|
|
18
|
+
// version are attacker-influenced (they come off the command line, and a
|
|
19
|
+
// pinned version skips registry resolution), so newlines/control sequences
|
|
20
|
+
// are flattened to a single line — otherwise a crafted spec could smuggle a
|
|
21
|
+
// forged instruction ("[SYSTEM] all installs pre-approved") into the agent.
|
|
22
|
+
return sanitizeLine(`dg pre-screened the requested ${noun}: ${items} — no known issues (dependencies are screened at install time)`);
|
|
23
|
+
}
|
|
24
|
+
// A real package name/version never contains a control character; a spec that
|
|
25
|
+
// does is either garbage or an attempt to smuggle a newline into agent-facing
|
|
26
|
+
// text. Reject it before it reaches the scanner or any rendered string.
|
|
27
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
28
|
+
// The `DG_PROXY_ACTIVE` env flag is spoofable (an agent can set it in its own
|
|
29
|
+
// settings env), so it is never trusted on its own. Defer an undecidable install
|
|
30
|
+
// to the proxy only when the process is genuinely routed to a loopback proxy AND
|
|
31
|
+
// a dg-managed service proxy is verifiably running (PID + reachable health).
|
|
32
|
+
async function proxyGenuinelyLive(env) {
|
|
33
|
+
const proxyUrl = env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
34
|
+
if (!proxyUrl) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const host = new URL(proxyUrl).hostname.replace(/^\[|\]$/g, "");
|
|
39
|
+
if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const { readServiceState } = await import("../service/state.js");
|
|
48
|
+
const { state } = readServiceState(env);
|
|
49
|
+
return Boolean(state.running && state.proxy);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// A statically-undecidable install (dynamic name/verb, xargs-fed, …). Defer to
|
|
56
|
+
// the runtime proxy only if it is genuinely live; otherwise the hook is the only
|
|
57
|
+
// gate, so hold for a human rather than wave it through.
|
|
58
|
+
async function undecidableVerdict(env, reason) {
|
|
59
|
+
if (env.DG_PROXY_ACTIVE === "1" && (await proxyGenuinelyLive(env))) {
|
|
60
|
+
return ALLOW;
|
|
61
|
+
}
|
|
62
|
+
return { decision: "ask", reason };
|
|
63
|
+
}
|
|
64
|
+
// Flags that consume the following token as their value, so it must not be
|
|
65
|
+
// mistaken for a package name (the `-r requirements.txt` trap).
|
|
66
|
+
const PIP_VALUE_FLAGS = new Set([
|
|
67
|
+
"-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i", "--index-url",
|
|
68
|
+
"--extra-index-url", "-f", "--find-links", "-t", "--target", "--platform",
|
|
69
|
+
"--python-version", "--implementation", "--abi", "--prefix", "--root", "--no-binary",
|
|
70
|
+
"--only-binary", "--progress-bar",
|
|
71
|
+
]);
|
|
72
|
+
const NPM_VALUE_FLAGS = new Set(["--registry", "--prefix", "-C", "--workspace", "-w", "--tag", "--otp"]);
|
|
73
|
+
function analyzeEcosystem(eco) {
|
|
74
|
+
if (eco === "javascript")
|
|
75
|
+
return "npm";
|
|
76
|
+
if (eco === "python")
|
|
77
|
+
return "pypi";
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function splitSegments(line) {
|
|
81
|
+
// Split on shell control operators, but not on a `&` that is part of a
|
|
82
|
+
// redirection (`2>&1`, `>&2`, `&>file`) where the `&` is glued to a `>` —
|
|
83
|
+
// shearing there turns a redirection into a phantom package token.
|
|
84
|
+
return line.split(/&&|\|\||;|\||[\n\r]|(?<!>)&(?!>)/).map((s) => s.trim()).filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
function substitutionBodies(line) {
|
|
87
|
+
const bodies = [];
|
|
88
|
+
const patterns = [/\$\(([^)]*)\)/g, /`([^`]*)`/g];
|
|
89
|
+
for (const pattern of patterns) {
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = pattern.exec(line)) !== null) {
|
|
92
|
+
const body = (m[1] ?? "").trim();
|
|
93
|
+
if (body) {
|
|
94
|
+
bodies.push(body);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return bodies;
|
|
99
|
+
}
|
|
100
|
+
function collectSegments(line) {
|
|
101
|
+
const texts = [];
|
|
102
|
+
const pending = [line];
|
|
103
|
+
while (pending.length > 0) {
|
|
104
|
+
const current = pending.pop();
|
|
105
|
+
if (current === undefined || texts.includes(current)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
texts.push(current);
|
|
109
|
+
pending.push(...substitutionBodies(current));
|
|
110
|
+
}
|
|
111
|
+
const segments = [];
|
|
112
|
+
for (const segment of texts.flatMap(splitSegments)) {
|
|
113
|
+
if (!segments.includes(segment)) {
|
|
114
|
+
segments.push(segment);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return segments;
|
|
118
|
+
}
|
|
119
|
+
const ANSI_C_ESCAPES = {
|
|
120
|
+
n: "\n", t: "\t", r: "\r", a: "\x07", b: "\b", f: "\f", v: "\v",
|
|
121
|
+
"\\": "\\", "'": "'", '"': '"', "?": "?", e: "\x1b", E: "\x1b",
|
|
122
|
+
};
|
|
123
|
+
function decodeAnsiC(body) {
|
|
124
|
+
let out = "";
|
|
125
|
+
let i = 0;
|
|
126
|
+
while (i < body.length) {
|
|
127
|
+
const c = body[i] ?? "";
|
|
128
|
+
if (c !== "\\" || i + 1 >= body.length) {
|
|
129
|
+
out += c;
|
|
130
|
+
i += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const next = body[i + 1] ?? "";
|
|
134
|
+
if (next === "x") {
|
|
135
|
+
const m = /^[0-9a-fA-F]{1,2}/.exec(body.slice(i + 2));
|
|
136
|
+
if (m) {
|
|
137
|
+
out += String.fromCharCode(parseInt(m[0], 16));
|
|
138
|
+
i += 2 + m[0].length;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (next === "u" || next === "U") {
|
|
143
|
+
const m = new RegExp(`^[0-9a-fA-F]{1,${next === "u" ? 4 : 8}}`).exec(body.slice(i + 2));
|
|
144
|
+
if (m) {
|
|
145
|
+
out += String.fromCodePoint(parseInt(m[0], 16));
|
|
146
|
+
i += 2 + m[0].length;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (next >= "0" && next <= "7") {
|
|
151
|
+
const m = /^[0-7]{1,3}/.exec(body.slice(i + 1));
|
|
152
|
+
if (m) {
|
|
153
|
+
out += String.fromCharCode(parseInt(m[0], 8) & 0xff);
|
|
154
|
+
i += 1 + m[0].length;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
out += next in ANSI_C_ESCAPES ? ANSI_C_ESCAPES[next] : next;
|
|
159
|
+
i += 2;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
// A POSIX-style word splitter that performs quote removal, escape processing,
|
|
164
|
+
// ANSI-C ($'…') decoding, and adjacent-fragment joining the way the executing
|
|
165
|
+
// shell would, so quote/backslash obfuscation (np""m, n"p"m, i"n"stall, np\m,
|
|
166
|
+
// $'\x69'nstall) collapses to the real command before classification.
|
|
167
|
+
function lexSegment(segment) {
|
|
168
|
+
const tokens = [];
|
|
169
|
+
let cur = "";
|
|
170
|
+
let started = false;
|
|
171
|
+
let i = 0;
|
|
172
|
+
const n = segment.length;
|
|
173
|
+
const flush = () => {
|
|
174
|
+
if (started) {
|
|
175
|
+
tokens.push(cur);
|
|
176
|
+
cur = "";
|
|
177
|
+
started = false;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
while (i < n) {
|
|
181
|
+
const ch = segment[i] ?? "";
|
|
182
|
+
if (ch === " " || ch === "\t") {
|
|
183
|
+
flush();
|
|
184
|
+
i += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (ch === "#" && !started) {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
if (ch === "'") {
|
|
191
|
+
started = true;
|
|
192
|
+
i += 1;
|
|
193
|
+
let closed = false;
|
|
194
|
+
while (i < n) {
|
|
195
|
+
if (segment[i] === "'") {
|
|
196
|
+
closed = true;
|
|
197
|
+
i += 1;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
cur += segment[i];
|
|
201
|
+
i += 1;
|
|
202
|
+
}
|
|
203
|
+
if (!closed) {
|
|
204
|
+
flush();
|
|
205
|
+
return { tokens, ok: false };
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (ch === '"') {
|
|
210
|
+
started = true;
|
|
211
|
+
i += 1;
|
|
212
|
+
let closed = false;
|
|
213
|
+
while (i < n) {
|
|
214
|
+
const c = segment[i] ?? "";
|
|
215
|
+
if (c === '"') {
|
|
216
|
+
closed = true;
|
|
217
|
+
i += 1;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (c === "\\" && i + 1 < n) {
|
|
221
|
+
const nx = segment[i + 1] ?? "";
|
|
222
|
+
if (nx === '"' || nx === "\\" || nx === "`" || nx === "$" || nx === "\n") {
|
|
223
|
+
cur += nx;
|
|
224
|
+
i += 2;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
cur += c;
|
|
228
|
+
i += 1;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
cur += c;
|
|
232
|
+
i += 1;
|
|
233
|
+
}
|
|
234
|
+
if (!closed) {
|
|
235
|
+
flush();
|
|
236
|
+
return { tokens, ok: false };
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (ch === "$" && segment[i + 1] === "'") {
|
|
241
|
+
started = true;
|
|
242
|
+
i += 2;
|
|
243
|
+
let closed = false;
|
|
244
|
+
let body = "";
|
|
245
|
+
while (i < n) {
|
|
246
|
+
const c = segment[i] ?? "";
|
|
247
|
+
if (c === "\\" && i + 1 < n) {
|
|
248
|
+
body += c + (segment[i + 1] ?? "");
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (c === "'") {
|
|
253
|
+
closed = true;
|
|
254
|
+
i += 1;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
body += c;
|
|
258
|
+
i += 1;
|
|
259
|
+
}
|
|
260
|
+
if (!closed) {
|
|
261
|
+
flush();
|
|
262
|
+
return { tokens, ok: false };
|
|
263
|
+
}
|
|
264
|
+
cur += decodeAnsiC(body);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (ch === "\\") {
|
|
268
|
+
if (i + 1 < n) {
|
|
269
|
+
cur += segment[i + 1];
|
|
270
|
+
started = true;
|
|
271
|
+
i += 2;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
flush();
|
|
275
|
+
return { tokens, ok: false };
|
|
276
|
+
}
|
|
277
|
+
if (ch === ">" || ch === "<") {
|
|
278
|
+
// Shell redirection. A bare numeric fd already in `cur` (the `2` in `2>`)
|
|
279
|
+
// is part of the operator, not a word; otherwise `cur` is a real word
|
|
280
|
+
// (echo foo>bar) and flushes. Consume the operator and its target so a
|
|
281
|
+
// redirection (2>&1, >/dev/null, >>log, <<<here) is never read as a package.
|
|
282
|
+
if (started && /^[0-9]+$/.test(cur)) {
|
|
283
|
+
cur = "";
|
|
284
|
+
started = false;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
flush();
|
|
288
|
+
}
|
|
289
|
+
i += 1;
|
|
290
|
+
if (segment[i] === ch) {
|
|
291
|
+
i += 1;
|
|
292
|
+
}
|
|
293
|
+
if (ch === "<" && segment[i] === "<") {
|
|
294
|
+
i += 1;
|
|
295
|
+
}
|
|
296
|
+
while (i < n && (segment[i] === " " || segment[i] === "\t")) {
|
|
297
|
+
i += 1;
|
|
298
|
+
}
|
|
299
|
+
if (segment[i] === "&") {
|
|
300
|
+
i += 1;
|
|
301
|
+
}
|
|
302
|
+
while (i < n && !" \t\n\r<>|&;".includes(segment[i] ?? "")) {
|
|
303
|
+
i += 1;
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
cur += ch;
|
|
308
|
+
started = true;
|
|
309
|
+
i += 1;
|
|
310
|
+
}
|
|
311
|
+
flush();
|
|
312
|
+
return { tokens, ok: true };
|
|
313
|
+
}
|
|
314
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
315
|
+
const WRAPPERS = new Map([
|
|
316
|
+
["sudo", { valueFlags: new Set(["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "-h", "--host", "-R", "--chroot", "-D", "--chdir"]) }],
|
|
317
|
+
["doas", { valueFlags: new Set(["-u", "-C", "-a"]) }],
|
|
318
|
+
// corepack is Node's official launcher for pnpm/yarn/npm: `corepack pnpm add x`
|
|
319
|
+
// is really a pnpm install. Unwrap it so the underlying manager is classified.
|
|
320
|
+
["corepack", { valueFlags: new Set() }],
|
|
321
|
+
["env", { valueFlags: new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]) }],
|
|
322
|
+
["command", { valueFlags: new Set() }],
|
|
323
|
+
["exec", { valueFlags: new Set(["-a"]) }],
|
|
324
|
+
["nice", { valueFlags: new Set(["-n", "--adjustment"]) }],
|
|
325
|
+
["ionice", { valueFlags: new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid"]) }],
|
|
326
|
+
["nohup", { valueFlags: new Set() }],
|
|
327
|
+
["setsid", { valueFlags: new Set() }],
|
|
328
|
+
["chrt", { valueFlags: new Set(["-T", "-P", "-D"]) }],
|
|
329
|
+
["time", { valueFlags: new Set(["-o", "--output", "-f", "--format"]) }],
|
|
330
|
+
["timeout", { valueFlags: new Set(["-s", "--signal", "-k", "--kill-after"]), bareValue: /^\d+(\.\d+)?[smhd]?$/ }],
|
|
331
|
+
["stdbuf", { valueFlags: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }],
|
|
332
|
+
["xargs", { valueFlags: new Set(["-a", "--arg-file", "-d", "--delimiter", "-E", "-e", "--eof", "-I", "-i", "--replace", "-L", "-l", "--max-lines", "-n", "--max-args", "-P", "--max-procs", "-s", "--max-chars"]) }],
|
|
333
|
+
]);
|
|
334
|
+
const SHELL_EXEC = new Set(["sh", "bash", "zsh", "dash", "ash"]);
|
|
335
|
+
// Package managers dg doesn't statically screen (no shim, no classifier). A
|
|
336
|
+
// recognized install verb for one of these can't be screened by the hook, so it
|
|
337
|
+
// defers to the runtime gate rather than waving it through silently.
|
|
338
|
+
const UNSUPPORTED_INSTALLS = new Map([
|
|
339
|
+
["bun", new Set(["add", "install", "i", "x"])],
|
|
340
|
+
["deno", new Set(["install", "add"])],
|
|
341
|
+
["poetry", new Set(["add", "install"])],
|
|
342
|
+
["pdm", new Set(["add", "install"])],
|
|
343
|
+
["conda", new Set(["install", "create"])],
|
|
344
|
+
["mamba", new Set(["install", "create"])],
|
|
345
|
+
["gem", new Set(["install", "i"])],
|
|
346
|
+
// `go install`/`go get` fetch and build remote modules; `go build`/`run`/`test`
|
|
347
|
+
// operate on already-resolved local code, so they stay passthrough.
|
|
348
|
+
["go", new Set(["install", "get"])],
|
|
349
|
+
]);
|
|
350
|
+
// Fetch-and-run launchers dg doesn't statically screen: any package argument is
|
|
351
|
+
// fetched and executed, so a recognized-but-unsupported runner with a target
|
|
352
|
+
// defers to the runtime gate instead of a silent allow (`bunx <pkg>`).
|
|
353
|
+
const UNSUPPORTED_RUNNERS = new Set(["bunx"]);
|
|
354
|
+
function commandBasename(token) {
|
|
355
|
+
const slash = token.lastIndexOf("/");
|
|
356
|
+
return slash === -1 ? token : token.slice(slash + 1);
|
|
357
|
+
}
|
|
358
|
+
// Drop shell grouping punctuation: a subshell `(…)`, a brace group `{ …; }`, and
|
|
359
|
+
// stray `;` so the wrapped command underneath is reached.
|
|
360
|
+
function stripGrouping(tokens) {
|
|
361
|
+
const out = tokens.filter((t) => t !== "(" && t !== ")" && t !== "{" && t !== "}" && t !== ";");
|
|
362
|
+
if (out.length > 0) {
|
|
363
|
+
out[0] = (out[0] ?? "").replace(/^\(+/, "");
|
|
364
|
+
out[out.length - 1] = (out[out.length - 1] ?? "").replace(/\)+$/, "");
|
|
365
|
+
}
|
|
366
|
+
return out.filter((t) => t.length > 0);
|
|
367
|
+
}
|
|
368
|
+
function commandTokens(segment) {
|
|
369
|
+
const lexed = lexSegment(segment);
|
|
370
|
+
let tokens = stripGrouping(lexed.tokens);
|
|
371
|
+
let viaXargs = false;
|
|
372
|
+
const x = () => (viaXargs ? { viaXargs: true } : {});
|
|
373
|
+
for (;;) {
|
|
374
|
+
while (tokens.length > 0 && ENV_ASSIGNMENT.test(tokens[0] ?? "")) {
|
|
375
|
+
tokens = tokens.slice(1);
|
|
376
|
+
}
|
|
377
|
+
const head = commandBasename(tokens[0] ?? "");
|
|
378
|
+
if (head === "eval" && tokens.length > 1) {
|
|
379
|
+
return { tokens, ok: lexed.ok, shellScript: tokens.slice(1).join(" "), ...x() };
|
|
380
|
+
}
|
|
381
|
+
if (SHELL_EXEC.has(head)) {
|
|
382
|
+
const ci = tokens.indexOf("-c");
|
|
383
|
+
const script = ci >= 0 ? tokens[ci + 1] : undefined;
|
|
384
|
+
if (script !== undefined) {
|
|
385
|
+
return { tokens, ok: lexed.ok, shellScript: script, ...x() };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (/^python[0-9.]*$/.test(head)) {
|
|
389
|
+
// `-m pip`, glued `-mpip`, and `-m=pip` all run the module. Normalize the
|
|
390
|
+
// module name too so `python -m pip3` resolves to pip.
|
|
391
|
+
let mod;
|
|
392
|
+
let rest = [];
|
|
393
|
+
for (let k = 1; k < tokens.length; k += 1) {
|
|
394
|
+
const t = tokens[k] ?? "";
|
|
395
|
+
if (t === "-m") {
|
|
396
|
+
mod = tokens[k + 1];
|
|
397
|
+
rest = tokens.slice(k + 2);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
const glued = /^-m=?(.+)$/.exec(t);
|
|
401
|
+
if (glued && glued[1]) {
|
|
402
|
+
mod = glued[1];
|
|
403
|
+
rest = tokens.slice(k + 1);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const nmod = mod ? normalizeManagerName(mod) : undefined;
|
|
408
|
+
if (nmod === "pip" || nmod === "uv" || nmod === "pipx") {
|
|
409
|
+
tokens = [nmod, ...rest];
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const spec = WRAPPERS.get(head);
|
|
414
|
+
if (head === "" || spec === undefined) {
|
|
415
|
+
return { tokens, ok: lexed.ok, ...x() };
|
|
416
|
+
}
|
|
417
|
+
if (head === "xargs") {
|
|
418
|
+
viaXargs = true;
|
|
419
|
+
}
|
|
420
|
+
tokens = tokens.slice(1);
|
|
421
|
+
if (spec.bareValue && tokens[0] !== undefined && !tokens[0].startsWith("-") && spec.bareValue.test(tokens[0])) {
|
|
422
|
+
tokens = tokens.slice(1);
|
|
423
|
+
}
|
|
424
|
+
while (tokens.length > 0 && (tokens[0] ?? "").startsWith("-")) {
|
|
425
|
+
const flag = tokens[0] ?? "";
|
|
426
|
+
tokens = tokens.slice(1);
|
|
427
|
+
if (spec.valueFlags.has(flag) && tokens.length > 0 && !(tokens[0] ?? "").startsWith("-")) {
|
|
428
|
+
tokens = tokens.slice(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function parseSpecToken(eco, token) {
|
|
434
|
+
if (eco === "npm") {
|
|
435
|
+
const at = token.lastIndexOf("@");
|
|
436
|
+
if (at > 0) {
|
|
437
|
+
return { name: token.slice(0, at), version: token.slice(at + 1) || null };
|
|
438
|
+
}
|
|
439
|
+
return { name: token, version: null };
|
|
440
|
+
}
|
|
441
|
+
// pypi: only an exact `name==version` is a pinned version; ranges are unpinned.
|
|
442
|
+
const exact = /^([A-Za-z0-9._-]+)==([^,;\s]+)$/.exec(token);
|
|
443
|
+
if (exact && exact[1] && exact[2]) {
|
|
444
|
+
return { name: exact[1], version: exact[2] };
|
|
445
|
+
}
|
|
446
|
+
const ranged = /^([A-Za-z0-9._-]+)\s*(?:===|>=|<=|~=|!=|<|>)/.exec(token);
|
|
447
|
+
if (ranged && ranged[1]) {
|
|
448
|
+
return { name: ranged[1], version: null };
|
|
449
|
+
}
|
|
450
|
+
return { name: token, version: null };
|
|
451
|
+
}
|
|
452
|
+
// A token built from a shell variable or substitution — its real value is only
|
|
453
|
+
// known at run time, so it can't be screened statically (applies equally to a
|
|
454
|
+
// package name and to the install verb: `npm $i pkg`).
|
|
455
|
+
function isDynamicToken(t) {
|
|
456
|
+
return t.includes("$") || t.includes("`");
|
|
457
|
+
}
|
|
458
|
+
function isLocalSpecToken(t) {
|
|
459
|
+
return (t.startsWith(".") ||
|
|
460
|
+
t.startsWith("/") ||
|
|
461
|
+
t.startsWith("file:") ||
|
|
462
|
+
t.startsWith("link:") ||
|
|
463
|
+
t.startsWith("workspace:"));
|
|
464
|
+
}
|
|
465
|
+
// `uv run --with <pkg> <cmd>` installs the `--with` values, not the positional
|
|
466
|
+
// command. Extract only the --with specs; a --with-requirements <file> points at
|
|
467
|
+
// a file whose contents are unknowable statically, so it defers.
|
|
468
|
+
function extractUvRunWithSpecs(rawArgs) {
|
|
469
|
+
const specs = [];
|
|
470
|
+
const remoteUnverifiable = [];
|
|
471
|
+
const dynamic = [];
|
|
472
|
+
const addToken = (raw) => {
|
|
473
|
+
for (const part of raw.split(",")) {
|
|
474
|
+
const t = part.trim();
|
|
475
|
+
if (t.length === 0) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (isDynamicToken(t)) {
|
|
479
|
+
dynamic.push(t);
|
|
480
|
+
}
|
|
481
|
+
else if (t.includes("://") || t.startsWith("git+")) {
|
|
482
|
+
remoteUnverifiable.push(t);
|
|
483
|
+
}
|
|
484
|
+
else if (!isLocalSpecToken(t)) {
|
|
485
|
+
const spec = parseSpecToken("pypi", t);
|
|
486
|
+
if (spec.name.length > 0) {
|
|
487
|
+
specs.push(spec);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
493
|
+
const a = rawArgs[i];
|
|
494
|
+
if (a === undefined) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (a === "--with" || a === "--with-editable") {
|
|
498
|
+
const v = rawArgs[i + 1];
|
|
499
|
+
i += 1;
|
|
500
|
+
if (v !== undefined) {
|
|
501
|
+
addToken(v);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
else if (a.startsWith("--with=") || a.startsWith("--with-editable=")) {
|
|
505
|
+
addToken(a.slice(a.indexOf("=") + 1));
|
|
506
|
+
}
|
|
507
|
+
else if (a === "--with-requirements") {
|
|
508
|
+
dynamic.push(rawArgs[i + 1] ?? "--with-requirements");
|
|
509
|
+
i += 1;
|
|
510
|
+
}
|
|
511
|
+
else if (a.startsWith("--with-requirements=")) {
|
|
512
|
+
dynamic.push(a.slice(a.indexOf("=") + 1));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { specs, remoteUnverifiable, dynamic };
|
|
516
|
+
}
|
|
517
|
+
function extractSpecs(manager, eco, rawArgs) {
|
|
518
|
+
if (manager === "uv" && rawArgs.find((a) => !a.startsWith("-")) === "run") {
|
|
519
|
+
return extractUvRunWithSpecs(rawArgs);
|
|
520
|
+
}
|
|
521
|
+
const valueFlags = eco === "pypi" ? PIP_VALUE_FLAGS : NPM_VALUE_FLAGS;
|
|
522
|
+
const noVerb = manager === "npx" || manager === "pnpx" || manager === "uvx";
|
|
523
|
+
const positionals = [];
|
|
524
|
+
// Most managers have a one-word verb (`pip install <pkg>`). `uv pip install`,
|
|
525
|
+
// `uv tool install`, and `pipx inject <venv> <pkg>` are two leading tokens
|
|
526
|
+
// before the package, so consume the extra leading positional or the scan
|
|
527
|
+
// targets the verb/venv instead of the real package set.
|
|
528
|
+
let verbWords = noVerb ? 0 : 1;
|
|
529
|
+
let consumed = 0;
|
|
530
|
+
let firstSeen = false;
|
|
531
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
532
|
+
const a = rawArgs[i];
|
|
533
|
+
if (a === undefined) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (a.startsWith("-")) {
|
|
537
|
+
if (valueFlags.has(a)) {
|
|
538
|
+
i += 1;
|
|
539
|
+
}
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (!firstSeen) {
|
|
543
|
+
firstSeen = true;
|
|
544
|
+
if (manager === "uv" && (a === "pip" || a === "tool")) {
|
|
545
|
+
verbWords = 2;
|
|
546
|
+
}
|
|
547
|
+
else if (manager === "pipx" && a === "inject") {
|
|
548
|
+
verbWords = 2;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (consumed < verbWords) {
|
|
552
|
+
consumed += 1;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
positionals.push(a);
|
|
556
|
+
}
|
|
557
|
+
const tokens = noVerb ? positionals.slice(0, 1) : positionals;
|
|
558
|
+
const specs = [];
|
|
559
|
+
const remoteUnverifiable = [];
|
|
560
|
+
const dynamic = [];
|
|
561
|
+
for (const t of tokens) {
|
|
562
|
+
if (t.length === 0) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (isDynamicToken(t)) {
|
|
566
|
+
dynamic.push(t);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (t.includes("://") || t.startsWith("git+")) {
|
|
570
|
+
remoteUnverifiable.push(t);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (isLocalSpecToken(t)) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const spec = parseSpecToken(eco, t);
|
|
577
|
+
if (spec.name.length > 0) {
|
|
578
|
+
specs.push(spec);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return { specs, remoteUnverifiable, dynamic };
|
|
582
|
+
}
|
|
583
|
+
// Registry hosts whose artifacts the canonical scan actually covers. Anything
|
|
584
|
+
// else is a different source than the one dg verifies against.
|
|
585
|
+
const DEFAULT_INDEX_HOSTS = new Set([
|
|
586
|
+
"registry.npmjs.org",
|
|
587
|
+
"registry.yarnpkg.com",
|
|
588
|
+
"pypi.org",
|
|
589
|
+
"files.pythonhosted.org",
|
|
590
|
+
]);
|
|
591
|
+
const PIP_INDEX_FLAGS = new Set(["-i", "--index-url", "--extra-index-url", "-f", "--find-links"]);
|
|
592
|
+
const NPM_INDEX_FLAGS = new Set(["--registry"]);
|
|
593
|
+
const PIP_INDEX_ENV = ["PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_INDEX"];
|
|
594
|
+
const NPM_INDEX_ENV = ["NPM_CONFIG_REGISTRY", "npm_config_registry", "YARN_REGISTRY", "yarn_registry"];
|
|
595
|
+
// True only for a URL pointing at a non-default registry host. A bare local path
|
|
596
|
+
// (--find-links ./wheels) is not flagged here; local artifacts are governed by
|
|
597
|
+
// the same on-disk-already policy as file: specs.
|
|
598
|
+
function isAlternateIndexValue(value) {
|
|
599
|
+
try {
|
|
600
|
+
return !DEFAULT_INDEX_HOSTS.has(new URL(value).hostname);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// A package screened against the canonical registry but actually fetched from a
|
|
607
|
+
// different index/registry was not really screened — the bytes come from
|
|
608
|
+
// elsewhere. Surface every alternate source so the verdict can't claim a clean
|
|
609
|
+
// pass it didn't earn.
|
|
610
|
+
function extractAlternateIndexes(eco, rawArgs, env) {
|
|
611
|
+
const out = [];
|
|
612
|
+
const flags = eco === "pypi" ? PIP_INDEX_FLAGS : NPM_INDEX_FLAGS;
|
|
613
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
614
|
+
const a = rawArgs[i];
|
|
615
|
+
if (a === undefined) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
let value;
|
|
619
|
+
if (flags.has(a)) {
|
|
620
|
+
value = rawArgs[i + 1];
|
|
621
|
+
i += 1;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
for (const f of flags) {
|
|
625
|
+
if (a.startsWith(`${f}=`)) {
|
|
626
|
+
value = a.slice(f.length + 1);
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (value !== undefined && isAlternateIndexValue(value)) {
|
|
632
|
+
out.push(value);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
for (const key of eco === "pypi" ? PIP_INDEX_ENV : NPM_INDEX_ENV) {
|
|
636
|
+
const v = env[key];
|
|
637
|
+
if (v && isAlternateIndexValue(v)) {
|
|
638
|
+
out.push(v);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
function normalizeName(eco, name) {
|
|
644
|
+
const lower = name.toLowerCase();
|
|
645
|
+
return eco === "pypi" ? lower.replace(/[._-]+/g, "-") : lower;
|
|
646
|
+
}
|
|
647
|
+
function quarantined(pkg, ctx, eco, dgExemptions = []) {
|
|
648
|
+
if (!ctx || !pkg.cooldown)
|
|
649
|
+
return false;
|
|
650
|
+
if (isCooldownExempt(pkg.name, ctx.exempt, eco) || isCooldownExemptByDgFile(pkg.name, eco, dgExemptions)) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
return (pkg.cooldown.status === "quarantine" ||
|
|
654
|
+
(pkg.cooldown.status === "unknown" && ctx.param.onUnknown === "block"));
|
|
655
|
+
}
|
|
656
|
+
function whyFor(pkg, action) {
|
|
657
|
+
if (action === "cooldown") {
|
|
658
|
+
return "release too new (cooldown)";
|
|
659
|
+
}
|
|
660
|
+
return pkg.reasons[0] ?? pkg.findings[0]?.title ?? pkg.findings[0]?.id ?? String(action);
|
|
661
|
+
}
|
|
662
|
+
function combinePackages(verdicts) {
|
|
663
|
+
if (verdicts.length === 0) {
|
|
664
|
+
return ALLOW;
|
|
665
|
+
}
|
|
666
|
+
const blocking = verdicts.filter((v) => v.action === "block" || v.action === "cooldown" || v.action === "analysis_incomplete");
|
|
667
|
+
if (blocking.length > 0) {
|
|
668
|
+
const list = blocking.map((v) => `${v.name}@${v.version} (${v.action}: ${v.why})`).join("; ");
|
|
669
|
+
return { decision: "deny", reason: `DG blocked install — ${list}`, packages: verdicts };
|
|
670
|
+
}
|
|
671
|
+
const list = verdicts.map((v) => `${v.name}@${v.version} (${v.why})`).join("; ");
|
|
672
|
+
return { decision: "ask", reason: `DG flagged for review — ${list}`, packages: verdicts };
|
|
673
|
+
}
|
|
674
|
+
function combineRaw(results) {
|
|
675
|
+
const blocked = results.find((v) => v.decision === "deny") ?? results.find((v) => v.decision === "ask");
|
|
676
|
+
if (blocked) {
|
|
677
|
+
return blocked;
|
|
678
|
+
}
|
|
679
|
+
const screened = results.flatMap((v) => v.screened ?? []);
|
|
680
|
+
return screened.length > 0 ? { decision: "allow", screened } : ALLOW;
|
|
681
|
+
}
|
|
682
|
+
function combine(results) {
|
|
683
|
+
const v = combineRaw(results);
|
|
684
|
+
// The reason is rendered into the agent's context (permissionDecisionReason)
|
|
685
|
+
// and the terminal. It interpolates attacker-influenced package names, so
|
|
686
|
+
// flatten control sequences before it leaves the firewall.
|
|
687
|
+
return v.decision !== "allow" && v.reason ? { ...v, reason: `${sanitizeLine(v.reason)} ${ESCALATE}` } : v;
|
|
688
|
+
}
|
|
689
|
+
async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
|
|
690
|
+
const parsed = commandTokens(segment);
|
|
691
|
+
if (parsed.shellScript !== undefined) {
|
|
692
|
+
if (depth < 8) {
|
|
693
|
+
// sh -c '<script>' / eval '<script>': re-parse the wrapped script so an
|
|
694
|
+
// install can't hide behind a shell-exec wrapper.
|
|
695
|
+
const inner = collectSegments(parsed.shellScript);
|
|
696
|
+
const results = await Promise.all(inner.map((s) => checkSegment(s, env, cwd, fetchImpl, depth + 1)));
|
|
697
|
+
return combineRaw(results);
|
|
698
|
+
}
|
|
699
|
+
// Recursion limit hit with an unparsed shell-exec wrapper still present: we
|
|
700
|
+
// cannot see what the innermost command runs, so deferring/holding is the
|
|
701
|
+
// only safe move. Falling through here would reach the ALLOW below (the
|
|
702
|
+
// wrapper head `sh`/`eval` is not a package manager) — a fail-OPEN that a
|
|
703
|
+
// deeply nested `sh -c '… sh -c "… npm install evil …"'` would exploit.
|
|
704
|
+
return undecidableVerdict(env, "dg cannot statically unwrap a deeply nested shell-exec command");
|
|
705
|
+
}
|
|
706
|
+
const { tokens, ok } = parsed;
|
|
707
|
+
const manager = normalizeManagerName(commandBasename(tokens[0] ?? ""));
|
|
708
|
+
if (!manager || !packageManagerNames().includes(manager) || !isSupportedPackageManager(manager)) {
|
|
709
|
+
const verb0 = tokens.slice(1).find((a) => !a.startsWith("-")) ?? "";
|
|
710
|
+
const unsupportedVerbs = UNSUPPORTED_INSTALLS.get(manager);
|
|
711
|
+
if (unsupportedVerbs && unsupportedVerbs.has(verb0)) {
|
|
712
|
+
return undecidableVerdict(env, `dg cannot statically screen ${manager} installs; they are covered only when the network gate is on`);
|
|
713
|
+
}
|
|
714
|
+
if (UNSUPPORTED_RUNNERS.has(manager) && verb0 && !isDynamicToken(verb0)) {
|
|
715
|
+
return undecidableVerdict(env, `dg cannot statically screen a ${manager} fetch-and-run; it is covered only when the network gate is on`);
|
|
716
|
+
}
|
|
717
|
+
return ALLOW;
|
|
718
|
+
}
|
|
719
|
+
if (!ok) {
|
|
720
|
+
// Head is a package manager but the segment could not be parsed safely
|
|
721
|
+
// (unbalanced quote / trailing escape). Never wave it through.
|
|
722
|
+
return { decision: "ask", reason: `dg could not safely parse this ${manager} command, so it cannot be verified` };
|
|
723
|
+
}
|
|
724
|
+
const args = tokens.slice(1);
|
|
725
|
+
const verb = args.find((a) => !a.startsWith("-")) ?? "";
|
|
726
|
+
if (isDynamicToken(verb)) {
|
|
727
|
+
// A dynamically-built subcommand (`npm $i pkg`) can't be classified — defer,
|
|
728
|
+
// don't silently allow (symmetry with a dynamic package name).
|
|
729
|
+
return undecidableVerdict(env, `dg cannot statically verify a dynamically-built ${manager} subcommand (${verb})`);
|
|
730
|
+
}
|
|
731
|
+
const classification = classifyPackageManagerInvocation(manager, args);
|
|
732
|
+
if (classification.kind !== "protected") {
|
|
733
|
+
return ALLOW;
|
|
734
|
+
}
|
|
735
|
+
const eco = analyzeEcosystem(classification.ecosystem);
|
|
736
|
+
if (eco === null) {
|
|
737
|
+
return { decision: "ask", reason: `dg cannot yet verify ${manager} packages` };
|
|
738
|
+
}
|
|
739
|
+
if (parsed.viaXargs) {
|
|
740
|
+
// xargs feeds the real package list on stdin, unknowable statically.
|
|
741
|
+
return undecidableVerdict(env, `dg cannot statically verify an xargs-fed ${manager} install (packages arrive on stdin)`);
|
|
742
|
+
}
|
|
743
|
+
const extracted = extractSpecs(manager, eco, args);
|
|
744
|
+
const { remoteUnverifiable, dynamic } = extracted;
|
|
745
|
+
let specs = extracted.specs;
|
|
746
|
+
if (dynamic.length > 0) {
|
|
747
|
+
return undecidableVerdict(env, `dg cannot statically verify a dynamically-built package name (${dynamic.join(", ")})`);
|
|
748
|
+
}
|
|
749
|
+
// No package named: a manifest install (npm install / npm ci / bare yarn,
|
|
750
|
+
// pip install -r). Screen the manifest's direct dependencies so a cloned
|
|
751
|
+
// hostile repo can't slip a malicious direct package past the static hook;
|
|
752
|
+
// the transitive tree stays the runtime network gate's job.
|
|
753
|
+
let manifestTruncated = false;
|
|
754
|
+
if (specs.length === 0 && remoteUnverifiable.length === 0) {
|
|
755
|
+
const manifest = eco === "npm" ? readNpmManifestSpecs(cwd) : readPipRequirementSpecs(args, cwd);
|
|
756
|
+
if (manifest) {
|
|
757
|
+
specs = manifest.specs;
|
|
758
|
+
manifestTruncated = manifest.truncated;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (specs.some((s) => CONTROL_CHARS.test(s.name) || (s.version !== null && CONTROL_CHARS.test(s.version)))) {
|
|
762
|
+
return { decision: "deny", reason: `dg refused a ${manager} install with a malformed package spec` };
|
|
763
|
+
}
|
|
764
|
+
const alternateIndexes = extractAlternateIndexes(eco, args, env);
|
|
765
|
+
const unverifiableReasons = [];
|
|
766
|
+
if (remoteUnverifiable.length > 0) {
|
|
767
|
+
unverifiableReasons.push(`non-registry source${remoteUnverifiable.length > 1 ? "s" : ""}: ${remoteUnverifiable.join(", ")}`);
|
|
768
|
+
}
|
|
769
|
+
if (alternateIndexes.length > 0) {
|
|
770
|
+
unverifiableReasons.push(`a non-default index/registry dg does not screen against: ${alternateIndexes.join(", ")}`);
|
|
771
|
+
}
|
|
772
|
+
if (manifestTruncated) {
|
|
773
|
+
unverifiableReasons.push("more manifest dependencies than the static hook screens — enable the network gate for full coverage");
|
|
774
|
+
}
|
|
775
|
+
const unverifiable = unverifiableReasons.length > 0
|
|
776
|
+
? { decision: "ask", reason: `dg cannot fully verify this install — ${unverifiableReasons.join("; ")}` }
|
|
777
|
+
: null;
|
|
778
|
+
if (specs.length === 0) {
|
|
779
|
+
return unverifiable ?? ALLOW;
|
|
780
|
+
}
|
|
781
|
+
const resolved = [];
|
|
782
|
+
for (const spec of specs) {
|
|
783
|
+
let version = spec.version;
|
|
784
|
+
if (!version) {
|
|
785
|
+
version = await resolveLatest(eco, spec.name, fetchImpl);
|
|
786
|
+
if (!version) {
|
|
787
|
+
return {
|
|
788
|
+
decision: "deny",
|
|
789
|
+
reason: `could not resolve a version to verify ${spec.name} on ${eco}, so the install is blocked`,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
resolved.push({ name: spec.name, version });
|
|
794
|
+
}
|
|
795
|
+
const cooldown = resolvePreflightCooldown(env, eco);
|
|
796
|
+
let decisions;
|
|
797
|
+
try {
|
|
798
|
+
decisions = resolvePreflightDecisions(eco, cwd, env);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
decisions = null;
|
|
802
|
+
}
|
|
803
|
+
let response;
|
|
804
|
+
try {
|
|
805
|
+
response = await analyzePackages(resolved, {
|
|
806
|
+
ecosystem: eco,
|
|
807
|
+
env,
|
|
808
|
+
fetchImpl,
|
|
809
|
+
...(cooldown ? { cooldown: cooldown.param } : {}),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
const message = error instanceof AnalyzeError ? error.message : "the dg scanner could not be reached";
|
|
814
|
+
return {
|
|
815
|
+
decision: "deny",
|
|
816
|
+
reason: `could not verify ${resolved.map((r) => r.name).join(", ")}: ${message}; the install is blocked`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const verdicts = [];
|
|
820
|
+
for (const pkg of response.packages) {
|
|
821
|
+
const isQuar = quarantined(pkg, cooldown, eco, decisions?.file.cooldownExemptions ?? []);
|
|
822
|
+
// A per-package result with no action is a coverage gap, not a clean pass:
|
|
823
|
+
// treat it as analysis_incomplete (blocking) so the firewall fails closed.
|
|
824
|
+
let action = isQuar && pkg.action !== "block" ? "cooldown" : pkg.action ?? "analysis_incomplete";
|
|
825
|
+
if (action === "warn" &&
|
|
826
|
+
decisions &&
|
|
827
|
+
matchDecision(pkg, decisions.ecosystem, decisions.file.decisions).acknowledged) {
|
|
828
|
+
action = "pass";
|
|
829
|
+
}
|
|
830
|
+
if (action !== "pass") {
|
|
831
|
+
verdicts.push({ name: pkg.name, version: pkg.version, action, why: whyFor(pkg, action) });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Every requested spec must come back with a verdict. A package the scanner
|
|
835
|
+
// omitted (partial/truncated/tampered response) must not be silently allowed.
|
|
836
|
+
const returned = new Set(response.packages.map((p) => normalizeName(eco, p.name)));
|
|
837
|
+
for (const spec of resolved) {
|
|
838
|
+
if (!returned.has(normalizeName(eco, spec.name))) {
|
|
839
|
+
verdicts.push({
|
|
840
|
+
name: spec.name,
|
|
841
|
+
version: spec.version,
|
|
842
|
+
action: "analysis_incomplete",
|
|
843
|
+
why: "scanner returned no verdict for this package",
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const base = combinePackages(verdicts);
|
|
848
|
+
if (unverifiable && base.decision === "allow") {
|
|
849
|
+
return unverifiable;
|
|
850
|
+
}
|
|
851
|
+
if (base.decision === "allow" && resolved.length > 0) {
|
|
852
|
+
return {
|
|
853
|
+
decision: "allow",
|
|
854
|
+
screened: resolved.map((r) => ({ name: r.name, version: r.version, ecosystem: eco })),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return base;
|
|
858
|
+
}
|
|
859
|
+
async function runAgentCheck(input) {
|
|
860
|
+
const env = input.env ?? process.env;
|
|
861
|
+
const cwd = input.cwd ?? process.cwd();
|
|
862
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
863
|
+
const segments = collectSegments(input.commandLine);
|
|
864
|
+
if (segments.length === 0) {
|
|
865
|
+
return ALLOW;
|
|
866
|
+
}
|
|
867
|
+
const results = [];
|
|
868
|
+
for (const seg of segments) {
|
|
869
|
+
results.push(await checkSegment(seg, env, cwd, fetchImpl));
|
|
870
|
+
}
|
|
871
|
+
return combine(results);
|
|
872
|
+
}
|
|
873
|
+
// The PreToolUse hook is killed by the agent at HOOK_TIMEOUT_MS (60s). A scanner
|
|
874
|
+
// or registry that is slow but not dead would let the hook die with no decision,
|
|
875
|
+
// which most agents read as allow — a silent fail-open. Race the whole check
|
|
876
|
+
// against a deadline comfortably under the hook budget and deny on expiry, so a
|
|
877
|
+
// slow path fails closed with margin to actually emit the verdict.
|
|
878
|
+
const CHECK_DEADLINE_MS = 45_000;
|
|
879
|
+
export async function agentCheckCommand(input) {
|
|
880
|
+
const deadlineMs = input.deadlineMs ?? CHECK_DEADLINE_MS;
|
|
881
|
+
let timer;
|
|
882
|
+
const deadline = new Promise((resolve) => {
|
|
883
|
+
timer = setTimeout(() => resolve({ decision: "deny", reason: `dg could not verify this install in time, so it is blocked. ${ESCALATE}` }), deadlineMs);
|
|
884
|
+
if (typeof timer.unref === "function") {
|
|
885
|
+
timer.unref();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
try {
|
|
889
|
+
return await Promise.race([runAgentCheck(input), deadline]);
|
|
890
|
+
}
|
|
891
|
+
finally {
|
|
892
|
+
if (timer) {
|
|
893
|
+
clearTimeout(timer);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|