Repository navigation
feat: add a permission audit script for generating LavaMoat configs - #344
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
Writing `lavamoat/scripts.*.json` by hand means guessing which permissions a script needs, then loosening the config whenever the guess turns out to be too strict. `yarn audit <script>` instead runs the script under `--permission-audit`, collects every permission it actually exercises, and prints a config granting exactly those. The collector is embedded in the script itself and injected as a data URL through `NODE_OPTIONS`, so it reaches child processes at any depth and reports back over an append-only log that every process shares. Paths are generalised so the result is portable rather than a description of one machine: anything inside the project becomes `./`, known locations become `$TMPDIR`, `$HOME`, or a `$GITHUB_*` variable, and the upward directory walk that module and config resolution performs is recognised as such. Any grant that still reaches outside the project is reported with the path that caused it, so a widened config is never silent about why it was widened. Node.js is pinned to 26.9.0 because audit mode is only usable from that release onwards. Earlier versions still enforce `lstat`, `symlink`, and addon loading despite documenting audit mode as non-blocking, so any script touching those dies part-way through the audit and the resulting config is incomplete (nodejs/node#65419).
5ed2f83 to
7155726
Compare
Module and workspace resolution walks from the project directory up to the file system root, and the paths it probes for describe the machine rather than the script, so they are generalised to `/` rather than named. That classification was applied to writes as well, which it should never be: resolution only ever reads. The effect was worse than an overly broad entry. A write to an ancestor directory, or to a file such as `~/.npmrc`, was read as a probe and granted `/`, and collapsing the grants then short-circuits on `/` and discards everything else. A single misclassified write therefore turned the whole write list into unrestricted file system access, silently. Recursive `mkdir` checks write access on ancestor directories, so this was reachable in ordinary use. `generalise` now takes the action and only consults the probe rules for reads. Reads are unaffected. Also repairs two breakages from the surrounding work: `toSorted` is ES2023 and the target is now ES2022, and Oxlint reports the `any` that `JSON.parse` returns as an unsafe return.
The permissions a script needs on a CI runner are not the ones it needs on a developer's machine: the paths, the environment variables, and the tool cache all differ. Generating a config locally and hoping it holds in CI is guesswork. Re-running a workflow with debug logging enabled now runs each command through `yarn audit` instead of directly, so the job reports what it actually needed where it actually runs. `RUN` defaults to `yarn` and is overridden to `yarn audit` only for those runs, which keeps one copy of each command rather than a pair behind opposing conditions. `runner.debug` is not available to a job-level `if`, so the override is a step. It is also limited to Node 26.x: Node 22 rejects `--permission-audit` outright, and auditing every version in the matrix would produce several configs for no gain. This depends on the audit passing the script's exit code on, which it previously swallowed. Wrapping a command in something that always succeeds would have turned every job green regardless of whether the tests passed. The changelog steps keep calling `yarn` directly, since they pass arguments and the audit does not forward those to the audited script.
These steps had no name, so GitHub labelled them with the command itself. That read fine as `yarn build`, but now that the command is `$RUN build` the label shows the variable rather than what the step does, and it is identical in both of the jobs that run tests.
The audit is silent by default, so routing commands through it meant that turning debug logging on removed the build, lint, and test output a normal run shows. Enabling debug gave strictly less to look at, which is backwards. `--verbose` restores that output and adds the report of which grants reached outside the project and the path that earned each one, which is the part worth reading on a runner.
A CI runner reads standard output and standard error as two separate pipes and merges them into one log by arrival, with no ordering guarantee between them. The report was written to standard error immediately after the config went to standard output, so the runner was free to place it anywhere — in practice, in the middle of the JSON. Flushing cannot fix that: ordering is only guaranteed within a single descriptor. Writing both to standard output is what makes the sequence deterministic, since the kernel serialises writes to one descriptor and no reader can reorder them afterwards. That makes standard output human-facing under `--verbose`, which is what the flag is for; `--out` remains the way to get the config on its own. Simulating the runner — separate pipes merged by arrival — the JSON stayed contiguous across 23 runs of two different scripts.
| - name: Enable permission audit for debug runs | ||
| if: ${{ runner.debug == '1' && matrix.node-version == '26.x' }} | ||
| run: echo 'RUN=yarn audit --verbose' >> "$GITHUB_ENV" | ||
| - name: Test |
Unrecognised arguments are now treated as the audited script's and handed straight to it. With that, the changelog steps run through `RUN` like everything else, and every command in the workflow is audited on a debug run.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e53dec9. Configure here.
mcmire
left a comment
There was a problem hiding this comment.
I'm working through the audit script. I left some comments below. Most are minor naming suggestions. I'll do another pass shortly.
mcmire
left a comment
There was a problem hiding this comment.
I ran yarn audit-script test and yarn audit-script --verbose test. It seems that there is a slight difference between the output that the audit produces vs. what we have in lavamoat/scripts.test.json — namely, the tool produces "--allow-fs-write": ["./"], but we have "--allow-fs-write": ["./", "$GITHUB_STEP_SUMMARY"],. I'm not sure if that intentional or not.
I also noticed some improvements we can make to the output and I've noted them below.
|
@mcmire Yes, that's intentional. When running in CI, Vitest writes a report to the file set by the That's why I added the option to run the audit script in CI as well by running in debug mode. You can see in this run (expand "Test") that it writes to |
|
@Mrtenz Cool, makes sense! |

Now that scripts run under the Node.js permission model, every script needs a
lavamoat/scripts.*.jsongranting it the permissions it actually uses. Writing those by hand means guessing, then loosening the config each time the guess turns out to be too strict — and a guess that is too loose is invisible, because nothing fails.This adds
yarn audit <script>, which runs a script under--permission-audit, collects every permission it really exercises, and prints a config granting exactly those.Pass
--out lavamoat/scripts.test.jsonto write it to a file instead, and--verboseto see the audited script's own output along with an explanation of how each grant was chosen.How it works
A collector subscribes to the
node:permission-model:*diagnostics channels and appends every reported check to a log. SinceNODE_OPTIONSis inherited by every descendant, the collector is injected with--importas a percent-encodeddata:URL, which means it loads in child processes at any depth without needing a file on disk. Each process appends to one shared log; writes are synchronous and belowPIPE_BUF, so they interleave atomically without locking and survive a process that dies before itsexithandlers run.Raw paths are useless in a committed config, so they are generalised: anything inside the project becomes
./, known locations become$TMPDIR,$HOME, or a$GITHUB_*variable, and the upward directory walk that module and config resolution performs (package.json,node_modules,browserslist, and friends, probed all the way to the file system root) is recognised as such rather than baked in as this machine's directory layout. Path comparison is case-folded on macOS and Windows, because tools such as TypeScript probe with inconsistent casing and would otherwise leak absolute paths into the output.Any grant that still reaches outside the project is reported under
--verbose, alongside the path that caused it, so a widened config is never silent about why it was widened:Node.js 26.9.0
.nvmrcis pinned to 26.9.0 because audit mode is only usable from that release onwards. Earlier versions still enforcelstat,symlink, and addon loading despite documenting audit mode as non-blocking, so any script touching those dies part-way through the audit and the resulting config is silently incomplete. That was reported as nodejs/node#65419 and fixed in 26.9.0.Examples
Generated configs match the hand-written ones already in this repository. For
test:vitestthe script independently produces"--allow-fs-read": ["/"],"--allow-fs-write": ["./"],--allow-child-processand--allow-addonsenabled and everything else disabled, which islavamoat/scripts.test.jsonas committed today. Forlint:miscit produces the tighter"--allow-fs-read": ["./"]with no writes at all.The generated config for
test:vitestwas also verified under real enforcement (--permission, not audit mode): the suite passes with exactly those grants.See: nodejs/node#65419
Note
Low Risk
Mostly developer tooling and CI wiring; no runtime product behavior change unless teams adopt the generated LavaMoat configs.
Overview
Adds
yarn audit-script(scripts/audit-script.ts), which runs apackage.jsonscript under Node’s--permission-audit, records checks from the permission-model diagnostics channels, and emits a portablelavamoat/scripts.*.json-style config (optional--out,--verbosefor grant rationale and script output)..nvmrcis pinned to v26.9.0 so audit runs are complete (pre-26.9 audit mode could still block and yield incomplete configs). Dev deps addyargsand bump@types/nodeto match.CI introduces a workflow
RUNdefault ofyarn; when debug logging is on and the matrix is Node 26.x, build/lint/test/compatibility steps route commands throughyarn audit-script --verboseinstead of calling scripts directly.Reviewed by Cursor Bugbot for commit bf6f53f. Bugbot is set up for automated code reviews on this repo. Configure here.