ruvnet-brain 4.7.22 → 4.7.24

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 (39) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +59 -1
  3. package/kb/lifecycle-evidence-retention.mjs +1 -1
  4. package/kb/update-storage-transaction.mjs +161 -1
  5. package/package.json +2 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/brain-footprint.mjs +5 -1
  9. package/plugin/scripts/learn-capture.mjs +26 -3
  10. package/scripts/generation-census.mjs +220 -0
  11. package/scripts/issue-autofix-agents.mjs +172 -0
  12. package/scripts/issue-autofix-comment.mjs +224 -0
  13. package/scripts/issue-autofix-diff.mjs +108 -0
  14. package/scripts/issue-autofix-exec.mjs +128 -0
  15. package/scripts/issue-autofix-fix.mjs +104 -0
  16. package/scripts/issue-autofix-gate.mjs +137 -0
  17. package/scripts/issue-autofix-night.mjs +164 -0
  18. package/scripts/issue-autofix-notes.mjs +346 -0
  19. package/scripts/issue-autofix-policy.mjs +349 -0
  20. package/scripts/issue-autofix-prompts.mjs +105 -0
  21. package/scripts/issue-autofix-release-ship.mjs +226 -0
  22. package/scripts/issue-autofix-release-util.mjs +141 -0
  23. package/scripts/issue-autofix-release.mjs +272 -0
  24. package/scripts/issue-autofix-report.mjs +121 -0
  25. package/scripts/issue-autofix-runner.mjs +75 -0
  26. package/scripts/issue-autofix-sandbox.mjs +108 -0
  27. package/scripts/issue-autofix-security.mjs +346 -0
  28. package/scripts/issue-autofix-selftest.mjs +101 -0
  29. package/scripts/issue-autofix-state.mjs +61 -0
  30. package/scripts/issue-autofix.mjs +382 -0
  31. package/scripts/issue-fix-lock.mjs +40 -0
  32. package/scripts/issue-fix.mjs +33 -20
  33. package/scripts/no-silent-substitution.mjs +1 -1
  34. package/scripts/ntfy-inbox.mjs +15 -0
  35. package/scripts/onboarding-console.mjs +6 -1
  36. package/scripts/release-prepush-checks.mjs +251 -0
  37. package/scripts/release-prepush.mjs +168 -0
  38. package/scripts/release-qualification-contract.mjs +32 -0
  39. package/scripts/wired-check.mjs +11 -5
@@ -0,0 +1,121 @@
1
+ // scripts/issue-autofix-report.mjs — S13 / S13b of the autofix job (ADR-107): tell the people who filed issues.
2
+ // ONE note per person per night, covering all of their issues, written fresh from their own words in the full thread
3
+ // and checked by issue-autofix-notes.mjs (specific, not boilerplate, not like any earlier note to anyone, apologetic
4
+ // after a 7-day wait, truthful about what was and was not verified). A note that never passes is not posted: the
5
+ // factual report goes out without it and the owner is paged to write one. The text rules of every comment live in
6
+ // issue-autofix-comment.mjs; this module decides WHETHER to post, reading the live thread so a wiped state file can
7
+ // never cause a second comment, and closes an issue only after a `fixed` comment naming an install-verified version.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { alreadyPosted, renderComment, postOutcome } from './issue-autofix-comment.mjs';
11
+ import { groupByPerson, reporterWords, waitedDays, generatePersonNote, noteText } from './issue-autofix-notes.mjs';
12
+ import { verifyTerminal } from './issue-autofix-release.mjs';
13
+ import { patchIssue, readNs, patchNs, clearAwaiting } from './issue-autofix-state.mjs';
14
+
15
+ const VIEW_FIELDS = 'number,title,author,body,labels,comments,updatedAt,state,createdAt';
16
+ const NOTES_KEPT = 400;
17
+ const NEEDS_OWNER_LABEL = { findings: ['autofix:needs-owner'], 'not-fixed': ['autofix:needs-owner'], reply: ['autofix:needs-owner'] };
18
+
19
+ export function viewIssue(rt, n) {
20
+ const r = rt.exec(rt.ghBin, ['issue', 'view', String(n), '--repo', rt.repo, '--json', VIEW_FIELDS]);
21
+ if (r.status !== 0) return null;
22
+ try { return JSON.parse(r.stdout); } catch { return null; }
23
+ }
24
+
25
+ const words = (t) => String(t).split(/\s+/).filter(Boolean);
26
+ export const priorNotes = (rt) => (readNs(rt.paths.state)._notesLog || []);
27
+ const openings = (notes) => notes.map((n) => `${words(n.text).slice(0, 8).join(' ')} … ${words(n.text).slice(-8).join(' ')}`);
28
+
29
+ async function writeNote(rt, login, group, now) {
30
+ const items = group.items.map((it) => ({ number: it.issue.number, title: it.issue.title, theirWords: reporterWords(it.issue, login),
31
+ waitedDays: waitedDays(it.issue, now), kind: it.kind, facts: it.facts }));
32
+ const prior = [...priorNotes(rt), ...rt.tonightNotes];
33
+ fs.mkdirSync(rt.tmpRoot, { recursive: true });
34
+ const tmp = fs.mkdtempSync(path.join(rt.tmpRoot, 'autofix-note-'));
35
+ return generatePersonNote({ login, items: group.items, priorNotes: prior, now, generate: async ({ feedback }) => {
36
+ const r = await rt.agent('note', 'S5b', tmp, { login, items, priorOpenings: openings(prior), feedback });
37
+ return r.result;
38
+ } });
39
+ }
40
+
41
+ /**
42
+ * items: [{ issue (full view), kind: fixed|shipping|findings|not-fixed|reply, facts, version?, sha?, testPath?,
43
+ * platformCaveat?, fallback: {whatWasWrong, whatChanged?, whatItNeeds?} }]. `checkpoint` is re-run before every post.
44
+ * Returns per-issue results. Never throws for a GitHub failure: the state keeps what still needs saying.
45
+ */
46
+ export async function notifyPeople(rt, items, { checkpoint }) {
47
+ const results = [];
48
+ const now = rt.now();
49
+ for (const group of groupByPerson(items)) {
50
+ const login = group.login;
51
+ let gen;
52
+ try { gen = await writeNote(rt, login, group, now); } catch (e) { gen = { ok: false, note: null, rejections: [[`error:${e.code || e.message}`]] }; }
53
+ if (!gen.ok) rt.page(`AUTOFIX NEEDS OWNER: write a personal thank-you to @${login}`, `No note passed the guards in ${gen.attempts ?? 0} attempts (${JSON.stringify(gen.rejections).slice(0, 400)}). Issues ${group.items.map((i) => i.issue.number).join(', ')} got the factual report only.`);
54
+ let thanksOn = null;
55
+ for (const it of group.items) {
56
+ const n = it.issue.number;
57
+ await checkpoint(n);
58
+ const view = viewIssue(rt, n);
59
+ if (!view) { results.push({ number: n, kind: it.kind, commented: false, error: 'cannot read the issue thread' }); continue; }
60
+ const close = it.kind === 'fixed' && String(view.state || 'OPEN').toUpperCase() === 'OPEN';
61
+ if (alreadyPosted(view.comments || [], { outcome: it.kind, version: it.version, issueUpdatedAt: view.updatedAt })) {
62
+ let closed = false;
63
+ if (close) closed = rt.exec(rt.ghBin, ['issue', 'close', String(n), '--repo', rt.repo, '--reason', 'completed']).status === 0;
64
+ results.push({ number: n, kind: it.kind, commented: false, deduped: true, closed });
65
+ continue;
66
+ }
67
+ const entry = { ...it.fallback, ...((gen.note?.perIssue || []).find((p) => p.number === n) || {}) };
68
+ const thanks = gen.ok && thanksOn === null ? gen.note.thanks : null;
69
+ const body = renderComment({ kind: it.kind, issueNumber: n, login: view.author?.login || login, thanks, thanksOn, entry,
70
+ facts: it.facts, version: it.version, sha: it.sha, commitUrl: it.sha ? `https://github.com/${rt.repo}/commit/${it.sha}` : undefined,
71
+ testPath: it.testPath, platformCaveat: it.platformCaveat || [], at: new Date(rt.now()).toISOString() });
72
+ const r = postOutcome({ repo: rt.repo, exec: rt.exec, ghBin: rt.ghBin }, { issueNumber: n, body, close, addLabels: NEEDS_OWNER_LABEL[it.kind] || [] });
73
+ if (r.commented && thanks) {
74
+ thanksOn = n;
75
+ const rec = { login: login.toLowerCase(), text: noteText(gen.note), at: new Date(rt.now()).toISOString(), issues: group.items.map((x) => x.issue.number) };
76
+ rt.tonightNotes.push(rec);
77
+ patchNs(rt.paths.state, { _notesLog: [...priorNotes(rt), rec].slice(-NOTES_KEPT) });
78
+ }
79
+ if (r.commented) {
80
+ patchIssue(rt.paths.state, n, { outcomes: [...(readNs(rt.paths.state)[n]?.outcomes || []), { kind: it.kind, version: it.version ?? null, postedAt: new Date(rt.now()).toISOString() }],
81
+ ...(it.kind === 'findings' ? { findingsPostedAt: new Date(rt.now()).toISOString() } : {}) });
82
+ }
83
+ results.push({ number: n, kind: it.kind, commented: r.commented, closed: r.closed, thanksIncluded: !!(r.commented && thanks), error: r.error, body });
84
+ }
85
+ }
86
+ return results;
87
+ }
88
+
89
+ /**
90
+ * S13b: issues whose release was published but not yet install-verified. Runs EVEN WHILE HALTED (it only reads and
91
+ * comments): when the terminal check now passes, say `fixed` and close; after 72 h unresolved, page.
92
+ */
93
+ export async function recheckAwaiting(rt, ctx, { checkpoint }) {
94
+ const done = [];
95
+ const due = [];
96
+ for (const [key, rec] of Object.entries(readNs(rt.paths.state))) {
97
+ const a = rec?.awaitingVerification;
98
+ if (!/^\d+$/.test(key) || !a) continue;
99
+ const n = Number(key);
100
+ const t = await verifyTerminal(ctx, { version: a.version, sha: a.sha });
101
+ if (!t.installVerified) {
102
+ if (rt.now() - Date.parse(a.publishedAt) > 72 * 3_600_000) rt.page(`AUTOFIX NEEDS OWNER #${n}: v${a.version} still unverified after 72 h`, 'The release was published but its install verification never passed.', 'high');
103
+ done.push({ number: n, outcome: 'still-awaiting' });
104
+ continue;
105
+ }
106
+ const view = viewIssue(rt, n);
107
+ if (!view) { done.push({ number: n, outcome: 'unreadable' }); continue; }
108
+ due.push({ issue: view, kind: 'fixed', facts: { verified: [...(a.facts?.verified || []), 'install-verified on Linux, macOS and Windows'], notVerified: a.facts?.notVerified || [] },
109
+ version: a.version, sha: a.fixSha || a.sha, testPath: a.testPath, platformCaveat: a.platformCaveat || [], fallback: a.fallback || { whatWasWrong: 'see the linked commit', whatChanged: 'see the linked commit' } });
110
+ }
111
+ if (!due.length) return done;
112
+ for (const r of await notifyPeople(rt, due, { checkpoint })) {
113
+ if (r.commented || r.deduped) {
114
+ clearAwaiting(rt.paths.state, r.number);
115
+ patchIssue(rt.paths.state, r.number, { lastOutcome: 'shipped' });
116
+ rt.page(`AUTOFIX SHIPPED #${r.number}`, 'Install-verified; the reporter was told and the issue closed.', 'low');
117
+ }
118
+ done.push({ number: r.number, outcome: r.commented || r.deduped ? 'fixed' : 'comment-failed', error: r.error });
119
+ }
120
+ return done;
121
+ }
@@ -0,0 +1,75 @@
1
+ // scripts/issue-autofix-runner.mjs — where the nightly autofix job's OWN code lives (ADR-107).
2
+ // launchd runs the job from ~/.cache/ruvnet-brain/autofix-runner: a detached worktree of the job's clone, pinned to
3
+ // the newest install-verified release tag. Never the owner's checkout: that would make the job depend on, and run, the
4
+ // owner's uncommitted work. After each run the runner moves to the newest tag that is published on npm, is GitHub's
5
+ // latest release, and resolves as install-verified — for the NEXT run; the running process is never changed under
6
+ // itself. Every file the runner executes is outside the editable allowlist (tests/unit/issue-autofix-deploy.test.mjs
7
+ // proves it over the whole import closure), so the job cannot change its own code; only a person's release can.
8
+ //
9
+ // node scripts/issue-autofix-runner.mjs --install --tag vX.Y.Z (arming: create the runner at a released tag)
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
14
+ import { makeExec } from './issue-autofix-exec.mjs';
15
+ import { PATHS } from './issue-autofix-state.mjs';
16
+
17
+ export const runnerPath = (home) => path.join(home, '.cache', 'ruvnet-brain', 'autofix-runner');
18
+ const TAG = /^v\d+\.\d+\.\d+$/;
19
+ const ver = (t) => String(t).replace(/^v/, '').split('.').map(Number);
20
+ const newer = (a, b) => { const x = ver(a); const y = ver(b); for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] > y[i]; return false; };
21
+
22
+ function runnerVersion(dir) {
23
+ try { return `v${JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version}`; } catch { return null; }
24
+ }
25
+
26
+ /** Point the runner at `tag` (fetching it into the clone) and install its node_modules. Throws on any failure. */
27
+ export function checkoutRunner(exec, { clone, runner, tag }) {
28
+ if (!TAG.test(tag)) throw new Error(`not a release tag: ${tag}`);
29
+ const must = (args, opts) => { const r = exec('git', args, opts); if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr.slice(-300)}`); return r; };
30
+ must(['-C', clone, 'fetch', '-q', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]);
31
+ if (!fs.existsSync(path.join(runner, '.git'))) {
32
+ fs.mkdirSync(path.dirname(runner), { recursive: true });
33
+ must(['-C', clone, 'worktree', 'add', '--detach', runner, tag]);
34
+ } else {
35
+ if (exec('git', ['-C', runner, 'status', '--porcelain', '--untracked-files=no']).stdout.trim()) throw new Error('the runner worktree has local changes; it is never overwritten');
36
+ must(['-C', runner, 'checkout', '-q', '--detach', tag]);
37
+ }
38
+ fs.mkdirSync(path.join(runner, 'logs'), { recursive: true }); // launchd's StandardOutPath/StandardErrorPath live here
39
+ const ci = exec('npm', ['ci', '--no-audit', '--no-fund'], { cwd: runner, timeoutMs: 30 * 60_000 });
40
+ if (ci.status !== 0) throw new Error(`npm ci in the runner failed: ${ci.stderr.slice(-300)}`);
41
+ return runnerVersion(runner);
42
+ }
43
+
44
+ /**
45
+ * After a live run: move the runner to `latestTag` when it is newer AND install-verified. Best effort; never throws.
46
+ * Returns { moved, from, to, reason }.
47
+ */
48
+ export async function advanceRunner(rt, { latestTag, installVerified }) {
49
+ const runner = rt.paths.runner;
50
+ if (!runner) return { moved: false, from: null, to: null, reason: 'no runner path' };
51
+ const from = runnerVersion(runner);
52
+ if (!fs.existsSync(path.join(runner, '.git'))) return { moved: false, from, to: null, reason: 'no runner worktree (not armed from one)' };
53
+ if (!latestTag || !TAG.test(latestTag) || !from || !newer(latestTag, from)) return { moved: false, from, to: latestTag, reason: 'up to date' };
54
+ if (!installVerified) return { moved: false, from, to: latestTag, reason: `${latestTag} is not install-verified yet` };
55
+ try { return { moved: true, from, to: checkoutRunner(rt.exec, { clone: rt.paths.clone, runner, tag: latestTag }), reason: 'advanced' }; }
56
+ catch (e) { return { moved: false, from, to: latestTag, reason: e.message.slice(0, 300) }; }
57
+ }
58
+
59
+ if (isDirectInvocation(import.meta.url)) {
60
+ const argv = process.argv.slice(2);
61
+ const tag = argv[argv.indexOf('--tag') + 1];
62
+ if (!argv.includes('--install') || !TAG.test(String(tag))) {
63
+ console.error('usage: node scripts/issue-autofix-runner.mjs --install --tag vX.Y.Z');
64
+ process.exit(2);
65
+ }
66
+ const home = process.env.HOME || os.homedir();
67
+ const paths = PATHS(home);
68
+ const exec = makeExec({ primary: paths.primary });
69
+ if (!fs.existsSync(path.join(paths.clone, '.git'))) {
70
+ const r = exec('git', ['clone', '-q', '--no-checkout', 'https://github.com/stuinfla/ruvnet-brain.git', paths.clone]);
71
+ if (r.status !== 0) { console.error(r.stderr); process.exit(1); }
72
+ }
73
+ try { console.log(`runner ${runnerPath(home)} at ${checkoutRunner(exec, { clone: paths.clone, runner: runnerPath(home), tag })}`); }
74
+ catch (e) { console.error(`issue-autofix-runner: ${e.message}`); process.exit(1); }
75
+ }
@@ -0,0 +1,108 @@
1
+ // scripts/issue-autofix-sandbox.mjs — the ONLY way the autofix job (ADR-107) runs code a model wrote.
2
+ //
3
+ // The build stage edits files from a stranger's issue. Running its tests (test validity, the verifier's evidence
4
+ // re-runs) executes that code on the owner's Mac, where the gh token sits in ~/.config/gh/hosts.yml and Claude's
5
+ // OAuth credential sits in the login keychain. So every such run goes through macOS `sandbox-exec` with a profile
6
+ // that denies ALL outbound network, keychain lookups, reads of credential stores and private folders, and every write
7
+ // outside the worktree and a per-run temp dir; the child also gets a minimal environment (no tokens, HOME = temp).
8
+ // Measured on this Mac (Darwin 27, 2026-10-30): inside the profile vitest runs normally while ~/.npmrc,
9
+ // ~/.config/gh, ~/.ssh, the Claude keychain item, curl and fetch are all refused (scratchpad probe, see ADR-107).
10
+ // Off macOS, or with sandbox-exec missing, nothing runs: AutofixAbort('sandbox-unavailable') — fail closed.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { AutofixAbort } from './issue-autofix-policy.mjs';
15
+
16
+ export const SANDBOX_BIN = '/usr/bin/sandbox-exec';
17
+ const q = (p) => JSON.stringify(String(p)); // SBPL string literal (paths never contain quotes; JSON escaping is exact)
18
+
19
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
20
+ /** Every hop of a symlink chain: the kernel checks each link it walks, so each must be readable. */
21
+ function linkChain(p, out = new Set()) {
22
+ let cur = path.resolve(p);
23
+ for (let i = 0; i < 16 && !out.has(cur); i += 1) {
24
+ out.add(cur);
25
+ let next;
26
+ try { next = path.resolve(path.dirname(cur), fs.readlinkSync(cur)); } catch { break; }
27
+ cur = next;
28
+ }
29
+ out.add(real(p));
30
+ return out;
31
+ }
32
+
33
+ /** Home-relative stores the child may never read (credentials, browser data, other projects' .env files). */
34
+ export const READ_DENY_HOME = ['.ssh', '.npmrc', '.netrc', '.aws', '.config', '.claude', '.gnupg', '.docker', '.kube',
35
+ '.git-credentials', '.gitconfig', '.local/share', '.npm/_cacache', 'Library', 'Documents', 'Desktop', 'Downloads', 'Code', '.cache/ruvnet-brain'];
36
+ const SECRET_MACH = ['com.apple.SecurityServer', 'com.apple.securityd.xpc', 'com.apple.security.agent', 'com.apple.secd',
37
+ 'com.apple.security.keychain-circle-notification', 'com.apple.trustd.agent'];
38
+
39
+ /**
40
+ * The SBPL profile. `writable` and `readable` are absolute paths; `readable` re-opens subpaths of a denied tree (for
41
+ * example a node_modules the worktree links to). Later rules win in SBPL, so the allows come after the denies.
42
+ */
43
+ export function sandboxProfile({ home, writable = [], readable = [] }) {
44
+ if (!home || !path.isAbsolute(home)) throw new Error('sandboxProfile: home must be absolute');
45
+ if (!writable.length || !writable.every((p) => path.isAbsolute(p))) throw new Error('sandboxProfile: writable paths must be absolute');
46
+ const h = real(home);
47
+ const deny = READ_DENY_HOME.map((d) => `(subpath ${q(path.join(h, d))})`);
48
+ return [
49
+ '(version 1)',
50
+ '(allow default)',
51
+ '(deny network-outbound (remote ip))',
52
+ '(deny network-outbound (remote unix-socket))',
53
+ '(deny network-bind)',
54
+ `(deny mach-lookup ${SECRET_MACH.map((n) => `(global-name ${q(n)})`).join(' ')})`,
55
+ `(deny file-read-data ${deny.join(' ')} (subpath "/Library/Keychains") (regex #"/\\.env(\\.[^/]*)?$"))`,
56
+ readable.length ? `(allow file-read-data ${[...new Set(readable.flatMap((p) => [...linkChain(p)]))].map((p) => `(subpath ${q(p)})`).join(' ')})` : '',
57
+ `(deny file-write* (require-not (require-any ${writable.map((p) => `(subpath ${q(real(p))})`).join(' ')} (subpath "/dev"))))`,
58
+ '',
59
+ ].filter((l, i, a) => l !== '' || i === a.length - 1).join('\n');
60
+ }
61
+
62
+ /** The child environment: nothing inherited but what node/npx need to find themselves. */
63
+ export function sandboxEnv({ tmpHome, pathDirs }) {
64
+ return { PATH: pathDirs.join(':'), HOME: tmpHome, TMPDIR: tmpHome, CI: '1', NO_COLOR: '1', LANG: 'en_US.UTF-8',
65
+ npm_config_update_notifier: 'false', npm_config_audit: 'false', npm_config_fund: 'false', npm_config_offline: 'true' };
66
+ }
67
+
68
+ /**
69
+ * Returns run(cmd, args, { cwd, timeoutMs, readable }) that executes through `exec` (so assertCommandAllowed and the
70
+ * primary-checkout guard still apply) inside the sandbox, writable only in `cwd` and a fresh temp dir.
71
+ */
72
+ export function makeSandboxRunner({ exec, tmpRoot = os.tmpdir(), platform = process.platform, sandboxBin = SANDBOX_BIN,
73
+ home = os.homedir(), nodeDir = path.dirname(process.execPath), exists = fs.existsSync } = {}) {
74
+ return function runSandboxed(cmd, args = [], { cwd, timeoutMs, readable = [], stage = 'S4' } = {}) {
75
+ if (platform !== 'darwin' || !exists(sandboxBin)) {
76
+ throw new AutofixAbort(stage, 'sandbox-unavailable', `untrusted code runs only under ${sandboxBin} on macOS`);
77
+ }
78
+ if (!cwd || !path.isAbsolute(cwd)) throw new AutofixAbort(stage, 'sandbox-unavailable', 'sandboxed runs need an absolute cwd');
79
+ fs.mkdirSync(tmpRoot, { recursive: true });
80
+ const tmp = fs.mkdtempSync(path.join(tmpRoot, 'autofix-sbx-'));
81
+ const profile = path.join(tmp, 'profile.sb');
82
+ // node_modules is usually a link out of the worktree; its target must stay readable even under a denied tree.
83
+ // node_modules is a directory of links into the shared install (issue-autofix-diff.mjs linkModules) or, in older
84
+ // layouts, a single link: either way the install it points into must stay READABLE even inside a denied tree such as
85
+ // ~/.cache/ruvnet-brain (measured 2026-10-30: without this, vitest itself could not be read and no test started).
86
+ const nm = path.join(cwd, 'node_modules');
87
+ const targets = new Set();
88
+ if (exists(nm)) {
89
+ targets.add(real(nm));
90
+ try {
91
+ for (const e of fs.readdirSync(nm)) {
92
+ const p = path.join(nm, e);
93
+ if (fs.lstatSync(p).isSymbolicLink()) targets.add(path.dirname(real(p)));
94
+ }
95
+ } catch { /* an unreadable node_modules just gets no extra grants */ }
96
+ }
97
+ const extra = [cwd, ...readable, ...targets];
98
+ fs.writeFileSync(profile, sandboxProfile({ home, writable: [cwd, tmp], readable: extra }));
99
+ const tmpHome = path.join(tmp, 'home');
100
+ fs.mkdirSync(tmpHome);
101
+ const env = sandboxEnv({ tmpHome, pathDirs: [nodeDir, '/usr/bin', '/bin'] });
102
+ try {
103
+ return exec(sandboxBin, ['-f', profile, cmd, ...args], { cwd, env, timeoutMs });
104
+ } finally {
105
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* temp only */ }
106
+ }
107
+ };
108
+ }