@westbayberry/dg 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/claude-code.js +10 -0
- package/dist/agents/codex.js +1 -1
- package/dist/agents/cursor.js +6 -1
- package/dist/agents/gate-posture.js +21 -0
- package/dist/agents/persistence.js +68 -2
- package/dist/agents/registry.js +4 -3
- package/dist/agents/routing.js +118 -0
- package/dist/agents/windsurf.js +1 -1
- package/dist/audit/rules.js +6 -2
- package/dist/auth/store.js +9 -2
- package/dist/commands/agents.js +45 -1
- package/dist/commands/audit.js +6 -0
- package/dist/commands/setup.js +1 -2
- package/dist/commands/uninstall.js +2 -1
- package/dist/config/settings.js +35 -4
- package/dist/install-ui/LiveInstall.js +3 -2
- package/dist/install-ui/block-render.js +17 -13
- package/dist/launcher/agent-check.js +455 -25
- package/dist/launcher/agent-hook-exec.js +1 -1
- package/dist/launcher/agent-hook-io.js +12 -4
- package/dist/launcher/classify.js +27 -1
- package/dist/launcher/env.js +39 -7
- package/dist/launcher/install-preflight.js +15 -6
- package/dist/launcher/live-install.js +4 -3
- package/dist/launcher/manifest-screen.js +171 -0
- package/dist/launcher/output-redaction.js +4 -1
- package/dist/launcher/preflight-prompt.js +4 -3
- package/dist/launcher/run.js +90 -18
- package/dist/project/dgfile.js +2 -1
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/metadata-map.js +29 -13
- package/dist/proxy/server.js +130 -14
- package/dist/runtime/first-run.js +2 -1
- package/dist/sbom-ui/inventory.js +5 -1
- package/dist/scan/staged.js +31 -8
- package/dist/scan-ui/hooks/useScan.js +4 -1
- package/dist/security/sanitize.js +8 -4
- package/dist/service/state.js +1 -1
- package/dist/service/trust-store.js +5 -9
- package/dist/service/worker.js +3 -3
- package/dist/setup/plan.js +156 -12
- package/dist/setup/uninstall-standalone.js +25 -0
- package/dist/setup-ui/wizard.js +9 -1
- package/dist/standalone/uninstall.mjs +2123 -0
- package/dist/verify/local.js +2 -2
- package/dist/verify/package-check.js +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
|
@@ -1,10 +1,66 @@
|
|
|
1
1
|
import { analyzePackages, AnalyzeError } from "../api/analyze.js";
|
|
2
|
-
import { classifyPackageManagerInvocation, isSupportedPackageManager, packageManagerNames, } from "./classify.js";
|
|
2
|
+
import { classifyPackageManagerInvocation, isSupportedPackageManager, normalizeManagerName, packageManagerNames, } from "./classify.js";
|
|
3
3
|
import { resolveLatest } from "../verify/package-check.js";
|
|
4
4
|
import { matchDecision } from "../decisions/apply.js";
|
|
5
5
|
import { isCooldownExempt, isCooldownExemptByDgFile } from "../policy/cooldown.js";
|
|
6
6
|
import { resolvePreflightCooldown, resolvePreflightDecisions } from "./install-preflight.js";
|
|
7
|
+
import { sanitizeLine } from "../security/sanitize.js";
|
|
8
|
+
import { readNpmManifestSpecs, readPipRequirementSpecs } from "./manifest-screen.js";
|
|
7
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
|
+
}
|
|
8
64
|
// Flags that consume the following token as their value, so it must not be
|
|
9
65
|
// mistaken for a package name (the `-r requirements.txt` trap).
|
|
10
66
|
const PIP_VALUE_FLAGS = new Set([
|
|
@@ -22,7 +78,10 @@ function analyzeEcosystem(eco) {
|
|
|
22
78
|
return null;
|
|
23
79
|
}
|
|
24
80
|
function splitSegments(line) {
|
|
25
|
-
|
|
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);
|
|
26
85
|
}
|
|
27
86
|
function substitutionBodies(line) {
|
|
28
87
|
const bodies = [];
|
|
@@ -215,6 +274,36 @@ function lexSegment(segment) {
|
|
|
215
274
|
flush();
|
|
216
275
|
return { tokens, ok: false };
|
|
217
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
|
+
}
|
|
218
307
|
cur += ch;
|
|
219
308
|
started = true;
|
|
220
309
|
i += 1;
|
|
@@ -223,25 +312,121 @@ function lexSegment(segment) {
|
|
|
223
312
|
return { tokens, ok: true };
|
|
224
313
|
}
|
|
225
314
|
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
226
|
-
const
|
|
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"]);
|
|
227
354
|
function commandBasename(token) {
|
|
228
355
|
const slash = token.lastIndexOf("/");
|
|
229
356
|
return slash === -1 ? token : token.slice(slash + 1);
|
|
230
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
|
+
}
|
|
231
368
|
function commandTokens(segment) {
|
|
232
369
|
const lexed = lexSegment(segment);
|
|
233
|
-
let tokens = lexed.tokens;
|
|
370
|
+
let tokens = stripGrouping(lexed.tokens);
|
|
371
|
+
let viaXargs = false;
|
|
372
|
+
const x = () => (viaXargs ? { viaXargs: true } : {});
|
|
234
373
|
for (;;) {
|
|
235
374
|
while (tokens.length > 0 && ENV_ASSIGNMENT.test(tokens[0] ?? "")) {
|
|
236
375
|
tokens = tokens.slice(1);
|
|
237
376
|
}
|
|
238
|
-
const head = tokens[0];
|
|
239
|
-
if (head ===
|
|
240
|
-
return { tokens, ok: lexed.ok };
|
|
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;
|
|
241
419
|
}
|
|
242
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
|
+
}
|
|
243
424
|
while (tokens.length > 0 && (tokens[0] ?? "").startsWith("-")) {
|
|
425
|
+
const flag = tokens[0] ?? "";
|
|
244
426
|
tokens = tokens.slice(1);
|
|
427
|
+
if (spec.valueFlags.has(flag) && tokens.length > 0 && !(tokens[0] ?? "").startsWith("-")) {
|
|
428
|
+
tokens = tokens.slice(1);
|
|
429
|
+
}
|
|
245
430
|
}
|
|
246
431
|
}
|
|
247
432
|
}
|
|
@@ -264,6 +449,12 @@ function parseSpecToken(eco, token) {
|
|
|
264
449
|
}
|
|
265
450
|
return { name: token, version: null };
|
|
266
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
|
+
}
|
|
267
458
|
function isLocalSpecToken(t) {
|
|
268
459
|
return (t.startsWith(".") ||
|
|
269
460
|
t.startsWith("/") ||
|
|
@@ -271,11 +462,72 @@ function isLocalSpecToken(t) {
|
|
|
271
462
|
t.startsWith("link:") ||
|
|
272
463
|
t.startsWith("workspace:"));
|
|
273
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
|
+
}
|
|
274
517
|
function extractSpecs(manager, eco, rawArgs) {
|
|
518
|
+
if (manager === "uv" && rawArgs.find((a) => !a.startsWith("-")) === "run") {
|
|
519
|
+
return extractUvRunWithSpecs(rawArgs);
|
|
520
|
+
}
|
|
275
521
|
const valueFlags = eco === "pypi" ? PIP_VALUE_FLAGS : NPM_VALUE_FLAGS;
|
|
276
522
|
const noVerb = manager === "npx" || manager === "pnpx" || manager === "uvx";
|
|
277
523
|
const positionals = [];
|
|
278
|
-
|
|
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;
|
|
279
531
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
280
532
|
const a = rawArgs[i];
|
|
281
533
|
if (a === undefined) {
|
|
@@ -287,8 +539,17 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
287
539
|
}
|
|
288
540
|
continue;
|
|
289
541
|
}
|
|
290
|
-
if (!
|
|
291
|
-
|
|
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;
|
|
292
553
|
continue;
|
|
293
554
|
}
|
|
294
555
|
positionals.push(a);
|
|
@@ -296,10 +557,15 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
296
557
|
const tokens = noVerb ? positionals.slice(0, 1) : positionals;
|
|
297
558
|
const specs = [];
|
|
298
559
|
const remoteUnverifiable = [];
|
|
560
|
+
const dynamic = [];
|
|
299
561
|
for (const t of tokens) {
|
|
300
562
|
if (t.length === 0) {
|
|
301
563
|
continue;
|
|
302
564
|
}
|
|
565
|
+
if (isDynamicToken(t)) {
|
|
566
|
+
dynamic.push(t);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
303
569
|
if (t.includes("://") || t.startsWith("git+")) {
|
|
304
570
|
remoteUnverifiable.push(t);
|
|
305
571
|
continue;
|
|
@@ -312,7 +578,67 @@ function extractSpecs(manager, eco, rawArgs) {
|
|
|
312
578
|
specs.push(spec);
|
|
313
579
|
}
|
|
314
580
|
}
|
|
315
|
-
return { specs, remoteUnverifiable };
|
|
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;
|
|
316
642
|
}
|
|
317
643
|
function normalizeName(eco, name) {
|
|
318
644
|
const lower = name.toLowerCase();
|
|
@@ -345,35 +671,109 @@ function combinePackages(verdicts) {
|
|
|
345
671
|
const list = verdicts.map((v) => `${v.name}@${v.version} (${v.why})`).join("; ");
|
|
346
672
|
return { decision: "ask", reason: `DG flagged for review — ${list}`, packages: verdicts };
|
|
347
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
|
+
}
|
|
348
682
|
function combine(results) {
|
|
349
|
-
|
|
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;
|
|
350
688
|
}
|
|
351
|
-
async function checkSegment(segment, env, cwd, fetchImpl) {
|
|
352
|
-
const
|
|
353
|
-
|
|
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] ?? ""));
|
|
354
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
|
+
}
|
|
355
717
|
return ALLOW;
|
|
356
718
|
}
|
|
357
719
|
if (!ok) {
|
|
358
720
|
// Head is a package manager but the segment could not be parsed safely
|
|
359
721
|
// (unbalanced quote / trailing escape). Never wave it through.
|
|
360
|
-
return { decision: "ask", reason: `dg could not safely parse this ${manager} command
|
|
722
|
+
return { decision: "ask", reason: `dg could not safely parse this ${manager} command, so it cannot be verified` };
|
|
361
723
|
}
|
|
362
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
|
+
}
|
|
363
731
|
const classification = classifyPackageManagerInvocation(manager, args);
|
|
364
732
|
if (classification.kind !== "protected") {
|
|
365
733
|
return ALLOW;
|
|
366
734
|
}
|
|
367
735
|
const eco = analyzeEcosystem(classification.ecosystem);
|
|
368
736
|
if (eco === null) {
|
|
369
|
-
return { decision: "ask", reason: `dg cannot yet verify ${manager} packages
|
|
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(", ")})`);
|
|
370
748
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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;
|
|
376
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("; ")}` }
|
|
377
777
|
: null;
|
|
378
778
|
if (specs.length === 0) {
|
|
379
779
|
return unverifiable ?? ALLOW;
|
|
@@ -386,7 +786,7 @@ async function checkSegment(segment, env, cwd, fetchImpl) {
|
|
|
386
786
|
if (!version) {
|
|
387
787
|
return {
|
|
388
788
|
decision: "deny",
|
|
389
|
-
reason: `could not resolve a version to verify ${spec.name} on ${eco}
|
|
789
|
+
reason: `could not resolve a version to verify ${spec.name} on ${eco}, so the install is blocked`,
|
|
390
790
|
};
|
|
391
791
|
}
|
|
392
792
|
}
|
|
@@ -413,7 +813,7 @@ async function checkSegment(segment, env, cwd, fetchImpl) {
|
|
|
413
813
|
const message = error instanceof AnalyzeError ? error.message : "the dg scanner could not be reached";
|
|
414
814
|
return {
|
|
415
815
|
decision: "deny",
|
|
416
|
-
reason: `could not verify ${resolved.map((r) => r.name).join(", ")}: ${message};
|
|
816
|
+
reason: `could not verify ${resolved.map((r) => r.name).join(", ")}: ${message}; the install is blocked`,
|
|
417
817
|
};
|
|
418
818
|
}
|
|
419
819
|
const verdicts = [];
|
|
@@ -448,9 +848,15 @@ async function checkSegment(segment, env, cwd, fetchImpl) {
|
|
|
448
848
|
if (unverifiable && base.decision === "allow") {
|
|
449
849
|
return unverifiable;
|
|
450
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
|
+
}
|
|
451
857
|
return base;
|
|
452
858
|
}
|
|
453
|
-
|
|
859
|
+
async function runAgentCheck(input) {
|
|
454
860
|
const env = input.env ?? process.env;
|
|
455
861
|
const cwd = input.cwd ?? process.cwd();
|
|
456
862
|
const fetchImpl = input.fetchImpl ?? fetch;
|
|
@@ -464,3 +870,27 @@ export async function agentCheckCommand(input) {
|
|
|
464
870
|
}
|
|
465
871
|
return combine(results);
|
|
466
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
|
+
}
|
|
@@ -36,7 +36,7 @@ export async function maybeAgentHookExec(args, opts = {}) {
|
|
|
36
36
|
result: {
|
|
37
37
|
exitCode: 2,
|
|
38
38
|
stdout: "",
|
|
39
|
-
stderr: `dg hook-exec: unknown agent '${agent ?? ""}' — blocked under the firewall; update dg (npm i -g @westbayberry/dg)
|
|
39
|
+
stderr: `dg hook-exec: unknown agent '${agent ?? ""}' — blocked under the firewall; update dg (npm i -g @westbayberry/dg)\n`
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
42
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { agentCheckCommand } from "./agent-check.js";
|
|
1
|
+
import { agentCheckCommand, ESCALATE, formatScreenedNote } from "./agent-check.js";
|
|
2
2
|
import { getAgent } from "../agents/registry.js";
|
|
3
|
+
import { redactSecrets } from "./output-redaction.js";
|
|
3
4
|
export async function runAgentHookExec(agent, stdin, deps = {}) {
|
|
4
5
|
const integration = getAgent(agent);
|
|
5
6
|
const parsed = integration.parseInput(stdin);
|
|
6
7
|
if (!parsed) {
|
|
7
8
|
// Unreadable payload -> fail closed (deny) so a malformed hook input can
|
|
8
9
|
// never slip an install through unverified.
|
|
9
|
-
const verdict = { decision: "deny", reason:
|
|
10
|
+
const verdict = { decision: "deny", reason: `dg hook: could not read the tool command, so the install is blocked. ${ESCALATE}` };
|
|
10
11
|
const emitted = integration.emitDecision(verdict);
|
|
11
12
|
return { stdout: emitted.stdout, stderr: "dg hook: malformed hook payload\n", exitCode: emitted.exitCode };
|
|
12
13
|
}
|
|
@@ -23,9 +24,16 @@ export async function runAgentHookExec(agent, stdin, deps = {}) {
|
|
|
23
24
|
// Any failure computing the verdict must fail closed: an unhandled throw
|
|
24
25
|
// would otherwise exit non-zero with no decision, which most agents read
|
|
25
26
|
// as allow.
|
|
26
|
-
verdict = { decision: "deny", reason:
|
|
27
|
+
verdict = { decision: "deny", reason: `dg hook: the firewall check failed, so the install is blocked. ${ESCALATE}` };
|
|
27
28
|
}
|
|
28
29
|
const emitted = integration.emitDecision(verdict);
|
|
29
|
-
const stderr = verdict
|
|
30
|
+
const stderr = allowStderr(verdict);
|
|
30
31
|
return { stdout: emitted.stdout, stderr, exitCode: emitted.exitCode };
|
|
31
32
|
}
|
|
33
|
+
function allowStderr(verdict) {
|
|
34
|
+
if (verdict.decision !== "allow") {
|
|
35
|
+
return ` ${redactSecrets(verdict.reason ?? "DG firewall")}\n`;
|
|
36
|
+
}
|
|
37
|
+
const note = verdict.screened ? formatScreenedNote(verdict.screened) : "";
|
|
38
|
+
return note ? ` ${redactSecrets(note)}\n` : "";
|
|
39
|
+
}
|