@westbayberry/dg 2.3.2 → 2.3.4

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 (122) hide show
  1. package/README.md +95 -208
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +4 -2
package/README.md CHANGED
@@ -6,9 +6,16 @@
6
6
 
7
7
  Dependency Guardian is a supply-chain firewall for npm and PyPI. The `dg` CLI
8
8
  checks a package against the scanner before its code can run, so a malicious
9
- install is blocked before its install scripts execute, not flagged afterward.
10
- It scans published package artifacts from the registry; your source code stays
11
- on your machine.
9
+ install is blocked before its install scripts execute, not flagged afterward. It
10
+ scans published package artifacts from the registry; your source code stays on
11
+ your machine.
12
+
13
+ Built by Mckeane McBrearty at WestBayBerry, open source under Apache-2.0.
14
+ Releases publish from GitHub Actions through npm's Trusted Publisher OIDC, never
15
+ from a personal machine. The scanner's findings land in the OpenSSF
16
+ malicious-packages database, including a 37-package React Native campaign from
17
+ the npm account `sproger` that had gone unreported for over a year (OSSF PR
18
+ #1322).
12
19
 
13
20
  ## Install
14
21
 
@@ -16,41 +23,26 @@ on your machine.
16
23
  npm install -g @westbayberry/dg
17
24
  ```
18
25
 
19
- Requires Node.js >= 22.14. Installing the package adds only the `dg` binary and
20
- runs no install lifecycle scripts of its own. The published tarball ships an
26
+ Requires Node.js >= 22.14. The install adds only the `dg` binary and runs no
27
+ lifecycle scripts of its own. The published tarball ships an
21
28
  `npm-shrinkwrap.json`, so every install resolves the exact dependency tree the
22
29
  release was audited with. Nothing touches your shell until you run `dg setup`.
23
30
 
24
31
  ## Quickstart
25
32
 
26
- Audit the project in the current directory:
27
-
28
33
  ```bash
29
- dg scan
34
+ dg login # free account
35
+ dg scan # audit the current project's lockfiles
30
36
  ```
31
37
 
32
- `dg scan` reads your lockfiles, asks the scanner for a verdict on each
33
- dependency, and opens a full-screen results browser in an interactive terminal
34
- (plain text or machine output otherwise). It installs nothing and runs no
35
- package scripts. Exit code 0 means the project is clean, 2 means a block-level
36
- package was found.
37
-
38
- ## Verdicts
39
-
40
- Every result is one of three states the scanner returns: **PASS** (verified),
41
- **WARN** (flagged, review advised), or **BLOCK** (refused). The exit code
42
- follows the verdict.
43
-
44
- **The server is the verdict source.** For registry checks and the install
45
- firewall the scanner returns the verdict and the CLI displays it; the CLI never
46
- derives a verdict from a local score. If the scanner is unreachable, `dg scan`
47
- falls back to local heuristics and marks the report (`scannerUnavailable` in
48
- JSON output) rather than passing silently.
38
+ `dg scan` reads your lockfiles and asks the scanner for a verdict on each
39
+ dependency. It installs nothing and runs no package scripts. Exit `0` is clean,
40
+ `2` means a block-level package was found.
49
41
 
50
42
  ## The install firewall
51
43
 
52
- Prefix any install command with `dg` and the package is checked before its
53
- bytes reach your package manager:
44
+ Prefix any install command with `dg` and the package is checked before its bytes
45
+ reach your package manager:
54
46
 
55
47
  ```bash
56
48
  dg npm install lodash
@@ -59,219 +51,115 @@ dg pip install requests
59
51
  dg cargo add serde
60
52
  ```
61
53
 
62
- The prefix starts a local proxy for just that command, hashes each artifact as
63
- it downloads, asks the scanner for a verdict, and refuses a blocked package
64
- before delivery. Supported prefixes include npm, npx, pnpm, pnpx, Yarn classic,
65
- pip, pipx, uv, uvx, and cargo. A block exits 2 and prints the override command;
66
- a warn prompts `Proceed? [y/N]` (default No) in a terminal.
54
+ It proxies just that command, hashes each artifact as it downloads, and refuses
55
+ a blocked one before delivery. Supported prefixes: npm, npx, pnpm, pnpx, Yarn
56
+ classic, pip, pipx, uv, uvx, and cargo. To protect bare `npm install` /
57
+ `pip install` and your AI coding agents too, run `dg setup` (one consent screen,
58
+ reversible with `dg uninstall`).
67
59
 
68
- To protect bare commands too, run `dg setup`. It shows one consent screen
69
- listing exactly what it writes, then installs reversible, user-local shims so
70
- plain `npm install` and `pip install` are scanned automatically, plus a native
71
- hook in each AI coding agent it detects. Undo all of it with `dg uninstall`.
60
+ ## Common commands
72
61
 
73
- ## Command reference
74
-
75
- Run `dg <command> --help` for full flags and examples, or `dg --help-all` for
76
- the complete list. The exhaustive reference lives at
62
+ | Command | What it does |
63
+ | --- | --- |
64
+ | `dg scan [path]` | Audit a project's lockfiles for malicious dependencies |
65
+ | `dg verify npm:react@18` | Check a published package before you install it |
66
+ | `dg sbom [path]` | CycloneDX 1.5 SBOM for the resolved tree |
67
+ | `dg licenses [path]` | SPDX license report grouped by risk |
68
+ | `dg audit [path]` | Pre-publish check of a package of your own |
69
+ | `dg agents` | Route AI coding agents' installs through the firewall |
70
+ | `dg setup` / `dg uninstall` | Install or remove the shell and agent hooks |
71
+ | `dg doctor` | Diagnose setup, PATH, auth, and service state |
72
+
73
+ Run `dg <command> --help` for flags, or `dg --help-all` for the complete list.
74
+ The full reference is at
77
75
  [westbayberry.com/docs/cli-reference](https://westbayberry.com/docs/cli-reference).
78
76
 
79
- ### dg scan [path]
80
-
81
- Audits the project's lockfiles for the path (current directory by default).
82
- Read-only: it never installs, runs package scripts, or changes setup state.
83
- Reads `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`,
84
- `pnpm-lock.yaml`, `requirements.txt`, `poetry.lock`, `Pipfile.lock`, and
85
- `uv.lock`, including nested projects in a monorepo.
86
-
87
- - Flags: `--staged`, `--json`, `--sarif`, `--output <file>`, `--no-decisions`.
88
- - Exit codes: 0 pass, 1 warn (strict mode upgrades warn to 2), 2 block,
89
- 4 analysis incomplete, 10 nothing to scan. JSON output carries a
90
- `schemaVersion` field and sets `scannerUnavailable` when the scanner could
91
- not be reached.
77
+ ## Verdicts and exit codes
92
78
 
93
- ### dg sbom [path]
79
+ Every result is one of three states the scanner returns: **PASS**, **WARN**, or
80
+ **BLOCK**, and the exit code follows it. The server is the verdict source. The
81
+ CLI never derives one from a local score, and if the scanner is unreachable it
82
+ marks the report rather than passing silently.
94
83
 
95
- Inventories the resolved dependency tree as a CycloneDX 1.5 software bill of
96
- materials, with purl, license, and integrity hash per component. Reads the same
97
- lockfiles as `dg scan` plus `Cargo.lock`. In a terminal it streams
98
- BLOCK/WARN/PASS verdicts for npm and PyPI components when signed in; cargo stays
99
- inventory only. Piped, with `--output` (alias `-o`), or with `--json` it stays
100
- offline and prints the raw CycloneDX document.
101
-
102
- - Flags: `--output <path>` / `-o`, `--json`, `--reproducible` (byte-stable
103
- output; honors `SOURCE_DATE_EPOCH`).
104
-
105
- ### dg verify &lt;registry:package[@version] | path | lockfile&gt;
106
-
107
- Runs a real scanner check on a published package before you install it. Name
108
- the registry, `npm:react` or `pypi:requests`, with an optional `@version`
109
- (latest by default). Works signed out with a free verdict summary (the verdict,
110
- top reasons, provenance, and the package page link); signing in unlocks the
111
- full reason list, license info, `--json`, and `--output`. Verifying a local
112
- path, lockfile, or tarball stays free and offline.
113
-
114
- ### dg licenses [path]
115
-
116
- SPDX license report for the dependency tree, grouped by risk. Use `--csv` or
117
- `--markdown` for CI artifacts and `--fail-on <risk>[,<risk>]` to exit non-zero
118
- when a dependency carries a named license-risk tier.
119
-
120
- ### dg audit [path]
121
-
122
- Pre-publish check of a package of your own.
123
- Inspects exactly the resolved publish set of one package, never the whole repo.
124
- Basic checks run 100% locally and upload nothing: leaked secrets and keys,
125
- credential files, source maps, `.git`, terraform state, risky lifecycle
126
- scripts, and a missing `files` allowlist. On a paid plan, and if your org allows
127
- it, it also runs a deep behavioral scan: a packed copy is uploaded with no
128
- lifecycle scripts run, raw bytes are never retained, and only the verdict and
129
- redacted findings reach your dashboard.
84
+ ## Exit codes
130
85
 
131
- - Flags: `--local` (skip the upload), `--require-deep` (fail when deep can't
132
- run), `--fail-on warn`.
133
- - Exit codes: 0 clean, 1 warn (with `--fail-on warn`), 2 block, 3 deep required
134
- but unavailable, 4 analysis incomplete.
86
+ The stable exit codes are `0` pass, `1` warn, `2` block,
87
+ `4` analysis incomplete, `10` nothing to scan, and `70` unexpected CLI error.
88
+ Some commands return `64` when a command unavailable or gated on this platform
89
+ is requested. Machine-readable output includes a `schemaVersion` field so CI
90
+ parsers can pin a contract.
135
91
 
136
- ### dg setup
92
+ ### dg sbom
137
93
 
138
- Installs the shell shims so bare `npm install` / `pip install` are scanned, and
139
- the agent hooks, behind one consent screen. `--print` previews the exact write
140
- plan and changes nothing; `--yes` applies just the shell shims non-interactively
141
- (add `--agents` or `--guard-commit` for the others). Everything it writes is
142
- dg-owned and reversible with `dg uninstall`.
94
+ `dg sbom [path]` inventories the resolved dependency tree as CycloneDX 1.5.
95
+ Interactive terminals can show scanner verdicts for npm and PyPI components;
96
+ `--json` or `--output` writes the raw SBOM for CI.
143
97
 
144
- ### dg guard-commit
98
+ ### dg audit
145
99
 
146
- Installs a per-repo git pre-commit hook that scans the staged lockfile changes
147
- and blocks a commit that would add a malicious dependency. It chains any
148
- existing pre-commit hook and self-checks that git will fire it. Reverse with
149
- `dg guard-commit off`. `git commit --no-verify` is the override.
100
+ `dg audit [path]` is a pre-publish check for a package of your own. Inspects exactly the resolved publish set of one package, never the whole repo. Basic checks run 100% locally. On paid plans, and only when enabled, deep behavioral
101
+ analysis uploads a packed copy for scanning; raw bytes are not retained, and
102
+ only verdict metadata plus redacted findings reach your dashboard.
150
103
 
151
104
  ### dg agents
152
105
 
153
- Routes AI coding agents' installs through the firewall with a native
154
- pre-command hook in each agent's own config, so a malicious or unresolvable
155
- package is blocked before it is fetched. Supported: Claude Code, Codex CLI,
156
- Cursor, Gemini CLI, GitHub Copilot CLI, and Windsurf. Bare `dg agents` lists
157
- each agent and whether it is protected; `dg agents on` hooks every detected
158
- agent (or one, `dg agents on claude-code`); `dg agents --check` verifies the
159
- wiring; `dg agents off` removes it. `dg hook claude-code` keeps working as an
160
- alias.
106
+ `dg agents` installs or checks native pre-command hooks for supported AI coding
107
+ agents so their package-manager calls route through the same install firewall.
108
+ Run `dg agents --check` in CI or a workstation audit to verify the wiring.
161
109
 
162
- ### dg decisions &lt;list | revoke&gt;
110
+ ### dg decisions
163
111
 
164
- Warn-level findings you accept at an install prompt or commit guard can be
165
- remembered in a committable `dg.json` at the project root, so repeat runs only
166
- surface new problems. A new finding category, a severity escalation, or a block
167
- always resurfaces; blocks can never be acknowledged away. `dg decisions list`
168
- shows what the project accepted; `dg decisions revoke` removes an entry.
112
+ `dg decisions list` shows remembered warn-level decisions for the current
113
+ project. `dg decisions revoke` removes one. Blocks can never be acknowledged
114
+ away.
169
115
 
170
- ### dg cooldown [&lt;age&gt; | off]
116
+ ### dg cooldown
171
117
 
172
- Quarantines new installs of a registry release younger than `cooldown.age`
173
- (default `24h`), since most malicious releases are caught within their first
174
- day. Change it with `dg cooldown 7d` or disable it with `dg cooldown off`.
175
- Lockfile scans are never failed by cooldown; packages you already depend on get
176
- a display annotation only. `dg cooldown exempt <name>` records a shareable
177
- exemption in `dg.json`.
118
+ `dg cooldown` shows the effective new-release quarantine and exemptions.
119
+ `dg cooldown exempt <name>` records a project exemption in `dg.json`; user-level
120
+ `cooldown.exempt` applies everywhere.
178
121
 
179
- ### dg config &lt;get | set | unset | list&gt;
122
+ ## In CI
180
123
 
181
- Views or edits user-global configuration in `~/.dg/config.json`. The everyday
182
- key is `policy.mode` (`off`, `warn`, `block` the default, or `strict`), which
183
- sets how a flagged package is handled. There is no detection-tuning knob;
184
- `policy.mode` changes only how a flagged package is handled, not what the
185
- scanner looks for.
124
+ Set `DG_API_KEY` (a `dg_live_*` key from Settings -> CI keys) and run `dg scan`.
125
+ The CLI auto-detects CI and switches to non-interactive output; add
126
+ `--sarif -o dg.sarif` to write findings for upload. On GitHub, the
127
+ [GitHub App](https://westbayberry.com/docs#github-app) is the zero-config path
128
+ and posts a PR status check.
186
129
 
187
- ### dg login / dg logout --yes
130
+ ## Privacy
188
131
 
189
- `dg login` signs in via your browser and links this machine to your account;
190
- `dg login --token dg_live_...` authenticates CI without the browser flow.
191
- Scanning works without an account. `dg logout --yes` removes the saved token
192
- from this machine (the token stays valid until you revoke it from the
193
- dashboard).
194
-
195
- ### dg doctor
196
-
197
- Checks runtime, auth, API, policy, setup shims, PATH precedence, real-binary
198
- resolution, stale state, and service state. Every non-pass line ends with the
199
- exact fix command. `--json` for machine output.
200
-
201
- ### dg service
202
-
203
- Service mode runs a persistent local proxy plus a managed certificate authority
204
- so installs are scanned continuously, for CI runners and private registries
205
- where a per-command proxy is not enough. A paid feature; run `dg login` first,
206
- then `dg service start`. Fully reversible with `dg uninstall --service`.
207
-
208
- ### dg update
209
-
210
- Checks whether a newer CLI version is published and prints the exact
211
- `npm install -g` command to run. It never installs anything and never mutates
212
- the installed package; you run the printed command yourself. Alias: `dg upgrade`.
213
-
214
- ### dg uninstall
215
-
216
- Removes registered dg-owned setup and service writes, state, cache, sessions,
217
- shims, and shell-rc sentinel blocks while preserving unrelated user content.
218
- Run it before `npm uninstall -g @westbayberry/dg`.
219
-
220
- ## Exit codes
221
-
222
- Stable, so CI and shells can read them directly:
223
-
224
- | Code | Meaning |
225
- | --- | --- |
226
- | `0` | Clean. The requested action is allowed. |
227
- | `1` | Warn-level result (strict policy upgrades this to `2`). |
228
- | `2` | Block: a block-level package, a denied install, or an unknown command. |
229
- | `3` | `dg audit --require-deep` ran but the deep scan was unavailable. |
230
- | `4` | Analysis incomplete; not a clean pass. |
231
- | `10` | `dg scan` found nothing to scan. |
232
- | `64` | A usage error (bad flags or arguments). |
233
- | `69` | A command unavailable or gated on this platform, or a signed-out `dg verify` asking for `--json`/`--output`. |
234
- | `70` | Internal tool error (for example, a report file could not be written). |
235
- | `130` / `143` | Interrupted by SIGINT (Ctrl-C) or SIGTERM. |
132
+ No telemetry, no crash reporter, no analytics. The CLI talks to the network only
133
+ when a command needs it: scanning sends package names and versions from your
134
+ lockfiles, and the scanner pulls the artifacts itself; the firewall sends each
135
+ artifact's name, version, and SHA-256. Your source never leaves your machine.
136
+ Detail: [westbayberry.com/docs/telemetry](https://westbayberry.com/docs/telemetry).
236
137
 
237
138
  ## What dg sends to the API
238
139
 
239
- The CLI sends no telemetry: no crash reporter and no analytics endpoint. It
240
- talks to the network only when a command needs it.
241
-
242
- - **Scanning** sends package names and pinned versions from your lockfiles to
243
- the scan API, which pulls the actual package artifacts from the public
244
- registry on its side. Your source never leaves your machine.
245
- - **The install firewall** asks the server for a verdict on each artifact as it
246
- downloads: name, version, ecosystem, registry host, download URL, and the
247
- artifact's SHA-256.
248
- - **Deep audit** (`dg audit`, opt-in, paid) uploads a packed copy of your own
249
- package to `/v1/scan-tarball`; raw bytes are never retained.
250
- - **Version check** queries the public npm registry for `@westbayberry/dg` at
251
- most once a day in an interactive terminal. Set `CI=1` to skip it.
140
+ Authenticated scans send `DG_API_KEY` with package names, versions, ecosystem,
141
+ registry host, lockfile integrity, and artifact hashes where available. Install
142
+ firewall calls send the public package identity and artifact hash needed for a
143
+ verdict. Source files, local paths, stack traces, and crash reports are not sent.
252
144
 
253
- Authenticate CI with `DG_API_KEY` (a `dg_live_*` or `dg_test_*` key;
254
- `DG_API_TOKEN` is an accepted alias). Full detail:
255
- [westbayberry.com/docs/telemetry](https://westbayberry.com/docs/telemetry).
256
-
257
- ## Use in CI
145
+ ## Development
258
146
 
259
147
  ```bash
260
- npm install -g @westbayberry/dg
261
- export DG_API_KEY="dg_live_..." # from Settings -> CI keys
262
- dg scan
148
+ git clone https://github.com/WestBayBerry/DG_CLI.git
149
+ cd DG_CLI
150
+ npm install
151
+ npm run build
152
+ npm test
263
153
  ```
264
154
 
265
- A non-zero exit means a block-level finding; wire that into your pipeline's
266
- pass/fail. The CLI auto-detects CI and switches to non-interactive output; pass
267
- `--json` or `--sarif` when a parser reads stdout. On GitHub, the
268
- [GitHub App](https://westbayberry.com/docs#github-app) is the simpler path and
269
- posts a required PR status check.
155
+ Requires Node.js >= 22.14. Run the built CLI from source with
156
+ `node dist/bin/dg.js --help`, and `npm run check` for typecheck, lint, and
157
+ format checks. Issues and pull requests are welcome.
270
158
 
271
159
  ## Documentation
272
160
 
273
161
  - [Getting started](https://westbayberry.com/docs)
274
- - [CLI reference](https://westbayberry.com/docs/cli-reference)
162
+ - [CLI reference](https://westbayberry.com/docs/cli-reference): every command, flag, and exit code
275
163
  - [Blocking behavior](https://westbayberry.com/docs/blocking)
276
164
  - [Integrations](https://westbayberry.com/docs/integrations)
277
165
  - [Telemetry](https://westbayberry.com/docs/telemetry)
@@ -284,5 +172,4 @@ rather than a public issue.
284
172
 
285
173
  ## License
286
174
 
287
- Apache-2.0. See [LICENSE](./LICENSE). Issues and pull requests are welcome at
288
- [github.com/WestBayBerry/DG_CLI](https://github.com/WestBayBerry/DG_CLI).
175
+ Apache-2.0. See [LICENSE](./LICENSE).
@@ -81,10 +81,15 @@ function emitDecision(verdict) {
81
81
  if (verdict.decision === "allow") {
82
82
  return { stdout: "{}", exitCode: 0 };
83
83
  }
84
+ // Copilot's permissionDecision has no safe human-review value, so a defer
85
+ // ("ask") hardens to "deny" rather than riding through as an unrecognized
86
+ // decision the host may auto-approve — matches codex/gemini/windsurf.
87
+ const suffix = verdict.decision === "ask" ? " (dg flagged this for human review — not auto-approved)" : "";
88
+ const reason = `${verdict.reason ?? "Dependency Guardian firewall"}${suffix}`;
84
89
  return {
85
90
  stdout: JSON.stringify({
86
- permissionDecision: verdict.decision,
87
- ...(verdict.reason ? { permissionDecisionReason: verdict.reason } : {}),
91
+ permissionDecision: "deny",
92
+ permissionDecisionReason: reason,
88
93
  }),
89
94
  exitCode: 0,
90
95
  };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
- import { dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { dirname, sep } from "node:path";
4
5
  import { randomBytes } from "node:crypto";
5
6
  import { readCleanupRegistry, recordCleanupEntry, removeCleanupEntry, } from "../state/index.js";
6
7
  export const LEGACY_AGENT_HOOK_SENTINEL = "dg-agent-hook-v1";
@@ -35,6 +36,33 @@ function assertSafeNode(path, role) {
35
36
  throw new AgentHookError(`${path} (${role}) is world-writable; refusing to write to it. Tighten its permissions, then retry.`);
36
37
  }
37
38
  }
39
+ // The recursive mkdir below follows a symlinked INTERMEDIATE component (e.g. a
40
+ // pre-planted ~/.codeium for the 2-deep windsurf config) before the leaf+parent
41
+ // safety check runs, redirecting the write outside the user tree. Reject any
42
+ // symlink component strictly BELOW the user's home — anchored at homedir() so a
43
+ // legitimately symlinked / networked home is not flagged.
44
+ function assertNoSymlinkBelowHome(target) {
45
+ const home = homedir();
46
+ if (!home) {
47
+ return;
48
+ }
49
+ let dir = dirname(target);
50
+ let prev = "";
51
+ while (dir !== prev && dir.startsWith(`${home}${sep}`)) {
52
+ try {
53
+ if (lstatSync(dir).isSymbolicLink()) {
54
+ throw new AgentHookError(`${dir} is a symlink; refusing to create or write through it. Replace it with a real directory, then retry.`);
55
+ }
56
+ }
57
+ catch (error) {
58
+ if (error instanceof AgentHookError) {
59
+ throw error;
60
+ }
61
+ }
62
+ prev = dir;
63
+ dir = dirname(dir);
64
+ }
65
+ }
38
66
  export function assertSafeWriteTarget(path) {
39
67
  assertSafeNode(dirname(path), "directory");
40
68
  assertSafeNode(path, "file");
@@ -55,11 +83,14 @@ export function readSettings(path) {
55
83
  }
56
84
  return { settings: parsed, existed: true };
57
85
  }
58
- export function writeSettingsAtomic(path, settings) {
86
+ export function writeFileAtomicSafe(path, content) {
87
+ // BEFORE the recursive mkdir (which would follow a symlinked intermediate),
88
+ // reject any symlink component below home; then validate the leaf + parent.
89
+ assertNoSymlinkBelowHome(path);
59
90
  mkdirSync(dirname(path), { recursive: true });
60
91
  assertSafeWriteTarget(path);
61
92
  const tmp = `${path}.dg-${randomBytes(6).toString("hex")}.tmp`;
62
- writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
93
+ writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
63
94
  try {
64
95
  renameSync(tmp, path);
65
96
  }
@@ -68,6 +99,9 @@ export function writeSettingsAtomic(path, settings) {
68
99
  throw error;
69
100
  }
70
101
  }
102
+ export function writeSettingsAtomic(path, settings) {
103
+ writeFileAtomicSafe(path, `${JSON.stringify(settings, null, 2)}\n`);
104
+ }
71
105
  async function wasCreatedByDg(ctx) {
72
106
  try {
73
107
  const reg = await readCleanupRegistry(ctx.paths);
@@ -99,13 +133,18 @@ async function removeHookEntries(ctx, legacySentinels) {
99
133
  await removeCleanupEntry(ctx.paths, { kind: "agent-hook", path: ctx.settingsPath, sentinel });
100
134
  }
101
135
  }
102
- function findHookCommand(node, signature) {
136
+ function findHookCommand(node, signature, depth = 0) {
137
+ // Bound the recursion so a deeply-nested (hand-crafted or corrupt) settings
138
+ // file can't blow the stack and crash the status/verify command.
139
+ if (depth > 512) {
140
+ return null;
141
+ }
103
142
  if (typeof node === "string") {
104
143
  return node.includes(signature) ? node : null;
105
144
  }
106
145
  if (Array.isArray(node)) {
107
146
  for (const value of node) {
108
- const found = findHookCommand(value, signature);
147
+ const found = findHookCommand(value, signature, depth + 1);
109
148
  if (found) {
110
149
  return found;
111
150
  }
@@ -114,7 +153,7 @@ function findHookCommand(node, signature) {
114
153
  }
115
154
  if (node && typeof node === "object") {
116
155
  for (const value of Object.values(node)) {
117
- const found = findHookCommand(value, signature);
156
+ const found = findHookCommand(value, signature, depth + 1);
118
157
  if (found) {
119
158
  return found;
120
159
  }
@@ -123,14 +162,50 @@ function findHookCommand(node, signature) {
123
162
  }
124
163
  return null;
125
164
  }
165
+ // Tokenize a rendered hook-command head, honoring the surrounding quotes
166
+ // defaultDgCommand adds around the absolute dg path. Without this the leading
167
+ // `"` keeps every token from matching startsWith("/"), so the broken-path check
168
+ // silently always passes.
169
+ function shellHeadTokens(head) {
170
+ const tokens = [];
171
+ const s = head.trim();
172
+ let i = 0;
173
+ while (i < s.length) {
174
+ while (i < s.length && /\s/.test(s[i] ?? "")) {
175
+ i += 1;
176
+ }
177
+ if (i >= s.length) {
178
+ break;
179
+ }
180
+ const quote = s[i];
181
+ if (quote === '"' || quote === "'") {
182
+ const end = s.indexOf(quote, i + 1);
183
+ if (end < 0) {
184
+ tokens.push(s.slice(i + 1));
185
+ break;
186
+ }
187
+ const inner = s.slice(i + 1, end);
188
+ tokens.push(quote === '"' ? inner.replace(/\\(["\\$`])/g, "$1") : inner);
189
+ i = end + 1;
190
+ }
191
+ else {
192
+ let j = i;
193
+ while (j < s.length && !/\s/.test(s[j] ?? "")) {
194
+ j += 1;
195
+ }
196
+ tokens.push(s.slice(i, j));
197
+ i = j;
198
+ }
199
+ }
200
+ return tokens;
201
+ }
126
202
  function hookResolvesCheck(settings, agent) {
127
203
  const command = findHookCommand(settings, `hook-exec ${agent}`);
128
204
  if (!command) {
129
205
  return null;
130
206
  }
131
207
  const head = command.split(/\s+hook-exec\s+/)[0] ?? command;
132
- const broken = head
133
- .split(/\s+/)
208
+ const broken = shellHeadTokens(head)
134
209
  .filter((token) => token.startsWith("/"))
135
210
  .filter((token) => !existsSync(token));
136
211
  return {
@@ -1,5 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
+ import { escapeDoubleQuotedSh } from "../util/sh-escape.js";
3
4
  import { resolveDgPaths } from "../state/index.js";
4
5
  import { claudeCodeIntegration } from "./claude-code.js";
5
6
  import { codexIntegration } from "./codex.js";
@@ -39,8 +40,10 @@ export function defaultDgCommand(agent) {
39
40
  }
40
41
  // Run the dg script via its own shebang rather than baking process.execPath:
41
42
  // a version-pinned node path (e.g. .../Cellar/node/<ver>/bin/node) vanishes on
42
- // a node upgrade and would silently disable the hook.
43
- return `${bin} ${agentHookSignature(agent)}`;
43
+ // a node upgrade and would silently disable the hook. Quote the path — the
44
+ // agent runs this string through a shell, so a space or shell metachar in the
45
+ // install path would otherwise word-split or command-substitute.
46
+ return `"${escapeDoubleQuotedSh(bin)}" ${agentHookSignature(agent)}`;
44
47
  }
45
48
  export function resolveAgentHookContext(agent, options = {}) {
46
49
  const env = options.env ?? process.env;
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { buildAgentRoutingEnv } from "../launcher/env.js";
4
4
  import { readServiceState } from "../service/state.js";
5
5
  import { resolveDgPaths } from "../state/index.js";
6
- import { readSettings, writeSettingsAtomic } from "./persistence.js";
6
+ import { readSettings, writeSettingsAtomic, writeFileAtomicSafe } from "./persistence.js";
7
7
  import { getAgent } from "./registry.js";
8
8
  // The agent's install hook screens statically; this routes the agent's ACTUAL
9
9
  // fetches through dg's proxy so a wrapped/dynamic install (eval, $VAR, python -m
@@ -29,30 +29,47 @@ function stripCodexBlock(content) {
29
29
  const block = new RegExp(`\\n?${escapeRegex(CODEX_BEGIN)}[\\s\\S]*?${escapeRegex(CODEX_END)}\\n?`, "g");
30
30
  return content.replace(block, "\n").replace(/\n{3,}/g, "\n\n");
31
31
  }
32
+ function loadBackupPrior(backup) {
33
+ if (!existsSync(backup)) {
34
+ return {};
35
+ }
36
+ try {
37
+ return JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
32
43
  function applyClaudeRouting(settingsPath, routing, backup) {
33
44
  const { settings } = readSettings(settingsPath);
34
45
  const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
35
- const prior = {};
46
+ // The backup must hold the user's PRE-dg values so removal can restore them. On
47
+ // a re-apply the `k in prior` guard preserves the snapshot captured on first
48
+ // apply, so dg's own values are never recorded as the user's prior. We only
49
+ // collapse a value that exactly equals what dg is writing now — never one that
50
+ // merely *looks* like dg's (a loopback host), which would silently delete the
51
+ // user's own local proxy / CA on `dg agents off`.
52
+ const prior = loadBackupPrior(backup);
36
53
  for (const [k, v] of Object.entries(routing)) {
37
- prior[k] = k in envObj ? String(envObj[k]) : null;
54
+ if (!(k in prior)) {
55
+ const current = k in envObj ? String(envObj[k]) : null;
56
+ prior[k] = current === v ? null : current;
57
+ }
38
58
  envObj[k] = v;
39
59
  }
40
- settings.env = envObj;
41
- writeSettingsAtomic(settingsPath, settings);
60
+ // Write the restore backup BEFORE mutating the agent settings: an IO failure on
61
+ // the settings write then leaves a recoverable state, never settings routed
62
+ // through dg with no backup to undo them.
42
63
  mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
43
64
  writeFileSync(backup, `${JSON.stringify({ kind: "claude-env", path: settingsPath, prior }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
65
+ settings.env = envObj;
66
+ writeSettingsAtomic(settingsPath, settings);
44
67
  }
45
68
  function removeClaudeRouting(settingsPath, backup) {
46
69
  if (!existsSync(backup)) {
47
70
  return;
48
71
  }
49
- let prior = {};
50
- try {
51
- prior = JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
52
- }
53
- catch {
54
- prior = {};
55
- }
72
+ const prior = loadBackupPrior(backup);
56
73
  if (existsSync(settingsPath)) {
57
74
  const { settings } = readSettings(settingsPath);
58
75
  const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
@@ -82,14 +99,14 @@ function applyCodexRouting(configPath, routing) {
82
99
  const set = Object.entries(routing).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ");
83
100
  const block = `${CODEX_BEGIN}\n[shell_environment_policy]\nset = { ${set} }\n${CODEX_END}\n`;
84
101
  const next = content === "" || content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
85
- writeFileSync(configPath, next, { encoding: "utf8", mode: 0o600 });
102
+ writeFileAtomicSafe(configPath, next);
86
103
  return { applied: true, detail: configPath };
87
104
  }
88
105
  function removeCodexRouting(configPath) {
89
106
  if (!existsSync(configPath)) {
90
107
  return;
91
108
  }
92
- writeFileSync(configPath, stripCodexBlock(readFileSync(configPath, "utf8")), { encoding: "utf8" });
109
+ writeFileAtomicSafe(configPath, stripCodexBlock(readFileSync(configPath, "utf8")));
93
110
  }
94
111
  function codexConfigPath(home) {
95
112
  return join(home, ".codex", "config.toml");