@westbayberry/dg 2.0.11 → 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 (40) 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/command.js +35 -8
  26. package/dist/scan/render.js +35 -4
  27. package/dist/scan/scanner-report.js +31 -4
  28. package/dist/scan/staged.js +69 -10
  29. package/dist/scan-ui/LegacyApp.js +4 -4
  30. package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
  31. package/dist/scan-ui/hooks/useScan.js +31 -3
  32. package/dist/scan-ui/shims.js +3 -0
  33. package/dist/scripts/detect.js +153 -0
  34. package/dist/scripts/gate.js +170 -0
  35. package/dist/scripts/rebuild.js +28 -0
  36. package/dist/setup/plan.js +36 -1
  37. package/dist/util/json-file.js +24 -0
  38. package/dist/util/tty-prompt.js +13 -6
  39. package/dist/verify/package-check.js +12 -0
  40. package/package.json +9 -1
@@ -103,7 +103,8 @@ export async function analyzePackages(packages, options) {
103
103
  scanId: options.scanId ?? randomUUID(),
104
104
  fetchImpl: options.fetchImpl ?? fetch,
105
105
  timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
106
- ...(options.signal ? { signal: options.signal } : {})
106
+ ...(options.signal ? { signal: options.signal } : {}),
107
+ ...(options.cooldown ? { cooldown: options.cooldown } : {})
107
108
  };
108
109
  const total = packages.length;
109
110
  const batches = [];
@@ -178,7 +179,7 @@ function delay(ms) {
178
179
  return new Promise((resolve) => setTimeout(resolve, ms));
179
180
  }
180
181
  async function analyzeBatch(context, batch, onBatchProgress) {
181
- const { url, token, deviceId, scanId, fetchImpl, timeoutMs, signal } = context;
182
+ const { url, token, deviceId, scanId, fetchImpl, timeoutMs, signal, cooldown } = context;
182
183
  const controller = new AbortController();
183
184
  let timedOut = false;
184
185
  let silenceTimer;
@@ -207,7 +208,8 @@ async function analyzeBatch(context, batch, onBatchProgress) {
207
208
  ...(token ? { Authorization: `Bearer ${token}` } : {})
208
209
  },
209
210
  body: JSON.stringify({
210
- packages: batch.map((entry) => ({ name: entry.name, version: entry.version }))
211
+ packages: batch.map((entry) => ({ name: entry.name, version: entry.version })),
212
+ ...(cooldown ? { cooldown } : {})
211
213
  }),
212
214
  signal: controller.signal
213
215
  });
package/dist/bin/dg.js CHANGED
@@ -53,7 +53,7 @@ else if (audit.handled) {
53
53
  writeCliResult(audit.result);
54
54
  }
55
55
  else {
56
- const preflight = await maybePreflightInstallPrompt(args);
56
+ const preflight = await maybePreflightInstallPrompt(args, { decisionsCwd: process.cwd() });
57
57
  if (preflight.handled) {
58
58
  writeCliResult(preflight.result);
59
59
  }
@@ -2,7 +2,7 @@ import { commandCatalog } from "./router.js";
2
2
  import { packageManagerCommandNames } from "./wrap.js";
3
3
  import { EXIT_USAGE } from "./types.js";
4
4
  const SHELLS = ["bash", "zsh", "fish"];
5
- const COMMON_FLAGS = ["--help", "--version", "--json", "--sarif", "--output", "--yes", "--check", "--staged", "--dg-force-install"];
5
+ const COMMON_FLAGS = ["--help", "--version", "--json", "--sarif", "--output", "--yes", "--check", "--staged", "--no-decisions", "--dg-force-install"];
6
6
  const FISH_FLAG_DESCRIPTIONS = {
7
7
  "--help": "Show help",
8
8
  "--version": "Show version",
@@ -12,6 +12,7 @@ const FISH_FLAG_DESCRIPTIONS = {
12
12
  "--yes": "Apply a confirmed mutation",
13
13
  "--check": "Report without mutating",
14
14
  "--staged": "Limit to staged files",
15
+ "--no-decisions": "Ignore dg.json decision memory",
15
16
  "--dg-force-install": "Install despite a block, where policy allows"
16
17
  };
17
18
  function completionCommands() {
@@ -6,13 +6,21 @@ export const configCommand = {
6
6
  usage: "dg config <get|set|unset|list> [key] [value] [--json]",
7
7
  args: [
8
8
  { name: "<action>", summary: "list | get <key> | set <key> <value> | unset <key>." },
9
- { name: "[key]", summary: "e.g. policy.mode, gitHook.onWarn, gitHook.onIncomplete, api.baseUrl." }
9
+ { name: "[key]", summary: "e.g. policy.mode, gitHook.onWarn, cooldown.age, cooldown.exempt, api.baseUrl." }
10
10
  ],
11
11
  flags: [{ flag: "--json", summary: "Machine-readable output for list/get." }],
12
- examples: ["dg config list", "dg config get policy.mode", "dg config set gitHook.onWarn allow", "dg config unset api.baseUrl"],
12
+ examples: [
13
+ "dg config list",
14
+ "dg config get policy.mode",
15
+ "dg config set gitHook.onWarn allow",
16
+ "dg config set cooldown.age 7d",
17
+ "dg config set cooldown.exempt '@myorg/*,typescript'",
18
+ "dg config unset api.baseUrl"
19
+ ],
13
20
  details: [
14
21
  "Reads and writes user-global dg configuration only.",
15
- "Project-local config and allowlists remain untrusted for install-time firewall enforcement by default."
22
+ "Project-local config and allowlists remain untrusted for install-time firewall enforcement by default.",
23
+ "cooldown.age quarantines registry releases younger than the window on new installs (default 24h; 0 disables; per-ecosystem overrides via cooldown.npm.age / cooldown.pypi.age / cooldown.cargo.age; DG_COOLDOWN_AGE overrides for CI)."
16
24
  ],
17
25
  handler: (context) => configHandler(context.args)
18
26
  };
@@ -0,0 +1,155 @@
1
+ import { recordDecisionEvents } from "../decisions/remember-prompt.js";
2
+ import { packageKey } from "../decisions/apply.js";
3
+ import { findProjectRoot, loadDgFile, removeDecisions, saveDgFile } from "../project/dgfile.js";
4
+ import { EXIT_USAGE } from "./types.js";
5
+ export const decisionsCommand = {
6
+ name: "decisions",
7
+ summary: "List or revoke remembered warn acceptances stored in dg.json.",
8
+ usage: "dg decisions [list] [--json] | dg decisions revoke <id|name[@version]>",
9
+ subcommands: [
10
+ { name: "list", summary: "Show every remembered acceptance (default).", usage: "dg decisions list [--json]", details: [], handler: () => unreachable() },
11
+ { name: "revoke", summary: "Remove an acceptance by id prefix, name, or name@version.", usage: "dg decisions revoke <id|name[@version]>", details: [], handler: () => unreachable() }
12
+ ],
13
+ flags: [{ flag: "--json", summary: "Machine-readable listing." }],
14
+ examples: ["dg decisions", "dg decisions list --json", "dg decisions revoke left-pad@1.3.0"],
15
+ details: [
16
+ "Acceptances live in dg.json at the git root and only ever soften how an acknowledged warn is presented — a block verdict is never suppressible. Revoking makes the warn surface again on the next scan."
17
+ ],
18
+ handler: (context) => runDecisionsCommand(context)
19
+ };
20
+ function unreachable() {
21
+ throw new Error("subcommand handled by the decisions router");
22
+ }
23
+ export function runDecisionsCommand(context, cwd = process.cwd(), env = process.env) {
24
+ const [first, ...rest] = context.args;
25
+ if (first === undefined || first === "list" || first === "--json") {
26
+ const json = first === "--json" || rest.includes("--json");
27
+ const extras = (first === undefined || first === "--json" ? [] : rest).filter((arg) => arg !== "--json");
28
+ if (extras.length > 0) {
29
+ return usage(`unexpected argument '${extras[0]}'`);
30
+ }
31
+ return listDecisions(cwd, env, json);
32
+ }
33
+ if (first === "revoke") {
34
+ const [selector, ...extra] = rest;
35
+ if (!selector || extra.length > 0) {
36
+ return usage("revoke takes exactly one <id|name[@version]>");
37
+ }
38
+ return revokeDecisions(selector, cwd, env);
39
+ }
40
+ return usage(`unknown subcommand '${first}'`);
41
+ }
42
+ function listDecisions(cwd, env, json) {
43
+ const located = locateDgFile(cwd, env);
44
+ if ("error" in located) {
45
+ return located.error;
46
+ }
47
+ const file = located.file;
48
+ if (json) {
49
+ return {
50
+ exitCode: 0,
51
+ stdout: `${JSON.stringify({ path: file.path, decisions: file.decisions.map((entry) => entryJson(entry)) }, null, 2)}\n`,
52
+ stderr: ""
53
+ };
54
+ }
55
+ if (file.decisions.length === 0) {
56
+ return { exitCode: 0, stdout: `No remembered acceptances in ${file.path}.\n`, stderr: "" };
57
+ }
58
+ const rows = file.decisions.map((entry) => [
59
+ entry.id.slice(0, 8),
60
+ `${entry.ecosystem}:${entry.name}@${scopeLabel(entry)}`,
61
+ findingsLabel(entry),
62
+ entry.acceptedBy,
63
+ entry.acceptedAt.slice(0, 10) || "-",
64
+ entry.expiresAt ? entry.expiresAt.slice(0, 10) : "-",
65
+ isExpired(entry) ? "expired" : "active"
66
+ ]);
67
+ const header = ["ID", "PACKAGE", "ACCEPTED FINDINGS", "BY", "WHEN", "EXPIRES", "STATUS"];
68
+ const widths = header.map((label, column) => Math.max(label.length, ...rows.map((row) => row[column]?.length ?? 0)));
69
+ const renderRow = (row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
70
+ const lines = [
71
+ `Remembered acceptances in ${file.path} (warns only — blocks are never suppressible):`,
72
+ "",
73
+ renderRow(header),
74
+ ...rows.map(renderRow),
75
+ "",
76
+ `Revoke with: dg decisions revoke <id|name[@version]>`
77
+ ];
78
+ return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
79
+ }
80
+ function revokeDecisions(selector, cwd, env) {
81
+ const located = locateDgFile(cwd, env);
82
+ if ("error" in located) {
83
+ return located.error;
84
+ }
85
+ const file = located.file;
86
+ const matched = file.decisions.filter((entry) => matchesSelector(entry, selector));
87
+ if (matched.length === 0) {
88
+ return { exitCode: 1, stdout: "", stderr: `dg decisions: nothing matches '${selector}' in ${file.path}.\n` };
89
+ }
90
+ saveDgFile(removeDecisions(file, new Set(matched.map((entry) => entry.id))));
91
+ recordDecisionEvents("decision.revoked", matched.map((entry) => `${entry.ecosystem}:${packageKey(entry.name, scopeLabel(entry))}`), `revoked via dg decisions (${selector})`, env);
92
+ const lines = matched.map((entry) => `Revoked ${entry.ecosystem}:${entry.name}@${scopeLabel(entry)} (${entry.id.slice(0, 8)}) — the warn will surface again.`);
93
+ return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
94
+ }
95
+ function locateDgFile(cwd, env) {
96
+ const root = findProjectRoot(cwd, env);
97
+ if (!root) {
98
+ return { error: { exitCode: EXIT_USAGE, stdout: "", stderr: "dg decisions: not inside a git repository.\n" } };
99
+ }
100
+ const file = loadDgFile(root);
101
+ if (!file.exists) {
102
+ return { error: { exitCode: 0, stdout: `No dg.json at ${root} — nothing remembered yet.\n`, stderr: "" } };
103
+ }
104
+ if (!file.readable) {
105
+ return { error: { exitCode: 1, stdout: "", stderr: `dg decisions: cannot use ${file.path} — ${file.failure ?? "unreadable"}.\n` } };
106
+ }
107
+ return { file };
108
+ }
109
+ function matchesSelector(entry, selector) {
110
+ if (selector.length >= 4 && entry.id.startsWith(selector)) {
111
+ return true;
112
+ }
113
+ const at = selector.lastIndexOf("@");
114
+ if (at > 0) {
115
+ const name = selector.slice(0, at);
116
+ const version = selector.slice(at + 1);
117
+ return entry.name === name && entry.scope.kind === "exact" && entry.scope.version === version;
118
+ }
119
+ return entry.name === selector;
120
+ }
121
+ function scopeLabel(entry) {
122
+ return entry.scope.kind === "exact" ? entry.scope.version : "*";
123
+ }
124
+ function findingsLabel(entry) {
125
+ const pairs = Object.entries(entry.findings).map(([category, severity]) => `${category}:${severity}`);
126
+ return pairs.length > 0 ? pairs.sort().join(",") : "(action-only warn)";
127
+ }
128
+ function isExpired(entry) {
129
+ if (!entry.expiresAt) {
130
+ return false;
131
+ }
132
+ const expiry = Date.parse(entry.expiresAt);
133
+ return !Number.isFinite(expiry) || expiry <= Date.now();
134
+ }
135
+ function entryJson(entry) {
136
+ return {
137
+ id: entry.id,
138
+ ecosystem: entry.ecosystem,
139
+ name: entry.name,
140
+ scope: entry.scope,
141
+ findings: entry.findings,
142
+ reason: entry.reason,
143
+ acceptedBy: entry.acceptedBy,
144
+ acceptedAt: entry.acceptedAt,
145
+ ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
146
+ status: isExpired(entry) ? "expired" : "active"
147
+ };
148
+ }
149
+ function usage(message) {
150
+ return {
151
+ exitCode: EXIT_USAGE,
152
+ stdout: "",
153
+ stderr: `dg decisions: ${message}. Usage: ${decisionsCommand.usage}\n`
154
+ };
155
+ }
@@ -3,7 +3,11 @@ import { closestCommand } from "./suggest.js";
3
3
  export const EXPLANATIONS = {
4
4
  "npm-lifecycle-script": {
5
5
  what: "The package declares an install lifecycle script (preinstall/install/postinstall/prepare) that runs arbitrary code on your machine during 'npm install'.",
6
- next: "Review the script in the package source. Prefer a version without install scripts, or install with --ignore-scripts."
6
+ next: "Review the script in the package source and prefer a version without install scripts. Raw --ignore-scripts also skips your own project's lifecycle and leaves native modules unbuilt (recover with 'npm rebuild <pkg>'); see 'dg explain script-gate' for how dg tracks this layer."
7
+ },
8
+ "script-gate": {
9
+ what: "dg watches protected npm and yarn-classic installs and reports which installed packages ran install lifecycle scripts (preinstall/install/postinstall or an implicit node-gyp build) — the layer most npm supply-chain attacks execute through. Observe mode reports without blocking; when a dg.json exists at the project root the observations are recorded under scriptApprovals.observed. pnpm already blocks dependency build scripts natively ('pnpm approve-builds'); pip has no install-script analog, so there is nothing to gate there.",
10
+ next: "Review the reported packages. Per-package script approvals with dg.json persistence and 'npm rebuild' recovery ship in an upcoming release; 'dg config set scriptGate.mode off' silences the report."
7
11
  },
8
12
  "unverified-network-dependency": {
9
13
  what: "A dependency is fetched from a raw URL (http/git) instead of the registry, so it has no registry identity or integrity to verify.",
@@ -67,7 +71,7 @@ export const EXPLANATIONS = {
67
71
  },
68
72
  lifecycle: {
69
73
  what: "The scanner flagged an install lifecycle script (preinstall/install/postinstall) that runs arbitrary code during install.",
70
- next: "Review the script in the package source, or install with --ignore-scripts."
74
+ next: "Review the script in the package source before installing; see 'dg explain script-gate' for how dg tracks install scripts and why raw --ignore-scripts has no recovery story."
71
75
  },
72
76
  "install-script": {
73
77
  what: "The scanner found an install-time script in this package that executes code on your machine during install.",
@@ -1,6 +1,7 @@
1
1
  import { configCommand } from "./config.js";
2
2
  import { completionCommand } from "./completion.js";
3
3
  import { auditCommand } from "./audit.js";
4
+ import { decisionsCommand } from "./decisions.js";
4
5
  import { doctorCommand } from "./doctor.js";
5
6
  import { explainCommand } from "./explain.js";
6
7
  import { guardCommitCommand } from "./guard-commit.js";
@@ -24,6 +25,7 @@ export const commandCatalog = [
24
25
  verifyCommand,
25
26
  setupCommand,
26
27
  guardCommitCommand,
28
+ decisionsCommand,
27
29
  uninstallCommand,
28
30
  doctorCommand,
29
31
  statusCommand,
@@ -8,7 +8,8 @@ export const scanCommand = {
8
8
  { flag: "--json", summary: "Machine-readable JSON report." },
9
9
  { flag: "--sarif", summary: "SARIF report for code-scanning tools." },
10
10
  { flag: "--output", value: "<path>", summary: "Write the report to a file instead of stdout (alias -o)." },
11
- { flag: "--staged", summary: "Scan only the git-staged lockfile changes (what dg guard-commit runs)." }
11
+ { flag: "--staged", summary: "Scan only the git-staged lockfile changes (what dg guard-commit runs)." },
12
+ { flag: "--no-decisions", summary: "Ignore acceptances remembered in dg.json (see dg decisions)." }
12
13
  ],
13
14
  examples: ["dg scan", "dg scan ./packages/api", "dg scan --json -o scan.json", "dg scan --staged"],
14
15
  details: [
@@ -3,6 +3,7 @@ import { envAuthToken } from "../auth/env-token.js";
3
3
  import { fetchAccountStatus } from "../auth/device-login.js";
4
4
  import { formatUsage } from "../scan-ui/format-helpers.js";
5
5
  import { loadUserConfig } from "../config/settings.js";
6
+ import { describeCooldownSettings } from "../policy/cooldown.js";
6
7
  import { resolvePresentation } from "../presentation/mode.js";
7
8
  import { createTheme } from "../presentation/theme.js";
8
9
  import { currentShellActivation, doctorReport } from "../setup/plan.js";
@@ -49,14 +50,15 @@ async function buildStatusReport(io) {
49
50
  const checks = doctorReport().checks;
50
51
  const checkPassed = (name) => checks.find((check) => check.name === name)?.status === "pass";
51
52
  const auth = safeAuthStatus();
52
- const policy = loadUserConfig().policy;
53
+ const config = loadUserConfig();
53
54
  return {
54
55
  account: auth,
55
56
  usage: await fetchStatusUsage(auth.connected, io),
56
57
  protection: { shims: checkPassed("shims"), path: checkPassed("path"), configured: checkPassed("shell-rc") },
57
58
  reloadHint: currentShellActivation(),
58
59
  commitGuard: safeCommitGuard(),
59
- policy: { mode: policy.mode, trustProjectAllowlists: policy.trustProjectAllowlists }
60
+ policy: { mode: config.policy.mode, trustProjectAllowlists: config.policy.trustProjectAllowlists },
61
+ cooldown: describeCooldownSettings(config, process.env)
60
62
  };
61
63
  }
62
64
  async function fetchStatusUsage(connected, io) {
@@ -123,6 +125,7 @@ function renderStatus(report, theme) {
123
125
  lines.push(` Commit guard ${guard}`);
124
126
  }
125
127
  lines.push(` Policy ${report.policy.mode} mode; project allowlists ${report.policy.trustProjectAllowlists ? "trusted" : "untrusted"}`);
128
+ lines.push(` Cooldown ${report.cooldown === "off" ? "off — new releases install immediately" : `${report.cooldown} release-age gate on new installs`}`);
126
129
  lines.push("");
127
130
  lines.push(`Full diagnostics: ${theme.paint("accent", "dg doctor")}`);
128
131
  return `${lines.join("\n")}\n`;
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { randomUUID } from "node:crypto";
3
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeJsonAtomic } from "../util/json-file.js";
4
4
  import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
5
5
  export const CONFIG_KEYS = Object.freeze([
6
6
  "api.baseUrl",
@@ -9,8 +9,16 @@ export const CONFIG_KEYS = Object.freeze([
9
9
  "policy.trustProjectAllowlists",
10
10
  "policy.allowForceOverride",
11
11
  "policy.scriptHardening",
12
+ "scriptGate.mode",
13
+ "scriptGate.observe",
12
14
  "gitHook.onWarn",
13
15
  "gitHook.onIncomplete",
16
+ "cooldown.age",
17
+ "cooldown.npm.age",
18
+ "cooldown.pypi.age",
19
+ "cooldown.cargo.age",
20
+ "cooldown.onUnknown",
21
+ "cooldown.exempt",
14
22
  "audit.upload",
15
23
  "telemetry.enabled",
16
24
  "webhooks.enabled"
@@ -29,10 +37,22 @@ export const DEFAULT_CONFIG = Object.freeze({
29
37
  allowForceOverride: true,
30
38
  scriptHardening: false
31
39
  },
40
+ scriptGate: {
41
+ mode: "observe",
42
+ observe: false
43
+ },
32
44
  gitHook: {
33
45
  onWarn: "prompt",
34
46
  onIncomplete: "allow"
35
47
  },
48
+ cooldown: {
49
+ age: "24h",
50
+ npmAge: "",
51
+ pypiAge: "",
52
+ cargoAge: "",
53
+ onUnknown: "allow",
54
+ exempt: ""
55
+ },
36
56
  audit: {
37
57
  upload: false
38
58
  },
@@ -113,12 +133,36 @@ export function getConfigValue(config, key) {
113
133
  if (key === "policy.scriptHardening") {
114
134
  return String(config.policy.scriptHardening);
115
135
  }
136
+ if (key === "scriptGate.mode") {
137
+ return config.scriptGate.mode;
138
+ }
139
+ if (key === "scriptGate.observe") {
140
+ return String(config.scriptGate.observe);
141
+ }
116
142
  if (key === "gitHook.onWarn") {
117
143
  return config.gitHook.onWarn;
118
144
  }
119
145
  if (key === "gitHook.onIncomplete") {
120
146
  return config.gitHook.onIncomplete;
121
147
  }
148
+ if (key === "cooldown.age") {
149
+ return config.cooldown.age;
150
+ }
151
+ if (key === "cooldown.npm.age") {
152
+ return config.cooldown.npmAge;
153
+ }
154
+ if (key === "cooldown.pypi.age") {
155
+ return config.cooldown.pypiAge;
156
+ }
157
+ if (key === "cooldown.cargo.age") {
158
+ return config.cooldown.cargoAge;
159
+ }
160
+ if (key === "cooldown.onUnknown") {
161
+ return config.cooldown.onUnknown;
162
+ }
163
+ if (key === "cooldown.exempt") {
164
+ return config.cooldown.exempt;
165
+ }
122
166
  if (key === "audit.upload") {
123
167
  return String(config.audit.upload);
124
168
  }
@@ -168,6 +212,24 @@ export function setConfigValue(config, key, rawValue) {
168
212
  if (key === "policy.scriptHardening") {
169
213
  return withPolicyBoolean(config, "scriptHardening", rawValue);
170
214
  }
215
+ if (key === "scriptGate.mode") {
216
+ return {
217
+ ...config,
218
+ scriptGate: {
219
+ ...config.scriptGate,
220
+ mode: parseScriptGateMode(rawValue)
221
+ }
222
+ };
223
+ }
224
+ if (key === "scriptGate.observe") {
225
+ return {
226
+ ...config,
227
+ scriptGate: {
228
+ ...config.scriptGate,
229
+ observe: parseBoolean(rawValue, key)
230
+ }
231
+ };
232
+ }
171
233
  if (key === "gitHook.onWarn") {
172
234
  return {
173
235
  ...config,
@@ -186,6 +248,24 @@ export function setConfigValue(config, key, rawValue) {
186
248
  }
187
249
  };
188
250
  }
251
+ if (key === "cooldown.age") {
252
+ return withCooldown(config, { age: parseCooldownAge(rawValue, key, false) });
253
+ }
254
+ if (key === "cooldown.npm.age") {
255
+ return withCooldown(config, { npmAge: parseCooldownAge(rawValue, key, true) });
256
+ }
257
+ if (key === "cooldown.pypi.age") {
258
+ return withCooldown(config, { pypiAge: parseCooldownAge(rawValue, key, true) });
259
+ }
260
+ if (key === "cooldown.cargo.age") {
261
+ return withCooldown(config, { cargoAge: parseCooldownAge(rawValue, key, true) });
262
+ }
263
+ if (key === "cooldown.onUnknown") {
264
+ return withCooldown(config, { onUnknown: parseCooldownOnUnknown(rawValue) });
265
+ }
266
+ if (key === "cooldown.exempt") {
267
+ return withCooldown(config, { exempt: parseCooldownExempt(rawValue) });
268
+ }
189
269
  if (key === "audit.upload") {
190
270
  return {
191
271
  ...config,
@@ -225,10 +305,13 @@ function normalizeConfig(raw) {
225
305
  const api = fieldObject(raw, "api");
226
306
  const org = fieldObject(raw, "org");
227
307
  const policy = fieldObject(raw, "policy");
308
+ const scriptGate = fieldObject(raw, "scriptGate");
228
309
  const gitHook = fieldObject(raw, "gitHook");
310
+ const cooldown = fieldObject(raw, "cooldown");
229
311
  const audit = fieldObject(raw, "audit");
230
312
  const telemetry = fieldObject(raw, "telemetry");
231
313
  const webhooks = fieldObject(raw, "webhooks");
314
+ const scriptHardening = fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening;
232
315
  return {
233
316
  version: 1,
234
317
  api: {
@@ -241,12 +324,24 @@ function normalizeConfig(raw) {
241
324
  mode: parsePolicyMode(fieldString(policy, "policy.mode", "mode") ?? DEFAULT_CONFIG.policy.mode),
242
325
  trustProjectAllowlists: fieldBoolean(policy, "policy.trustProjectAllowlists", "trustProjectAllowlists") ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
243
326
  allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
244
- scriptHardening: fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening
327
+ scriptHardening
328
+ },
329
+ scriptGate: {
330
+ mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
331
+ observe: fieldBoolean(scriptGate, "scriptGate.observe", "observe") ?? DEFAULT_CONFIG.scriptGate.observe
245
332
  },
246
333
  gitHook: {
247
334
  onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
248
335
  onIncomplete: parseOnIncomplete(fieldString(gitHook, "gitHook.onIncomplete", "onIncomplete") ?? DEFAULT_CONFIG.gitHook.onIncomplete)
249
336
  },
337
+ cooldown: {
338
+ age: parseCooldownAge(fieldString(cooldown, "cooldown.age", "age") ?? DEFAULT_CONFIG.cooldown.age, "cooldown.age", false),
339
+ npmAge: parseCooldownAge(fieldString(cooldown, "cooldown.npm.age", "npmAge") ?? DEFAULT_CONFIG.cooldown.npmAge, "cooldown.npm.age", true),
340
+ pypiAge: parseCooldownAge(fieldString(cooldown, "cooldown.pypi.age", "pypiAge") ?? DEFAULT_CONFIG.cooldown.pypiAge, "cooldown.pypi.age", true),
341
+ cargoAge: parseCooldownAge(fieldString(cooldown, "cooldown.cargo.age", "cargoAge") ?? DEFAULT_CONFIG.cooldown.cargoAge, "cooldown.cargo.age", true),
342
+ onUnknown: parseCooldownOnUnknown(fieldString(cooldown, "cooldown.onUnknown", "onUnknown") ?? DEFAULT_CONFIG.cooldown.onUnknown),
343
+ exempt: parseCooldownExempt(fieldString(cooldown, "cooldown.exempt", "exempt") ?? DEFAULT_CONFIG.cooldown.exempt)
344
+ },
250
345
  audit: {
251
346
  upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
252
347
  },
@@ -321,6 +416,12 @@ function parsePolicyMode(value) {
321
416
  }
322
417
  throw new ConfigError("policy.mode must be one of: off, warn, block, strict");
323
418
  }
419
+ function parseScriptGateMode(value) {
420
+ if (value === "observe" || value === "enforce" || value === "off") {
421
+ return value;
422
+ }
423
+ throw new ConfigError("scriptGate.mode must be one of: observe, enforce, off");
424
+ }
324
425
  function parseOnWarn(value) {
325
426
  if (value === "prompt" || value === "allow" || value === "block") {
326
427
  return value;
@@ -333,6 +434,45 @@ function parseOnIncomplete(value) {
333
434
  }
334
435
  throw new ConfigError("gitHook.onIncomplete must be one of: allow, block");
335
436
  }
437
+ const COOLDOWN_AGE_RE = /^([1-9]\d{0,3})(h|d)$/;
438
+ const COOLDOWN_EXEMPT_ENTRY_RE = /^[@A-Za-z0-9][@A-Za-z0-9._/-]*\*?$/;
439
+ export function parseCooldownAge(value, field, allowEmpty) {
440
+ const trimmed = value.trim();
441
+ if (trimmed === "" && allowEmpty) {
442
+ return "";
443
+ }
444
+ if (trimmed === "0" || trimmed === "off") {
445
+ return "0";
446
+ }
447
+ if (COOLDOWN_AGE_RE.test(trimmed)) {
448
+ return trimmed;
449
+ }
450
+ throw new ConfigError(`${field} must be a duration like 24h or 7d, or 0 to disable${allowEmpty ? ", or empty to inherit cooldown.age" : ""}`);
451
+ }
452
+ function parseCooldownOnUnknown(value) {
453
+ if (value === "allow" || value === "block") {
454
+ return value;
455
+ }
456
+ throw new ConfigError("cooldown.onUnknown must be one of: allow, block");
457
+ }
458
+ function parseCooldownExempt(value) {
459
+ const entries = value.split(",").map((entry) => entry.trim()).filter(Boolean);
460
+ for (const entry of entries) {
461
+ if (!COOLDOWN_EXEMPT_ENTRY_RE.test(entry)) {
462
+ throw new ConfigError(`cooldown.exempt entry '${entry}' is not a valid package name or pattern (use names or globs like @org/*)`);
463
+ }
464
+ }
465
+ return entries.join(",");
466
+ }
467
+ function withCooldown(config, patch) {
468
+ return {
469
+ ...config,
470
+ cooldown: {
471
+ ...config.cooldown,
472
+ ...patch
473
+ }
474
+ };
475
+ }
336
476
  function parseBoolean(value, field) {
337
477
  if (value === "true") {
338
478
  return true;
@@ -359,24 +499,3 @@ function parseUrl(value) {
359
499
  throw new ConfigError("api.baseUrl must be an absolute URL");
360
500
  }
361
501
  }
362
- function writeJsonAtomic(path, value) {
363
- mkdirSync(dirname(path), {
364
- recursive: true,
365
- mode: 0o700
366
- });
367
- const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
368
- try {
369
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
370
- encoding: "utf8",
371
- flag: "wx",
372
- mode: 0o600
373
- });
374
- renameSync(tempPath, path);
375
- }
376
- catch (error) {
377
- rmSync(tempPath, {
378
- force: true
379
- });
380
- throw error;
381
- }
382
- }