@westbayberry/dg 2.0.10 → 2.1.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.
Files changed (43) hide show
  1. package/dist/api/analyze.js +5 -3
  2. package/dist/bin/dg.js +1 -1
  3. package/dist/commands/completion.js +2 -1
  4. package/dist/commands/config.js +11 -3
  5. package/dist/commands/decisions.js +155 -0
  6. package/dist/commands/explain.js +6 -2
  7. package/dist/commands/router.js +2 -0
  8. package/dist/commands/scan.js +2 -1
  9. package/dist/commands/status.js +5 -2
  10. package/dist/config/settings.js +144 -25
  11. package/dist/decisions/apply.js +128 -0
  12. package/dist/decisions/remember-prompt.js +97 -0
  13. package/dist/install-ui/block-render.js +21 -4
  14. package/dist/install-ui/prompt.js +14 -0
  15. package/dist/launcher/install-preflight.js +126 -13
  16. package/dist/launcher/preflight-prompt.js +29 -2
  17. package/dist/launcher/run.js +14 -3
  18. package/dist/policy/cooldown.js +104 -0
  19. package/dist/policy/evaluate.js +0 -15
  20. package/dist/presentation/provenance.js +23 -0
  21. package/dist/project/dgfile.js +307 -0
  22. package/dist/proxy/enforcement.js +2 -1
  23. package/dist/proxy/metadata-map.js +25 -1
  24. package/dist/proxy/server.js +31 -2
  25. package/dist/scan/collect.js +10 -4
  26. package/dist/scan/command.js +35 -8
  27. package/dist/scan/discovery.js +66 -4
  28. package/dist/scan/render.js +35 -4
  29. package/dist/scan/scanner-report.js +31 -4
  30. package/dist/scan/staged.js +69 -10
  31. package/dist/scan-ui/LegacyApp.js +4 -4
  32. package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
  33. package/dist/scan-ui/hooks/useScan.js +31 -3
  34. package/dist/scan-ui/launch.js +4 -1
  35. package/dist/scan-ui/shims.js +3 -0
  36. package/dist/scripts/detect.js +153 -0
  37. package/dist/scripts/gate.js +170 -0
  38. package/dist/scripts/rebuild.js +28 -0
  39. package/dist/setup/plan.js +36 -1
  40. package/dist/util/json-file.js +24 -0
  41. package/dist/util/tty-prompt.js +13 -6
  42. package/dist/verify/package-check.js +12 -0
  43. package/package.json +9 -1
@@ -0,0 +1,128 @@
1
+ export function packageKey(name, version) {
2
+ return `${name}@${version}`;
3
+ }
4
+ export function findingCategory(finding) {
5
+ return finding.category ?? finding.id ?? "unknown";
6
+ }
7
+ export function findingFingerprint(findings) {
8
+ const fingerprint = {};
9
+ for (const finding of findings) {
10
+ const category = findingCategory(finding);
11
+ fingerprint[category] = Math.max(fingerprint[category] ?? 0, finding.severity);
12
+ }
13
+ return fingerprint;
14
+ }
15
+ export function matchDecision(pkg, ecosystem, entries, now = new Date()) {
16
+ if ((pkg.action ?? "pass") !== "warn") {
17
+ return { acknowledged: false };
18
+ }
19
+ const fingerprint = findingFingerprint(pkg.findings);
20
+ let nearestUncovered;
21
+ for (const entry of entries) {
22
+ if (entry.ecosystem !== ecosystem || entry.name !== pkg.name) {
23
+ continue;
24
+ }
25
+ if (!scopeMatches(entry, pkg.version)) {
26
+ continue;
27
+ }
28
+ if (isExpired(entry, now)) {
29
+ continue;
30
+ }
31
+ const uncovered = uncoveredFindings(fingerprint, entry.findings);
32
+ if (uncovered.length === 0) {
33
+ return { acknowledged: true, entry };
34
+ }
35
+ if (!nearestUncovered || uncovered.length < nearestUncovered.length) {
36
+ nearestUncovered = uncovered;
37
+ }
38
+ }
39
+ return nearestUncovered ? { acknowledged: false, newFindings: nearestUncovered } : { acknowledged: false };
40
+ }
41
+ export function applyDecisions(packages, ecosystemOf, file, rawAction, now = new Date()) {
42
+ const annotations = {};
43
+ let acknowledgedCount = 0;
44
+ let worst = 0;
45
+ for (const pkg of packages) {
46
+ const action = pkg.action ?? "pass";
47
+ if (action === "pass") {
48
+ continue;
49
+ }
50
+ const ecosystem = ecosystemOf(pkg);
51
+ if (!ecosystem) {
52
+ worst = Math.max(worst, actionSeverity(action));
53
+ continue;
54
+ }
55
+ const match = file.readable ? matchDecision(pkg, ecosystem, file.decisions, now) : { acknowledged: false };
56
+ const key = packageKey(pkg.name, pkg.version);
57
+ if (match.acknowledged) {
58
+ acknowledgedCount += 1;
59
+ annotations[key] = {
60
+ ecosystem,
61
+ acknowledged: {
62
+ decisionId: match.entry.id,
63
+ by: match.entry.acceptedBy,
64
+ at: match.entry.acceptedAt,
65
+ reason: match.entry.reason
66
+ }
67
+ };
68
+ }
69
+ else {
70
+ annotations[key] = {
71
+ ecosystem,
72
+ ...(match.newFindings ? { newFindings: match.newFindings } : {})
73
+ };
74
+ worst = Math.max(worst, actionSeverity(action));
75
+ }
76
+ }
77
+ const effectiveAction = acknowledgedCount === 0 || worst >= actionSeverity(rawAction) ? rawAction : actionFromSeverity(worst);
78
+ return {
79
+ file: file.path,
80
+ acknowledgedCount,
81
+ effectiveAction,
82
+ packages: annotations
83
+ };
84
+ }
85
+ const ACTION_SEVERITY = {
86
+ pass: 0,
87
+ analysis_incomplete: 1,
88
+ warn: 2,
89
+ block: 3
90
+ };
91
+ function actionSeverity(action) {
92
+ return ACTION_SEVERITY[action] ?? 0;
93
+ }
94
+ function actionFromSeverity(severity) {
95
+ if (severity >= 3) {
96
+ return "block";
97
+ }
98
+ if (severity === 2) {
99
+ return "warn";
100
+ }
101
+ if (severity === 1) {
102
+ return "analysis_incomplete";
103
+ }
104
+ return "pass";
105
+ }
106
+ function scopeMatches(entry, version) {
107
+ if (entry.scope.kind === "any") {
108
+ return true;
109
+ }
110
+ return entry.scope.version === version;
111
+ }
112
+ function isExpired(entry, now) {
113
+ if (!entry.expiresAt) {
114
+ return false;
115
+ }
116
+ const expiry = Date.parse(entry.expiresAt);
117
+ return !Number.isFinite(expiry) || expiry <= now.getTime();
118
+ }
119
+ function uncoveredFindings(fingerprint, accepted) {
120
+ const uncovered = [];
121
+ for (const [category, severity] of Object.entries(fingerprint)) {
122
+ const acceptedSeverity = accepted[category];
123
+ if (acceptedSeverity === undefined || severity > acceptedSeverity) {
124
+ uncovered.push(`${category}:${severity}`);
125
+ }
126
+ }
127
+ return uncovered.sort();
128
+ }
@@ -0,0 +1,97 @@
1
+ import { emitWebhookEvent, recordAuditEvent } from "../audit/events.js";
2
+ import { loadUserConfig } from "../config/settings.js";
3
+ import { promptText, promptYesNo } from "../install-ui/prompt.js";
4
+ import { appendDecisions, saveDgFile } from "../project/dgfile.js";
5
+ import { promptLine as ttyPromptLine, promptYesNo as ttyPromptYesNo } from "../util/tty-prompt.js";
6
+ import { findingFingerprint, packageKey } from "./apply.js";
7
+ const defaultSyncPrompts = {
8
+ yesNo: (question, defaultYes) => ttyPromptYesNo(question, defaultYes),
9
+ line: (question) => ttyPromptLine(question),
10
+ write: (text) => process.stderr.write(text)
11
+ };
12
+ export async function offerRememberOnIo(options) {
13
+ const { io, file, packages } = options;
14
+ if (!io.isTTY || !file.readable || packages.length === 0) {
15
+ return false;
16
+ }
17
+ const choice = (await promptText(" Remember this acceptance? [v] this version / [o] just once: ", io)).trim().toLowerCase();
18
+ if (choice !== "v" && choice !== "version") {
19
+ return false;
20
+ }
21
+ if (!file.exists) {
22
+ const create = await promptYesNo(` Create ${file.path}?`, io, false);
23
+ if (!create) {
24
+ return false;
25
+ }
26
+ }
27
+ const reason = (await promptText(" Reason (Enter to skip): ", io)).trim();
28
+ persistRemembered(file, packages, {
29
+ reason: reason || `accepted at ${options.surface}`,
30
+ acceptedBy: options.acceptedBy,
31
+ env: options.env ?? process.env
32
+ });
33
+ io.output.write(` ✓ remembered in ${file.path} — review with 'dg decisions'\n`);
34
+ return true;
35
+ }
36
+ export function offerRememberSync(options) {
37
+ const { file, packages } = options;
38
+ const prompts = options.prompts ?? defaultSyncPrompts;
39
+ if (!file.readable || packages.length === 0) {
40
+ return false;
41
+ }
42
+ const remember = prompts.yesNo(" Remember this acceptance in dg.json for future commits?", false);
43
+ if (remember !== true) {
44
+ return false;
45
+ }
46
+ if (!file.exists) {
47
+ const create = prompts.yesNo(` Create ${file.path}?`, false);
48
+ if (create !== true) {
49
+ return false;
50
+ }
51
+ }
52
+ const reason = (prompts.line(" Reason (Enter to skip): ") ?? "").trim();
53
+ persistRemembered(file, packages, {
54
+ reason: reason || `accepted at ${options.surface}`,
55
+ acceptedBy: options.acceptedBy,
56
+ env: options.env ?? process.env
57
+ });
58
+ prompts.write(` ✓ remembered in ${file.path} — review with 'dg decisions'\n`);
59
+ return true;
60
+ }
61
+ export function persistRemembered(file, packages, options) {
62
+ const additions = packages.map((pkg) => ({
63
+ ecosystem: pkg.ecosystem,
64
+ name: pkg.name,
65
+ scope: { kind: "exact", version: pkg.version },
66
+ findings: findingFingerprint(pkg.findings),
67
+ reason: options.reason,
68
+ acceptedBy: options.acceptedBy
69
+ }));
70
+ saveDgFile(appendDecisions(file, additions));
71
+ recordDecisionEvents("decision.accepted", packages.map((pkg) => `${pkg.ecosystem}:${packageKey(pkg.name, pkg.version)}`), options.reason, options.env);
72
+ }
73
+ export function recordDecisionEvents(type, packageNames, reason, env) {
74
+ let policyMode = "unknown";
75
+ try {
76
+ policyMode = loadUserConfig(env).policy.mode;
77
+ }
78
+ catch {
79
+ // a corrupt user config must not block recording the decision trail
80
+ }
81
+ for (const packageName of packageNames) {
82
+ const event = {
83
+ type,
84
+ packageName,
85
+ reason,
86
+ policyMode,
87
+ createdAt: new Date().toISOString()
88
+ };
89
+ try {
90
+ recordAuditEvent(event, env);
91
+ emitWebhookEvent(event, env);
92
+ }
93
+ catch {
94
+ // audit trail is best-effort; the decision write already succeeded
95
+ }
96
+ }
97
+ }
@@ -1,3 +1,4 @@
1
+ import { formatCooldownDuration, formatPackageAge } from "../policy/cooldown.js";
1
2
  const VERIFIED_BAD = new Set([
2
3
  "malware",
3
4
  "policy",
@@ -19,6 +20,7 @@ const HEADLINES = {
19
20
  "api-timeout": "scanner timed out",
20
21
  "registry-timeout": "registry timed out",
21
22
  "analysis-incomplete": "analysis incomplete",
23
+ cooldown: "release too new (cooldown)",
22
24
  "unsupported-manager": "unsupported package manager",
23
25
  "proxy-setup-failure": "protection unavailable"
24
26
  };
@@ -29,14 +31,24 @@ const NEXT_STEP = {
29
31
  "hash-mismatch": "Clear your package cache and retry. If it persists, do not install.",
30
32
  "private-upload-disabled": "Enable private artifact scanning to verify this package.",
31
33
  "needs-login": "Run 'dg login' (free) to check packages from the registry before they install.",
32
- "quota-exceeded": "Upgrade your plan or wait for your monthly limit to reset. See westbayberry.com/pricing."
34
+ "quota-exceeded": "Upgrade your plan or wait for your monthly limit to reset. See westbayberry.com/pricing.",
35
+ cooldown: "Wait for the cooldown, pin an older version, or exempt it: dg config set cooldown.exempt <name>"
33
36
  };
37
+ function cooldownDetailLine(cooldown) {
38
+ const window = formatCooldownDuration(cooldown.requiredDays);
39
+ const eligible = formatResetDate(cooldown.eligibleAt);
40
+ const suffix = eligible ? ` (eligible ${eligible})` : "";
41
+ if (cooldown.ageDays === undefined) {
42
+ return `publish time unknown; your cooldown is ${window}${suffix}`;
43
+ }
44
+ return `published ${formatPackageAge(cooldown.ageDays)}; your cooldown is ${window}${suffix}`;
45
+ }
34
46
  export function describeBlockedInstall(decision) {
35
47
  const verifiedBad = VERIFIED_BAD.has(decision.cause);
36
48
  const override = decision.forceOverride && !decision.forceOverride.allowed
37
49
  ? "not allowed by your policy"
38
50
  : "re-run with --dg-force-install";
39
- const nextStep = verifiedBad || decision.cause === "needs-login"
51
+ const nextStep = verifiedBad || decision.cause === "needs-login" || decision.cause === "cooldown"
40
52
  ? NEXT_STEP[decision.cause]
41
53
  : "Re-check later with 'dg verify', or override if you accept the risk.";
42
54
  return {
@@ -85,9 +97,14 @@ export function renderInstallDecision(decision) {
85
97
  const lines = [
86
98
  verifiedBad
87
99
  ? `✘ DG blocked install — ${headline}`
88
- : `? DG could not verify ${decision.packageName} ${headline}`,
100
+ : decision.cause === "cooldown"
101
+ ? `? DG quarantined ${decision.packageName} — ${headline}`
102
+ : `? DG could not verify ${decision.packageName} — ${headline}`,
89
103
  ` ${decision.packageName} ${decision.reason}`
90
104
  ];
105
+ if (decision.cause === "cooldown" && decision.cooldown) {
106
+ lines.push(` ${cooldownDetailLine(decision.cooldown)}`);
107
+ }
91
108
  if (decision.dashboardUrl) {
92
109
  lines.push(` Evidence: ${decision.dashboardUrl}`);
93
110
  }
@@ -97,7 +114,7 @@ export function renderInstallDecision(decision) {
97
114
  lines.push(decision.forceOverride && !decision.forceOverride.allowed
98
115
  ? " Override: not allowed by your policy"
99
116
  : " Override: re-run with --dg-force-install");
100
- const next = verifiedBad || decision.cause === "needs-login"
117
+ const next = verifiedBad || decision.cause === "needs-login" || decision.cause === "cooldown"
101
118
  ? NEXT_STEP[decision.cause]
102
119
  : "Re-check later with 'dg verify', or override if you accept the risk.";
103
120
  if (next) {
@@ -6,6 +6,20 @@ export function defaultPromptIo() {
6
6
  isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY)
7
7
  };
8
8
  }
9
+ export async function promptText(question, io) {
10
+ if (!io.isTTY) {
11
+ return "";
12
+ }
13
+ const rl = createInterface({ input: io.input, output: io.output });
14
+ try {
15
+ return await new Promise((resolve) => {
16
+ rl.question(question, resolve);
17
+ });
18
+ }
19
+ finally {
20
+ rl.close();
21
+ }
22
+ }
9
23
  export async function promptYesNo(question, io, defaultYes = false) {
10
24
  if (!io.isTTY) {
11
25
  return false;
@@ -1,8 +1,46 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { analyzePackages } from "../api/analyze.js";
4
+ import { loadUserConfig } from "../config/settings.js";
5
+ import { matchDecision, packageKey } from "../decisions/apply.js";
6
+ import { offerRememberOnIo } from "../decisions/remember-prompt.js";
7
+ import { provenanceDowngradeLine } from "../presentation/provenance.js";
4
8
  import { defaultPromptIo } from "../install-ui/prompt.js";
9
+ import { cooldownRequestParam, formatCooldownDuration, formatPackageAge, isCooldownExempt } from "../policy/cooldown.js";
10
+ import { findProjectRoot, loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
5
11
  import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
12
+ export function resolvePreflightCooldown(env) {
13
+ try {
14
+ const config = loadUserConfig(env);
15
+ const param = cooldownRequestParam(config, env, "pypi", "");
16
+ return param ? { param, exempt: config.cooldown.exempt } : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ function isQuarantined(pkg, context) {
23
+ if (!context || !pkg.cooldown) {
24
+ return false;
25
+ }
26
+ if (isCooldownExempt(pkg.name, context.exempt, "pypi")) {
27
+ return false;
28
+ }
29
+ return pkg.cooldown.status === "quarantine"
30
+ || (pkg.cooldown.status === "unknown" && context.param.onUnknown === "block");
31
+ }
32
+ export function resolvePreflightDecisions(ecosystem, cwd, env = process.env) {
33
+ const root = findProjectRoot(cwd, env);
34
+ if (!root) {
35
+ return null;
36
+ }
37
+ const file = loadDgFile(root);
38
+ warnUnreadableDgFile(file);
39
+ if (!file.readable) {
40
+ return null;
41
+ }
42
+ return { root, file, ecosystem, env };
43
+ }
6
44
  const PROCEED = { proceed: true };
7
45
  const UNRESOLVED = { set: undefined, count: undefined };
8
46
  const approvedFlagRanks = new Map();
@@ -75,18 +113,48 @@ function resolvePipInstallSet(binary, args, env) {
75
113
  function findingSummary(pkg) {
76
114
  return pkg.reasons[0] ?? pkg.findings[0]?.title ?? pkg.findings[0]?.id ?? "flagged";
77
115
  }
116
+ function cooldownSummary(pkg) {
117
+ const cooldown = pkg.cooldown;
118
+ if (!cooldown) {
119
+ return "release too new";
120
+ }
121
+ const window = cooldown.requiredDays !== undefined ? formatCooldownDuration(cooldown.requiredDays) : "your cooldown";
122
+ if (cooldown.ageDays === undefined) {
123
+ return `publish time unknown; cooldown ${window}`;
124
+ }
125
+ return `published ${formatPackageAge(cooldown.ageDays)}; cooldown ${window}`;
126
+ }
78
127
  function renderPreflight(flagged, out) {
79
- const blocks = flagged.filter((pkg) => pkg.action === "block").length;
128
+ const blocks = flagged.filter((entry) => entry.action === "block").length;
80
129
  const noun = flagged.length === 1 ? "package" : "packages";
81
130
  const tail = blocks > 0 ? ` (${blocks} blocked)` : "";
82
131
  out.write(`\n DG flagged ${flagged.length} ${noun} before install${tail}:\n`);
83
- for (const pkg of flagged) {
84
- const tag = pkg.action === "block" ? "block" : "warn";
85
- out.write(` ${pkg.name}@${pkg.version} ${tag} ${findingSummary(pkg)}\n`);
132
+ for (const entry of flagged) {
133
+ const tag = entry.viaCooldown ? "cooldown" : entry.action === "block" ? "block" : "warn";
134
+ const summary = entry.viaCooldown ? cooldownSummary(entry.pkg) : findingSummary(entry.pkg);
135
+ out.write(` ${entry.pkg.name}@${entry.pkg.version} ${tag} ${summary}${provenanceSuffix(entry.pkg)}\n`);
86
136
  }
87
137
  out.write("\n");
88
138
  }
89
- export async function runInstallPreflight(manager, binary, childArgs, env) {
139
+ function provenanceSuffix(pkg) {
140
+ const prov = pkg.provenance;
141
+ if (!prov || prov.status === "unknown") {
142
+ return "";
143
+ }
144
+ return ` provenance: ${prov.status}`;
145
+ }
146
+ export function renderProvenanceDowngrades(packages, out) {
147
+ const downgraded = packages.filter((pkg) => pkg.provenance?.downgrade);
148
+ if (downgraded.length === 0) {
149
+ return;
150
+ }
151
+ out.write("\n");
152
+ for (const pkg of downgraded) {
153
+ const line = provenanceDowngradeLine(pkg.version, pkg.provenance);
154
+ out.write(` ⚠ ${pkg.name}@${pkg.version}: ${line} (display only, verdict unchanged)\n`);
155
+ }
156
+ }
157
+ export async function runInstallPreflight(manager, binary, childArgs, env, cwd = process.cwd()) {
90
158
  if (manager !== "pip" || !binary) {
91
159
  return PROCEED;
92
160
  }
@@ -100,29 +168,74 @@ export async function runInstallPreflight(manager, binary, childArgs, env) {
100
168
  if (!set || set.length === 0) {
101
169
  return PROCEED;
102
170
  }
171
+ const cooldownContext = resolvePreflightCooldown(env);
103
172
  let verdicts;
104
173
  try {
105
- verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env });
174
+ verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env, ...(cooldownContext ? { cooldown: cooldownContext.param } : {}) });
106
175
  }
107
176
  catch {
108
177
  return PROCEED;
109
178
  }
110
- return decideFromVerdicts(verdicts.packages, defaultPromptIo());
179
+ return decideFromVerdicts(verdicts.packages, defaultPromptIo(), cooldownContext, resolvePreflightDecisions("pypi", cwd, env));
111
180
  }
112
- export async function decideFromVerdicts(packages, io) {
113
- const flagged = packages.filter((pkg) => (pkg.action === "warn" || pkg.action === "block") && !isPreflightApproved({ name: pkg.name, version: pkg.version, action: pkg.action }));
114
- if (flagged.length === 0 || !io.isTTY) {
181
+ export async function decideFromVerdicts(packages, io, cooldownContext, decisions = null) {
182
+ if (io.isTTY) {
183
+ renderProvenanceDowngrades(packages, io.output);
184
+ }
185
+ const covered = [];
186
+ const entries = packages
187
+ .map((pkg) => {
188
+ const viaCooldown = isQuarantined(pkg, cooldownContext) && pkg.action !== "block";
189
+ const action = viaCooldown ? "block" : pkg.action ?? "pass";
190
+ return { pkg, action, viaCooldown };
191
+ })
192
+ .filter((entry) => {
193
+ if ((entry.action !== "warn" && entry.action !== "block")
194
+ || isPreflightApproved({ name: entry.pkg.name, version: entry.pkg.version, action: entry.action })) {
195
+ return false;
196
+ }
197
+ if (decisions && entry.action === "warn" && matchDecision(entry.pkg, decisions.ecosystem, decisions.file.decisions).acknowledged) {
198
+ covered.push(entry.pkg);
199
+ return false;
200
+ }
201
+ return true;
202
+ });
203
+ if (covered.length > 0 && io.isTTY) {
204
+ renderCoveredWarns(covered, decisions, io.output);
205
+ recordPreflightApprovals(covered.map((pkg) => ({ name: pkg.name, version: pkg.version, action: "warn" })));
206
+ }
207
+ if (entries.length === 0 || !io.isTTY) {
115
208
  return PROCEED;
116
209
  }
117
- renderPreflight(flagged, io.output);
118
- const hasBlock = flagged.some((pkg) => pkg.action === "block");
210
+ renderPreflight(entries, io.output);
211
+ const hasBlock = entries.some((entry) => entry.action === "block");
119
212
  const accepted = await promptPreflightYesNo(hasBlock ? " Override and install anyway?" : " Proceed?", io, false);
120
213
  if (!accepted) {
121
214
  return { proceed: false };
122
215
  }
123
- recordPreflightApprovals(flagged.map((pkg) => ({ name: pkg.name, version: pkg.version, action: pkg.action ?? "warn" })));
216
+ recordPreflightApprovals(entries.map((entry) => ({ name: entry.pkg.name, version: entry.pkg.version, action: entry.action })));
217
+ if (!hasBlock && decisions) {
218
+ await offerRememberOnIo({
219
+ io,
220
+ file: decisions.file,
221
+ packages: entries
222
+ .filter((entry) => entry.action === "warn")
223
+ .map((entry) => ({ ecosystem: decisions.ecosystem, name: entry.pkg.name, version: entry.pkg.version, findings: entry.pkg.findings })),
224
+ acceptedBy: resolveAcceptedBy(decisions.root, decisions.env ?? process.env),
225
+ surface: "install preflight",
226
+ env: decisions.env ?? process.env
227
+ });
228
+ }
124
229
  return hasBlock ? { proceed: true, forceOverride: { force: true } } : PROCEED;
125
230
  }
231
+ export function renderCoveredWarns(covered, decisions, out) {
232
+ for (const pkg of covered) {
233
+ const match = decisions ? matchDecision(pkg, decisions.ecosystem, decisions.file.decisions) : { acknowledged: false };
234
+ const who = match.acknowledged ? match.entry.acceptedBy : "dg.json";
235
+ const when = match.acknowledged && match.entry.acceptedAt ? ` on ${match.entry.acceptedAt.slice(0, 10)}` : "";
236
+ out.write(` ⚠ ${packageKey(pkg.name, pkg.version)} warn previously accepted by ${who}${when} — see 'dg decisions'\n`);
237
+ }
238
+ }
126
239
  export async function promptPreflightYesNo(question, io, defaultYes) {
127
240
  if (!io.isTTY) {
128
241
  return false;
@@ -1,10 +1,13 @@
1
1
  import { analyzePackages } from "../api/analyze.js";
2
2
  import { isCiEnv } from "../presentation/mode.js";
3
+ import { matchDecision } from "../decisions/apply.js";
4
+ import { offerRememberOnIo } from "../decisions/remember-prompt.js";
3
5
  import { renderInstallDecision } from "../install-ui/block-render.js";
4
6
  import { defaultPromptIo } from "../install-ui/prompt.js";
7
+ import { resolveAcceptedBy } from "../project/dgfile.js";
5
8
  import { enforceProtectedInstall } from "../proxy/enforcement.js";
6
9
  import { classifyPackageManagerInvocation, isSupportedPackageManager } from "./classify.js";
7
- import { actionRank, promptPreflightYesNo, recordPreflightApprovals } from "./install-preflight.js";
10
+ import { actionRank, promptPreflightYesNo, recordPreflightApprovals, renderCoveredWarns, renderProvenanceDowngrades, resolvePreflightDecisions } from "./install-preflight.js";
8
11
  import { redactSecrets } from "./output-redaction.js";
9
12
  const ECOSYSTEM_BY_MANAGER = {
10
13
  npm: "npm",
@@ -38,25 +41,37 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
38
41
  if (specs.length === 0) {
39
42
  return FALL_THROUGH;
40
43
  }
44
+ const decisions = options.decisionsCwd ? resolvePreflightDecisions(ecosystem, options.decisionsCwd, env) : null;
41
45
  const flagged = [];
46
+ const covered = [];
42
47
  try {
43
48
  const response = await (options.analyze ?? analyzePackages)(specs.map((spec) => ({ name: spec.name, version: spec.version })), { ecosystem, env });
49
+ renderProvenanceDowngrades(response.packages, io.output);
44
50
  for (const spec of specs) {
45
51
  const pkg = response.packages.find((entry) => entry.name === spec.name && entry.version === spec.version);
46
52
  const action = pkg?.action ?? "pass";
47
53
  if (action === "pass") {
48
54
  continue;
49
55
  }
56
+ if (decisions && pkg && action === "warn" && matchDecision(pkg, decisions.ecosystem, decisions.file.decisions).acknowledged) {
57
+ covered.push(pkg);
58
+ continue;
59
+ }
50
60
  flagged.push({
51
61
  action,
52
62
  spec,
53
- reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner"
63
+ reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner",
64
+ findings: pkg?.findings ?? []
54
65
  });
55
66
  }
56
67
  }
57
68
  catch {
58
69
  return FALL_THROUGH;
59
70
  }
71
+ if (covered.length > 0) {
72
+ renderCoveredWarns(covered, decisions, io.output);
73
+ recordPreflightApprovals(covered.map((pkg) => ({ name: pkg.name, version: pkg.version, action: "warn" })));
74
+ }
60
75
  const worst = flagged.reduce((top, entry) => (!top || actionRank(entry.action) > actionRank(top.action) ? entry : top), undefined);
61
76
  if (!worst) {
62
77
  return FALL_THROUGH;
@@ -97,6 +112,18 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
97
112
  const proceed = await promptPreflightYesNo(`⚠ DG flagged ${label} (warn) — ${worst.reason}. Proceed?`, io, false);
98
113
  if (proceed) {
99
114
  recordPreflightApprovals(flagged.map(asFlaggedPackage));
115
+ if (decisions) {
116
+ await offerRememberOnIo({
117
+ io,
118
+ file: decisions.file,
119
+ packages: flagged
120
+ .filter((entry) => entry.action === "warn")
121
+ .map((entry) => ({ ecosystem, name: entry.spec.name, version: entry.spec.version, findings: entry.findings })),
122
+ acceptedBy: resolveAcceptedBy(decisions.root, env),
123
+ surface: "install preflight",
124
+ env
125
+ });
126
+ }
100
127
  return FALL_THROUGH;
101
128
  }
102
129
  return {
@@ -16,6 +16,7 @@ import { cachedPipResolution } from "./install-preflight.js";
16
16
  import { parsePipReportInstallCount } from "./pip-report.js";
17
17
  import { createStreamRedactor, redactSecrets } from "./output-redaction.js";
18
18
  import { resolveRealBinary } from "./resolve-real-binary.js";
19
+ import { runScriptGateAfterInstall } from "../scripts/gate.js";
19
20
  export const EXIT_INSTALL_BLOCKED = 2;
20
21
  const CMD_SCRIPT_PATTERN = /\.(cmd|bat)$/i;
21
22
  const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
@@ -129,7 +130,7 @@ export async function runPackageManager(manager, args, options = {}) {
129
130
  return {
130
131
  exitCode: child.exitCode,
131
132
  stdout: streamedOut(child.stdout, options),
132
- stderr: `${rendered}${streamedErr(child.stderr, options)}`
133
+ stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}`
133
134
  };
134
135
  }
135
136
  const child = await spawnPackageManager(plan, args, options);
@@ -195,7 +196,7 @@ async function runWithProductionProxy(plan, args, options) {
195
196
  return {
196
197
  exitCode: child.exitCode,
197
198
  stdout: streamedOut(child.stdout, options),
198
- stderr: `${rendered}${streamedErr(child.stderr, options)}`
199
+ stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}`
199
200
  };
200
201
  }
201
202
  finally {
@@ -203,6 +204,16 @@ async function runWithProductionProxy(plan, args, options) {
203
204
  stopProxyWorker(proxy);
204
205
  }
205
206
  }
207
+ function scriptGateLine(plan, exitCode, options) {
208
+ if (exitCode !== 0) {
209
+ return "";
210
+ }
211
+ return runScriptGateAfterInstall({
212
+ classification: plan.classification,
213
+ env: options.env ?? process.env,
214
+ ...(options.projectDir ? { projectDir: options.projectDir } : {})
215
+ });
216
+ }
206
217
  export function deriveLiveView(state, phase, resolvedTotal) {
207
218
  const verified = state.decisions.filter((decision) => decision.action === "pass").length;
208
219
  const warnDecisions = state.decisions.filter((decision) => decision.action === "warn");
@@ -331,7 +342,7 @@ export async function runWithProductionProxyLive(plan, args, options, onView) {
331
342
  const outcome = installOutcome(finished.stdout);
332
343
  return { exitCode: EXIT_INSTALL_BLOCKED, stdout: outcome, stderr: cacheOnlyNotice(outcome.length > 0) };
333
344
  }
334
- return { exitCode: finished.exitCode, stdout: installOutcome(finished.stdout), stderr: "" };
345
+ return { exitCode: finished.exitCode, stdout: installOutcome(finished.stdout), stderr: scriptGateLine(plan, finished.exitCode, options) };
335
346
  }
336
347
  finally {
337
348
  restoreSignalHandlers();