@vola-trebla/croak 0.0.0-stage → 0.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.
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/dist/cli.js +373 -0
- package/dist/crypto/der.js +136 -0
- package/dist/crypto/fields.js +60 -0
- package/dist/crypto/registry-signature.js +88 -0
- package/dist/crypto/rekor.js +70 -0
- package/dist/crypto/sigstore.js +121 -0
- package/dist/detectors/git-config.js +137 -0
- package/dist/detectors/hidden-code.js +58 -0
- package/dist/detectors/ide-autorun.js +136 -0
- package/dist/detectors/index.js +20 -0
- package/dist/detectors/lifecycle-script.js +337 -0
- package/dist/detectors/node-modules-present.js +32 -0
- package/dist/detectors/obfuscation.js +124 -0
- package/dist/detectors/unscanned.js +41 -0
- package/dist/hook/hook.js +391 -0
- package/dist/hook/install-commands.js +927 -0
- package/dist/install/content-cache.js +94 -0
- package/dist/install/content.js +42 -0
- package/dist/install/detectors.js +79 -0
- package/dist/install/installed.js +99 -0
- package/dist/install/lockfile.js +72 -0
- package/dist/install/metadata.js +159 -0
- package/dist/install/plan.js +31 -0
- package/dist/install/pnpm-builds.js +111 -0
- package/dist/install/pnpm-lockfile.js +102 -0
- package/dist/install/popular.js +177 -0
- package/dist/install/project.js +223 -0
- package/dist/install/provenance.js +77 -0
- package/dist/install/registry.js +274 -0
- package/dist/install/report.js +26 -0
- package/dist/install/sanitize.js +88 -0
- package/dist/install/scan.js +302 -0
- package/dist/install/signature.js +62 -0
- package/dist/install/tar.js +178 -0
- package/dist/install/workspace.js +31 -0
- package/dist/install/yaml.js +282 -0
- package/dist/llm/client.js +22 -0
- package/dist/llm/cost.js +26 -0
- package/dist/llm/diff.js +114 -0
- package/dist/llm/escalate.js +14 -0
- package/dist/llm/plan-step.js +91 -0
- package/dist/llm/select.js +21 -0
- package/dist/llm/triage.js +111 -0
- package/dist/repo-report.js +95 -0
- package/dist/repo-snapshot.js +157 -0
- package/dist/scan.js +48 -0
- package/dist/signals/code.js +136 -0
- package/dist/signals/git-config.js +58 -0
- package/dist/signals/hidden.js +77 -0
- package/dist/signals/jsonc.js +100 -0
- package/dist/signals/obfuscation.js +98 -0
- package/dist/signals/shell.js +717 -0
- package/dist/signals/text.js +2 -0
- package/dist/ui.js +12 -0
- package/dist/verdict.js +20 -0
- package/package.json +71 -4
- package/scripts/croak-hook.sh +59 -0
- package/scripts/croak-install.sh +262 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vola-trebla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,284 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 🐸 croak
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Croak before you install.**
|
|
4
|
+
|
|
5
|
+
`croak` checks what a package install — or an unfamiliar repo — is about to do
|
|
6
|
+
**before any of its code runs on your machine**. It's built for AI coding agents
|
|
7
|
+
first: when an agent reaches for `npm install`, croak stops it, explains why,
|
|
8
|
+
and lets the agent fix its own mistake.
|
|
9
|
+
|
|
10
|
+
> 🚧 **Status: early stage (0.x).** `croak repo`, the npm and pnpm install
|
|
11
|
+
> checks, signature and provenance verification, the optional LLM layer and
|
|
12
|
+
> the Claude Code hook work end to end, and each milestone since M1 has had a
|
|
13
|
+
> security review. It is young software: use it, but don't make it your only
|
|
14
|
+
> line of defense, and expect the CLI to change before 1.0. The
|
|
15
|
+
> [roadmap](ROADMAP.md) shows what's done.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 😱 The problem
|
|
20
|
+
|
|
21
|
+
`npm install` doesn't just download code. It **runs** it: `postinstall`
|
|
22
|
+
scripts, native builds, and whatever else a package decides to do — with your
|
|
23
|
+
SSH keys, cloud tokens, `.env` files, and crypto wallets one `fs.readFile` away.
|
|
24
|
+
|
|
25
|
+
And in 2026 this is happening constantly:
|
|
26
|
+
- 📦 **Popular packages get hijacked.** A maintainer account is taken over, a
|
|
27
|
+
new version ships with a hidden dependency, its `postinstall` drops a remote
|
|
28
|
+
access trojan. It's live for hours before anyone notices.
|
|
29
|
+
- 🤖 **AI agents install things without looking.** Models invent package names
|
|
30
|
+
that don't exist; attackers register those names and wait
|
|
31
|
+
(_slopsquatting_). The agent runs the install. Nobody reviews it.
|
|
32
|
+
- 💼 **Fake job interviews.** "Here's our test assignment, just clone and run
|
|
33
|
+
`npm install`." The repo opens a VS Code task on folder open, or hides a
|
|
34
|
+
payload after 300 spaces in `tailwind.config.js`.
|
|
35
|
+
- 🙈 **`--ignore-scripts` isn't enough.** Some attacks run through `binding.gyp`
|
|
36
|
+
and native builds, with no lifecycle scripts at all.
|
|
37
|
+
|
|
38
|
+
## 🛂 What croak does
|
|
39
|
+
|
|
40
|
+
Think of `npm install` as a courier whose parcels **unpack themselves**.
|
|
41
|
+
croak is the customs officer at your door. It x-rays the contents as plain
|
|
42
|
+
data and never lets anything inside run (how: [security model](docs/security-model.md)):
|
|
43
|
+
|
|
44
|
+
| | Check | Catches |
|
|
45
|
+
| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| 🔍 | **Does the sender exist?** | hallucinated and typosquatted packages |
|
|
47
|
+
| ⚖️ | **What changed since the last delivery?** | a new dependency or install script that appeared in a patch release |
|
|
48
|
+
| 🩻 | **Will anything run on unpack?** | `postinstall`, `binding.gyp`, prebuilt binaries, IDE autorun tasks |
|
|
49
|
+
| 🕵️ | **Is something hidden?** | obfuscated code, payloads in build configs |
|
|
50
|
+
| 🔏 | **Is the seal intact?** | a registry signature or [provenance](https://docs.npmjs.com/generating-provenance-statements) that doesn't verify, a build from another repo |
|
|
51
|
+
| 🤖 | **Ask an expert (optional)** | an LLM reviews the _diff_ between versions — and can only raise the alarm, never lower it |
|
|
52
|
+
|
|
53
|
+
Then it gives a verdict — 🟢 **allow** (nothing suspicious found, not a
|
|
54
|
+
guarantee), 🟡 **warn** (needs your attention, never "safe"), or 🔴 **block** — with
|
|
55
|
+
reasons a human _and_ an agent can act on:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
$ croak-install reactt-domm
|
|
59
|
+
croak-install: a requested package does not exist on npm. Check the name:
|
|
60
|
+
AI agents often invent plausible package names, and attackers register them.
|
|
61
|
+
reactt-domm: did you mean `react-dom`?
|
|
62
|
+
|
|
63
|
+
$ croak-install esbuild
|
|
64
|
+
croak plan - WARN
|
|
65
|
+
WARN esbuild@0.28.2/package.json:10 repo/lifecycle-script
|
|
66
|
+
postinstall runs `node install.js` on install: network (install.js:93), process (install.js:95), decode (install.js:166)
|
|
67
|
+
INFO package-lock.json install/other-platform
|
|
68
|
+
25 package(s) built for other platforms not checked: npm will not install them on darwin-arm64
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 📦 Install and use
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install -g @vola-trebla/croak # Node 24.2+, macOS or Linux
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Or once, without installing (fitting for a tool about installs: croak has
|
|
78
|
+
no install scripts and one dependency, Anthropic's SDK):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx @vola-trebla/croak repo ./that-test-assignment
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Two modes
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
croak-install [pkg] # plan an npm install, check it before anything executes
|
|
88
|
+
croak-install --pnpm [pkg] # the same for pnpm
|
|
89
|
+
croak repo . # scan an unfamiliar repo before you install or open it
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Plus a **Claude Code hook** that puts croak in front of every install
|
|
93
|
+
command an agent runs. Add it to `~/.claude/settings.json` (or a project's
|
|
94
|
+
`.claude/settings.json`; croak never edits your config), with the path that
|
|
95
|
+
`command -v croak-hook` prints:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"hooks": {
|
|
100
|
+
"PreToolUse": [
|
|
101
|
+
{
|
|
102
|
+
"matcher": "Bash",
|
|
103
|
+
"hooks": [
|
|
104
|
+
{
|
|
105
|
+
"type": "command",
|
|
106
|
+
"command": "/usr/local/bin/croak-hook",
|
|
107
|
+
"timeout": 300,
|
|
108
|
+
"onFailure": "block"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
🔴, an incomplete check, or a command croak cannot vouch for (yarn or bun, a
|
|
118
|
+
custom registry, `npm i $PKG`, an `.npmrc` that points at a private
|
|
119
|
+
registry) → the command is denied and the agent is told why;
|
|
120
|
+
🟡 → Claude Code asks you; 🟢 → the hook stays silent and your normal
|
|
121
|
+
permissions apply. `onFailure: "block"` matters: without it, Claude Code lets
|
|
122
|
+
a command through when a hook crashes or times out. It needs Claude Code
|
|
123
|
+
2.1.295 or later. The hook runs `node` from Claude Code's own PATH: if that
|
|
124
|
+
finds a Node older than 24.2 (common with nvm when Claude Code starts from
|
|
125
|
+
the desktop), every install is denied with a message saying so, so put
|
|
126
|
+
Node 24 first on that PATH.
|
|
127
|
+
|
|
128
|
+
**The AI layer (optional).** With `CROAK_LLM=1` and `ANTHROPIC_API_KEY` set
|
|
129
|
+
(for the hook: in the environment Claude Code runs it with; for
|
|
130
|
+
`croak plan`: `--llm`), croak asks Claude Haiku 5.5 about each package the
|
|
131
|
+
rules only warned about, showing it what changed since the previous
|
|
132
|
+
version. Its answer can only add a 🟡 or a 🔴, never remove one; without a
|
|
133
|
+
key, or when the model fails, the rules decide alone. Only the diffs of
|
|
134
|
+
public npm packages are sent, never your project's files. On croak's eval
|
|
135
|
+
it turned four red-team variants of one decode-and-run payload from 🟡 into
|
|
136
|
+
🔴 (none of their tricks fooled it) with no false alarms, and on 61 popular
|
|
137
|
+
packages it added no blocks, for about $0.001 a package
|
|
138
|
+
([eval/README.md](eval/README.md#llm-ablation-m5)).
|
|
139
|
+
|
|
140
|
+
**`croak-install`** has npm resolve the install with `--package-lock-only
|
|
141
|
+
--ignore-scripts` in an isolated temp directory (your project's lockfile is
|
|
142
|
+
not touched), then `croak plan` checks every version that is not installed
|
|
143
|
+
yet, transitive ones included (on a fresh clone, the whole lockfile): the lockfile (install scripts, sources, integrity),
|
|
144
|
+
the registry (release age, provenance, publisher, look-alike names, brand-new
|
|
145
|
+
packages and dependencies), the registry's signature and the Sigstore
|
|
146
|
+
provenance, verified by croak itself against keys it pins (did the same
|
|
147
|
+
repository and workflow build this version as the last one?), and the package contents, downloaded and verified
|
|
148
|
+
against the lockfile hash and read in memory, never extracted or run.
|
|
149
|
+
|
|
150
|
+
**pnpm** (`croak-install --pnpm`, and `pnpm add/install/update/dlx`, `pnpx`
|
|
151
|
+
in the hook) gets the same checks, plus what pnpm does differently:
|
|
152
|
+
pnpm-lock.yaml is read by a strict parser of croak's own; an install script
|
|
153
|
+
that pnpm 10+ will not run (the project does not allow its builds) is noted,
|
|
154
|
+
not warned about; the project's `.pnpmfile.cjs` and config dependencies,
|
|
155
|
+
which pnpm runs on every install, are checked; and planning never lets a
|
|
156
|
+
project pick the pnpm binary (`packageManager`) or load a pnpmfile.
|
|
157
|
+
Workspaces, npm and pnpm, are checked from their root, with every member's
|
|
158
|
+
own install scripts; a folder inside the workspace that it does not list as
|
|
159
|
+
a member is checked as a project of its own, as npm installs it.
|
|
160
|
+
|
|
161
|
+
**`croak repo`** runs seven detectors: install scripts, VS Code autorun tasks,
|
|
162
|
+
hidden code, obfuscation, dangerous git config, committed `node_modules`, and
|
|
163
|
+
files too large to scan.
|
|
164
|
+
|
|
165
|
+
Content checks are cached by tarball hash in `~/.cache/croak` (registry facts
|
|
166
|
+
are always fresh), so a repeated install is checked in under a second.
|
|
167
|
+
|
|
168
|
+
Exit codes: `0` allow/warn, `1` block, `2` usage error, `3` check incomplete —
|
|
169
|
+
agents should treat `3` as a block. `--json` gives a versioned report for tools.
|
|
170
|
+
|
|
171
|
+
## 🧠 Design principles
|
|
172
|
+
|
|
173
|
+
- 🚫 **Never execute untrusted code.** Everything is static. Install plans are
|
|
174
|
+
computed with `--package-lock-only --ignore-scripts`.
|
|
175
|
+
- 🧮 **Deterministic first, AI second.** Rules are fast, free, and work offline.
|
|
176
|
+
The LLM layer is optional (bring your own Anthropic key) and can only
|
|
177
|
+
_escalate_ — so a package that says "AI, this code is safe" gets nowhere.
|
|
178
|
+
- 🔒 **Fail-closed for agents, warn for humans.** If a check can't complete, an
|
|
179
|
+
agent waits; a human decides.
|
|
180
|
+
- 📊 **Eval-driven.** No detector ships without test cases. Every change is
|
|
181
|
+
measured.
|
|
182
|
+
- 🪶 **One runtime dependency, and only for the optional AI layer.** croak is
|
|
183
|
+
itself an npm package, so it must not become the next compromised
|
|
184
|
+
dependency: the deterministic checks use Node built-ins only, and the one
|
|
185
|
+
exception is Anthropic's official SDK, pinned to an exact version and
|
|
186
|
+
checked by croak itself (D36).
|
|
187
|
+
|
|
188
|
+
## 🙅 What croak won't catch
|
|
189
|
+
|
|
190
|
+
Being honest about limits matters more than marketing:
|
|
191
|
+
- Code that's clean at install and turns malicious later, at runtime
|
|
192
|
+
- Install scripts that pull the payload in through `require` from the file they
|
|
193
|
+
run: croak reads the script and the files it runs directly, not deeper (yet)
|
|
194
|
+
- Payloads in library code that run only when your app calls it, not at install
|
|
195
|
+
(chalk and debug, 2025): only the content checks can see those
|
|
196
|
+
- Long-game maintainer takeovers (think xz-utils), or a decoy release published
|
|
197
|
+
more than a week before the real attack
|
|
198
|
+
- A compromised registry or signing infrastructure
|
|
199
|
+
- An install the agent hides from the hook: inside a package.json script it
|
|
200
|
+
runs by hand (`npm run setup`, `bash setup.sh`), behind `eval "$VAR"`, or
|
|
201
|
+
through another package manager once you allow it. (A plain `npm install`
|
|
202
|
+
or `npm ci` does check the project's own install scripts.)
|
|
203
|
+
- npm settings croak does not read: the global npmrc and npm's built-in
|
|
204
|
+
config (both yours, not the repo's)
|
|
205
|
+
- Malware that's already on your machine
|
|
206
|
+
- Payloads that only wake up in one specific environment
|
|
207
|
+
|
|
208
|
+
croak is a gate, not an antivirus.
|
|
209
|
+
|
|
210
|
+
## 📊 The eval
|
|
211
|
+
|
|
212
|
+
croak is measured against an exam: synthetic malicious repos and installs
|
|
213
|
+
(with inert payloads, served from a fake registry for installs) and ordinary
|
|
214
|
+
ones that must _not_ trigger alarms. Each install case maps to a real attack
|
|
215
|
+
(axios 2026, Shai-Hulud 2025, ua-parser-js 2021…), checked against OSV.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pnpm eval
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
🐸 croak eval — 67 cases (35 malicious, 32 benign)
|
|
223
|
+
…
|
|
224
|
+
block recall 26/35 74% ███████████████░░░░░
|
|
225
|
+
flag recall 35/35 100% ████████████████████
|
|
226
|
+
false alarms 0/32 0% ░░░░░░░░░░░░░░░░░░░░
|
|
227
|
+
rule recall 52/52 100% ████████████████████
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Synthetic cases are written by us, so two more runs check the noise on code we
|
|
231
|
+
did not write: 13 pinned real-world repositories (`pnpm corpus`) and 61 pinned
|
|
232
|
+
popular npm packages (`pnpm corpus:packages`: 0 blocks; the warnings are
|
|
233
|
+
fresh releases and real install scripts). A case that ever passed is not
|
|
234
|
+
allowed to fail again. Format, metrics and the real attacks behind each case:
|
|
235
|
+
[eval/README.md](eval/README.md).
|
|
236
|
+
|
|
237
|
+
## 🗺️ Roadmap
|
|
238
|
+
|
|
239
|
+
| | Milestone |
|
|
240
|
+
| --- | --------------------------------------------------------------------------- |
|
|
241
|
+
| ✅ | **M0** — foundation: TypeScript, tests, CI, supply-chain-hardened workflows |
|
|
242
|
+
| ✅ | **M1** — eval first: case format, runner, metrics, regression guard |
|
|
243
|
+
| ✅ | **M2** — `croak repo`: autorun configs, hidden code, obfuscation |
|
|
244
|
+
| ✅ | **M3** — npm install plans, registry and package-content signals |
|
|
245
|
+
| ✅ | **M4** — Claude Code hook, agent-friendly verdicts |
|
|
246
|
+
| ✅ | **M5** — LLM triage of version diffs (Haiku by default) |
|
|
247
|
+
| ✅ | **M6** — pnpm and workspaces |
|
|
248
|
+
| ✅ | **M7** — registry signatures + provenance verification |
|
|
249
|
+
| ⏳ | **M8** — public release |
|
|
250
|
+
| 💭 | Later: Python, shared signed verdicts, tarball vs source (D44) |
|
|
251
|
+
|
|
252
|
+
Details in [ROADMAP.md](ROADMAP.md); the reasoning behind every choice is in
|
|
253
|
+
[docs/decisions.md](docs/decisions.md).
|
|
254
|
+
|
|
255
|
+
## 🛠️ Development
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
nvm use # Node 24.2+
|
|
259
|
+
pnpm install
|
|
260
|
+
git config core.hooksPath .githooks # pre-commit + pre-push checks
|
|
261
|
+
pnpm check # lint + typecheck + invariants + tests
|
|
262
|
+
pnpm eval # run the eval
|
|
263
|
+
pnpm corpus:fetch && pnpm corpus # false-alarm check on real repos (network)
|
|
264
|
+
pnpm corpus:packages # false-alarm check on real npm packages (network)
|
|
265
|
+
pnpm croak repo <path> # run croak from source
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Working with an AI agent on this repo? Start with [CLAUDE.md](CLAUDE.md).
|
|
269
|
+
|
|
270
|
+
## 🎓 Origin story
|
|
271
|
+
|
|
272
|
+
In 2013 I wrote a university thesis on elliptic-curve cryptography: point
|
|
273
|
+
addition, Diffie–Hellman, a little C# client-server app. I didn't really get
|
|
274
|
+
why any of it mattered.
|
|
275
|
+
|
|
276
|
+
In 2026, those same curves sign the provenance of npm packages — and someone
|
|
277
|
+
sent me a "test assignment" that I had an AI agent inspect before installing.
|
|
278
|
+
It told me not to touch it.
|
|
279
|
+
|
|
280
|
+
croak is what that thesis turns into thirteen years later.
|
|
281
|
+
|
|
282
|
+
## 📄 License
|
|
283
|
+
|
|
284
|
+
[MIT](LICENSE). Found a way around croak? See [SECURITY.md](SECURITY.md).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { runHook } from "./hook/hook.js";
|
|
7
|
+
import { codeFingerprint, fileContentCache, } from "./install/content-cache.js";
|
|
8
|
+
import { installedLockfile, installedPnpmLockfile, } from "./install/installed.js";
|
|
9
|
+
import { suggestNames } from "./install/metadata.js";
|
|
10
|
+
import { pnpmBuildPolicy } from "./install/pnpm-builds.js";
|
|
11
|
+
import { isPnpmLockfile, parsePnpmLockfile } from "./install/pnpm-lockfile.js";
|
|
12
|
+
import { pnpmfileFindings, projectScriptFindings, workspaceMembers, } from "./install/project.js";
|
|
13
|
+
import { planToJson, renderPlan } from "./install/report.js";
|
|
14
|
+
import { SanitizeError, sanitizePnpmWorkspace, sanitizeRootManifest, } from "./install/sanitize.js";
|
|
15
|
+
import { planInstall, planInstallOnline, } from "./install/scan.js";
|
|
16
|
+
import { workspaceRoot } from "./install/workspace.js";
|
|
17
|
+
import { anthropicTriage, DEFAULT_MODEL } from "./llm/client.js";
|
|
18
|
+
import { renderScan, scanToJson } from "./repo-report.js";
|
|
19
|
+
import { ScanTargetError, scanRepoAt } from "./scan.js";
|
|
20
|
+
import { aggregate } from "./verdict.js";
|
|
21
|
+
export const VERSION = "0.1.0";
|
|
22
|
+
/**
|
|
23
|
+
* 0 allow or warn (warnings are printed, the process succeeds),
|
|
24
|
+
* 1 block, 2 usage error, 3 the scan could not complete — agents treat 3 as
|
|
25
|
+
* a block (fail-closed).
|
|
26
|
+
*/
|
|
27
|
+
export const EXIT = { ok: 0, block: 1, usage: 2, incomplete: 3 };
|
|
28
|
+
/** Conventional exit code for invalid command-line usage. */
|
|
29
|
+
export const EXIT_USAGE = EXIT.usage;
|
|
30
|
+
const DEFAULT_DEPS = {
|
|
31
|
+
scan: (root) => scanRepoAt(root),
|
|
32
|
+
emoji: false,
|
|
33
|
+
};
|
|
34
|
+
const HELP = `croak 🐸 — croak before you install
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
croak plan --after <lock> [--before <lock|none> | --installed <project>] [--project <dir>] [--member <dir>] [--llm]
|
|
38
|
+
[--pnpm-builds <dir> [--pnpm-version <v>]]
|
|
39
|
+
[--offline | --metadata-only] [--no-cache]
|
|
40
|
+
judge an install from the lockfile npm planned
|
|
41
|
+
croak repo [path] scan a repo before you install or open it (default: .)
|
|
42
|
+
croak hook plan|decide the Claude Code hook's steps (used by croak-hook)
|
|
43
|
+
croak --version
|
|
44
|
+
croak --help
|
|
45
|
+
|
|
46
|
+
Options for repo:
|
|
47
|
+
--json machine-readable report
|
|
48
|
+
--plain no emoji, even in a terminal
|
|
49
|
+
|
|
50
|
+
Exit codes: 0 allow/warn · 1 block · 2 usage error · 3 scan incomplete
|
|
51
|
+
`;
|
|
52
|
+
function isParseArgsError(error) {
|
|
53
|
+
return (error instanceof TypeError &&
|
|
54
|
+
"code" in error &&
|
|
55
|
+
typeof error.code === "string" &&
|
|
56
|
+
error.code.startsWith("ERR_PARSE_ARGS_"));
|
|
57
|
+
}
|
|
58
|
+
function parse(argv) {
|
|
59
|
+
return parseArgs({
|
|
60
|
+
args: [...argv],
|
|
61
|
+
options: {
|
|
62
|
+
version: { type: "boolean", short: "v" },
|
|
63
|
+
help: { type: "boolean", short: "h" },
|
|
64
|
+
json: { type: "boolean" },
|
|
65
|
+
plain: { type: "boolean" },
|
|
66
|
+
before: { type: "string" },
|
|
67
|
+
after: { type: "string" },
|
|
68
|
+
offline: { type: "boolean" },
|
|
69
|
+
"metadata-only": { type: "boolean" },
|
|
70
|
+
"no-cache": { type: "boolean" },
|
|
71
|
+
installed: { type: "string" },
|
|
72
|
+
project: { type: "string" },
|
|
73
|
+
member: { type: "string" },
|
|
74
|
+
llm: { type: "boolean" },
|
|
75
|
+
"pnpm-builds": { type: "string" },
|
|
76
|
+
"pnpm-version": { type: "string" },
|
|
77
|
+
},
|
|
78
|
+
allowPositionals: true,
|
|
79
|
+
strict: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const usageError = (message) => ({
|
|
83
|
+
code: EXIT.usage,
|
|
84
|
+
stdout: "",
|
|
85
|
+
stderr: `croak: ${message}\n\n${HELP}`,
|
|
86
|
+
});
|
|
87
|
+
function exitCode(outcome) {
|
|
88
|
+
if (outcome.status === "incomplete")
|
|
89
|
+
return EXIT.incomplete;
|
|
90
|
+
return outcome.verdict === "block" ? EXIT.block : EXIT.ok;
|
|
91
|
+
}
|
|
92
|
+
async function runPlan(values, rest, deps) {
|
|
93
|
+
if (rest.length > 0)
|
|
94
|
+
return usageError(`plan takes no positional arguments, got ${rest.length}`);
|
|
95
|
+
if (values.after === undefined)
|
|
96
|
+
return usageError("plan needs --after <lockfile>");
|
|
97
|
+
const read = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
98
|
+
const load = (path) => {
|
|
99
|
+
try {
|
|
100
|
+
return read(path);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return usageError(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const after = load(values.after);
|
|
107
|
+
if (typeof after !== "string")
|
|
108
|
+
return after;
|
|
109
|
+
let before;
|
|
110
|
+
if (values.installed !== undefined && values.before !== undefined)
|
|
111
|
+
return usageError("plan takes --before or --installed, not both");
|
|
112
|
+
if (values.installed !== undefined) {
|
|
113
|
+
// What is really in node_modules, verified folder by folder (D34).
|
|
114
|
+
const tryRead = (path) => {
|
|
115
|
+
try {
|
|
116
|
+
return read(path);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
// The installed tree of the manager that wrote the plan (D38).
|
|
123
|
+
before = isPnpmLockfile(after)
|
|
124
|
+
? installedPnpmLockfile(values.installed, tryRead, (dir) => {
|
|
125
|
+
try {
|
|
126
|
+
return readdirSync(dir);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}, parsePnpmLockfile)
|
|
132
|
+
: installedLockfile(values.installed, tryRead);
|
|
133
|
+
}
|
|
134
|
+
else if (values.before !== undefined && values.before !== "none") {
|
|
135
|
+
const text = load(values.before);
|
|
136
|
+
if (typeof text !== "string")
|
|
137
|
+
return text;
|
|
138
|
+
before = text;
|
|
139
|
+
}
|
|
140
|
+
// pnpm's own rule for whose install scripts run, from the project's settings (D38).
|
|
141
|
+
const buildsDir = values["pnpm-builds"];
|
|
142
|
+
const builds = buildsDir === undefined || values["pnpm-version"] === undefined
|
|
143
|
+
? undefined
|
|
144
|
+
: pnpmBuildPolicy({
|
|
145
|
+
packageJson: (() => {
|
|
146
|
+
try {
|
|
147
|
+
return read(`${buildsDir}/package.json`);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
})(),
|
|
153
|
+
workspaceYaml: (() => {
|
|
154
|
+
try {
|
|
155
|
+
return read(`${buildsDir}/pnpm-workspace.yaml`);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
})(),
|
|
161
|
+
}, values["pnpm-version"]);
|
|
162
|
+
// --offline: lockfile signals only, no registry requests (tests, air-gapped CI).
|
|
163
|
+
const outcome = values.offline === true
|
|
164
|
+
? planInstall(before, after)
|
|
165
|
+
: await planInstallOnline(before, after, {
|
|
166
|
+
...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
|
|
167
|
+
...(deps.cache === undefined || values["no-cache"] === true
|
|
168
|
+
? {}
|
|
169
|
+
: { cache: deps.cache }),
|
|
170
|
+
content: values["metadata-only"] !== true,
|
|
171
|
+
...(builds === undefined ? {} : { builds }),
|
|
172
|
+
...(values.llm === true && deps.llm !== undefined
|
|
173
|
+
? { llm: deps.llm }
|
|
174
|
+
: {}),
|
|
175
|
+
});
|
|
176
|
+
// --llm without a key: the rules decide, and the report says the model did not run.
|
|
177
|
+
const llmMissing = values.llm === true && deps.llm === undefined && values.offline !== true
|
|
178
|
+
? [
|
|
179
|
+
{
|
|
180
|
+
ruleId: "llm/triage",
|
|
181
|
+
severity: "info",
|
|
182
|
+
file: "croak",
|
|
183
|
+
message: "--llm was asked for, but ANTHROPIC_API_KEY is not set: rules only",
|
|
184
|
+
},
|
|
185
|
+
]
|
|
186
|
+
: [];
|
|
187
|
+
const tryRead = (path) => {
|
|
188
|
+
try {
|
|
189
|
+
return read(path);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const projectFs = {
|
|
196
|
+
read: tryRead,
|
|
197
|
+
kind: (path) => {
|
|
198
|
+
try {
|
|
199
|
+
const stats = lstatSync(path);
|
|
200
|
+
const kind = stats.isSymbolicLink()
|
|
201
|
+
? "symlink"
|
|
202
|
+
: stats.isFile()
|
|
203
|
+
? "file"
|
|
204
|
+
: "other";
|
|
205
|
+
return { kind, size: stats.size };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
// A plain npm install or npm ci also runs the project's own install scripts
|
|
213
|
+
// (D35); pnpm runs the project's pnpmfile and config dependencies on every
|
|
214
|
+
// install (D39).
|
|
215
|
+
const project = values.project;
|
|
216
|
+
const extra = [
|
|
217
|
+
...llmMissing,
|
|
218
|
+
// The pnpmfile check needs only the project; the version is for the build rule.
|
|
219
|
+
...(buildsDir === undefined ? [] : pnpmfileFindings(buildsDir, projectFs)),
|
|
220
|
+
...(project === undefined || outcome.status !== "complete"
|
|
221
|
+
? []
|
|
222
|
+
: projectScriptFindings(project, projectFs, {
|
|
223
|
+
// The plan's lockfile parsed, so its members can be read (M6 review).
|
|
224
|
+
members: workspaceMembers(after),
|
|
225
|
+
pnpm: buildsDir !== undefined,
|
|
226
|
+
})),
|
|
227
|
+
];
|
|
228
|
+
// The folder the install ran in must be a member of the workspace the plan
|
|
229
|
+
// was made at; otherwise the package manager installs elsewhere (M6 review).
|
|
230
|
+
const member = values.member;
|
|
231
|
+
const notMember = member !== undefined &&
|
|
232
|
+
outcome.status === "complete" &&
|
|
233
|
+
!workspaceMembers(after).includes(posix.normalize(member));
|
|
234
|
+
const judged = notMember
|
|
235
|
+
? {
|
|
236
|
+
status: "incomplete",
|
|
237
|
+
reason: `${member} is inside a workspace that does not list it as a member, so the install would not run where croak planned it`,
|
|
238
|
+
durationMs: outcome.durationMs,
|
|
239
|
+
}
|
|
240
|
+
: outcome.status !== "complete" || extra.length === 0
|
|
241
|
+
? outcome
|
|
242
|
+
: (() => {
|
|
243
|
+
const findings = [...extra, ...outcome.findings];
|
|
244
|
+
return { ...outcome, findings, verdict: aggregate(findings) };
|
|
245
|
+
})();
|
|
246
|
+
const stdout = values.json === true
|
|
247
|
+
? planToJson(judged)
|
|
248
|
+
: renderPlan(judged, { emoji: deps.emoji && values.plain !== true });
|
|
249
|
+
return { code: exitCode(judged), stdout, stderr: "" };
|
|
250
|
+
}
|
|
251
|
+
/** Pure entry point: all I/O goes through deps. */
|
|
252
|
+
export async function run(argv, deps = DEFAULT_DEPS) {
|
|
253
|
+
// What the planning wrapper may hand to pnpm from an untrusted project (D38).
|
|
254
|
+
if (argv[0] === "sanitize") {
|
|
255
|
+
const [, kind, file] = argv;
|
|
256
|
+
if (file === undefined ||
|
|
257
|
+
(kind !== "root-manifest" && kind !== "pnpm-workspace"))
|
|
258
|
+
return usageError("usage: croak sanitize root-manifest|pnpm-workspace <file>");
|
|
259
|
+
const read = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
260
|
+
try {
|
|
261
|
+
const text = read(file);
|
|
262
|
+
return {
|
|
263
|
+
code: EXIT.ok,
|
|
264
|
+
stdout: kind === "root-manifest"
|
|
265
|
+
? sanitizeRootManifest(text)
|
|
266
|
+
: sanitizePnpmWorkspace(text),
|
|
267
|
+
stderr: "",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof SanitizeError)
|
|
272
|
+
return usageError(error.message);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// The workspace root croak-install plans at (D38): the same reading as the hook's.
|
|
277
|
+
if (argv[0] === "workspace-root") {
|
|
278
|
+
const [, manager, dir] = argv;
|
|
279
|
+
if (dir === undefined || (manager !== "npm" && manager !== "pnpm"))
|
|
280
|
+
return usageError("usage: croak workspace-root npm|pnpm <dir>");
|
|
281
|
+
const root = workspaceRoot(dir, manager, {
|
|
282
|
+
exists: existsSync,
|
|
283
|
+
readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")),
|
|
284
|
+
});
|
|
285
|
+
return {
|
|
286
|
+
code: EXIT.ok,
|
|
287
|
+
stdout: root === undefined ? "" : `${root}\n`,
|
|
288
|
+
stderr: "",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Suggestions for package names npm does not have (croak-install on E404).
|
|
292
|
+
if (argv[0] === "suggest") {
|
|
293
|
+
const lines = argv.slice(1).flatMap((spec) => {
|
|
294
|
+
const names = suggestNames(decodeURIComponent(spec));
|
|
295
|
+
return names.length === 0
|
|
296
|
+
? []
|
|
297
|
+
: [
|
|
298
|
+
` ${spec}: did you mean ${names.map((n) => `\`${n}\``).join(", ")}?\n`,
|
|
299
|
+
];
|
|
300
|
+
});
|
|
301
|
+
return { code: EXIT.ok, stdout: lines.join(""), stderr: "" };
|
|
302
|
+
}
|
|
303
|
+
// The agent hook's two steps (scripts/croak-hook.sh); they read files only.
|
|
304
|
+
if (argv[0] === "hook")
|
|
305
|
+
return runHook(argv.slice(1), {
|
|
306
|
+
readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")),
|
|
307
|
+
exists: existsSync,
|
|
308
|
+
home: homedir(),
|
|
309
|
+
env: process.env,
|
|
310
|
+
});
|
|
311
|
+
let parsed;
|
|
312
|
+
try {
|
|
313
|
+
parsed = parse(argv);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
// Usage mistakes are explained, not crashed on; anything else is a bug.
|
|
317
|
+
if (!isParseArgsError(error))
|
|
318
|
+
throw error;
|
|
319
|
+
return usageError(error.message);
|
|
320
|
+
}
|
|
321
|
+
const { values, positionals } = parsed;
|
|
322
|
+
if (values.version === true)
|
|
323
|
+
return { code: EXIT.ok, stdout: `${VERSION}\n`, stderr: "" };
|
|
324
|
+
const [command, ...rest] = positionals;
|
|
325
|
+
if (values.help === true || command === undefined) {
|
|
326
|
+
return { code: EXIT.ok, stdout: HELP, stderr: "" };
|
|
327
|
+
}
|
|
328
|
+
if (command === "plan")
|
|
329
|
+
return await runPlan(values, rest, deps);
|
|
330
|
+
if (command !== "repo")
|
|
331
|
+
return usageError(`unknown command "${command}"`);
|
|
332
|
+
if (rest.length > 1)
|
|
333
|
+
return usageError(`repo takes one path, got ${rest.length}`);
|
|
334
|
+
let outcome;
|
|
335
|
+
try {
|
|
336
|
+
outcome = deps.scan(rest[0] ?? ".");
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (!(error instanceof ScanTargetError))
|
|
340
|
+
throw error;
|
|
341
|
+
return usageError(error.message);
|
|
342
|
+
}
|
|
343
|
+
const stdout = values.json === true
|
|
344
|
+
? scanToJson(outcome)
|
|
345
|
+
: renderScan(outcome, { emoji: deps.emoji && values.plain !== true });
|
|
346
|
+
return { code: exitCode(outcome), stdout, stderr: "" };
|
|
347
|
+
}
|
|
348
|
+
// Only touch the process when executed directly, not when imported by tests.
|
|
349
|
+
if (import.meta.main) {
|
|
350
|
+
// Built on first use: fingerprinting croak's code costs a directory read.
|
|
351
|
+
let cache;
|
|
352
|
+
const contentCache = () => {
|
|
353
|
+
cache ??= fileContentCache(join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "croak", "content"), codeFingerprint(import.meta.dirname));
|
|
354
|
+
return cache;
|
|
355
|
+
};
|
|
356
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
357
|
+
const result = await run(process.argv.slice(2), {
|
|
358
|
+
scan: (root) => scanRepoAt(root),
|
|
359
|
+
emoji: process.stdout.isTTY === true,
|
|
360
|
+
...(key === undefined || key === ""
|
|
361
|
+
? {}
|
|
362
|
+
: {
|
|
363
|
+
llm: { model: DEFAULT_MODEL, triage: anthropicTriage(key) },
|
|
364
|
+
}),
|
|
365
|
+
cache: {
|
|
366
|
+
get: (integrity, label) => contentCache().get(integrity, label),
|
|
367
|
+
set: (integrity, label, findings) => contentCache().set(integrity, label, findings),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
process.stdout.write(result.stdout);
|
|
371
|
+
process.stderr.write(result.stderr);
|
|
372
|
+
process.exitCode = result.code;
|
|
373
|
+
}
|