Skip to content

Commit e27a348

Browse files
authored
Supply Chain page: Cargo + bundled-runtime snapshots, provable Node pin (#98)
2 parents ab3cfcb + 380a434 commit e27a348

17 files changed

Lines changed: 4048 additions & 123 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333

3434
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
3535
with:
36-
node-version: 22
36+
node-version-file: standalone/.node-version
3737

3838
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
3939
with:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,4 @@ This project was built with a combination of Claude, Codex, and Devin. We make h
5656

5757
[FSL-1.1-MIT](LICENSE) — Copyright 2026 DiffPlug LLC
5858

59-
[Production dependencies](https://dormouse.sh/dependencies)
59+
[Supply chain](https://dormouse.sh/supply-chain)

SECURITY.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,22 @@ Dormouse is a terminal, so users trust it with shells, source trees, credentials
66

77
## Dependency Supply Chain
88

9-
Dormouse keeps its runtime dependency surface intentionally small. We add dependencies only when they are necessary, and we expect dependency changes to justify their value against their supply-chain risk.
9+
Dormouse keeps its runtime dependency surface intentionally small. We add dependencies only when they are necessary, and we expect dependency changes to justify their value against their supply-chain risk. We use maturity gating inside our pnpm configuration and also inside our [dependabot configuration](.github/dependabot.yml).
1010

11-
Every production transitive dependency shipped in the end-user application is listed at <https://dormouse.sh/dependencies>
11+
Every dependency shipped in the end-user application is listed at <https://dormouse.sh/supply-chain>. This includes:
1212

13-
That page is generated from the lockfile and reviewed as part of release work. If a production dependency is added, removed, or upgraded, the dependency list must be regenerated and committed.
13+
- every npm dependency (direct and transitive)
14+
- every cargo dependency (direct is listed separately from transitive)
15+
- the Node.js runtime bundled as a Tauri sidecar in the standalone app
1416

15-
New npm package versions are not adopted immediately. The workspace uses pnpm's package maturity gate so newly published npm versions have time to be reported, yanked, or investigated before Dormouse resolves them into the lockfile. Dependabot also applies cooldown windows for npm and Cargo updates so routine dependency PRs do not chase brand-new releases unnecessarily.
17+
Those dependency snapshots are generated from the lockfiles and reviewed as part of release work. If a production dependency is added, removed, or upgraded, the dependency lists must be regenerated and committed.
1618

17-
- FAIL IF `node website/scripts/generate-deps.js` changes `website/src/data/dependencies.json` when run from a clean checkout.
19+
The standalone app ships a Node.js runtime binary (`standalone/src-tauri/build.rs` copies it into the bundle as a Tauri sidecar). Its version is pinned exactly in `standalone/.node-version`, and the build is the authority: `build.rs` runs `--version` on the binary it is about to bundle and fails the build unless it matches the pin. The supply-chain page reads the same pin, so the version disclosed there provably equals the runtime users receive — it cannot drift to whatever Node happened to be on the build machine's PATH. The version is a deliberate, manual pin (no Dependabot ecosystem tracks it); the workflows that do not bundle the runtime are free to track the latest `22`.
20+
21+
- FAIL IF `node website/scripts/generate-deps.js` changes `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, or `website/src/data/dependencies-runtime.json` when run from a clean checkout.
22+
- FAIL IF `standalone/.node-version` is missing or does not pin an exact Node.js version (a bare major such as `22` is not acceptable; it must be `MAJOR.MINOR.PATCH`).
23+
- FAIL IF `standalone/src-tauri/build.rs` no longer verifies that the bundled Node.js binary matches `standalone/.node-version` (this verification is what makes the disclosed runtime version provable).
24+
- FAIL IF the `build-standalone` job in `.github/workflows/release.yml` does not install the pinned runtime via `actions/setup-node` with `node-version-file: standalone/.node-version` (other jobs may pin `node-version` inline since their interpreter is never bundled).
1825
- FAIL IF `pnpm-workspace.yaml` is missing `minimumReleaseAge: 1440`.
1926
- FAIL IF `.github/dependabot.yml` is missing npm coverage for `/` or Cargo coverage for `/standalone/src-tauri`.
2027
- FAIL IF `.github/dependabot.yml` is missing dependency cooldown windows.
@@ -30,7 +37,13 @@ GitHub Actions are always pinned by commit hash, not version tag. Dependabot wil
3037

3138
## Automated Maintainer (tend)
3239

33-
This repository runs the [tend](https://github.com/max-sixty/tend) agent harness as the GitHub user `dormouse-bot`. tend reviews PRs, triages issues, fixes CI failures, regenerates its own workflow files on a nightly schedule, and responds to mentions. The agent expands the project's attack surface. The boundaries we accept are codified below.
40+
This repository runs the [tend](https://github.com/max-sixty/tend) agent harness as the GitHub user `dormouse-bot`. tend reviews PRs, triages issues, fixes CI failures, regenerates its own workflow files on a nightly schedule, and responds to mentions. The agent expands the project's attack surface.
41+
42+
An attacker who lands a prompt injection in tend's harness can reach three secrets. None of them escalates directly into malicious content on the `main` branch or into any deployment-related secret — those paths stay admin-gated. The boundaries we accept are codified below.
43+
44+
- `TEND_BOT_TOKEN` (worst case): full `repo` + `workflow` write access *as a trusted collaborator*. Direct uses are issue/PR spam, force-pushing or deleting feature branches, and persistent compromise by authoring new workflows (persistent compromise mitigated by [`workflow-audit.yaml`](.github/workflows/workflow-audit.yaml)). Authoring a workflow is also the mechanism by which `CHROMATIC_PROJECT_TOKEN` is reached. and the bot's trusted identity. **It cannot itself merge to `main`, push tags, or reach env-scoped secrets, but the bot's trusted identity can be used to social-engineer an admin toward a `main` merge.**
45+
- `CLAUDE_CODE_OAUTH_TOKEN`: bounded Anthropic API-credit abuse, capped by the bot account's spend limit.
46+
- `CHROMATIC_PROJECT_TOKEN`: lets the attacker corrupt snapshot testing; mitigated by rotation, and any abuse is visible in Chromatic's own dashboard.
3447

3548
**Prompt-injection through user-supplied content.** tend's harness reads PR descriptions, code diffs, issue text, comments, and CI logs — all attacker-influenceable surfaces. A malicious prompt could direct the harness to push a workflow that references a repo-level secret to an external URL. The bot cannot merge to `main` or push tags, so admin-gated release paths stay sealed, but a workflow on a bot-pushed feature branch will still execute with repo-level secrets in scope.
3649

@@ -85,4 +98,4 @@ gh secret set AUDIT_PAT --env security-audit --repo diffplug/dormouse --body 'gi
8598
```
8699

87100
- FAIL IF `.github/workflows/security-audit.yaml` is missing, disabled, or no longer invoked from `release.yml`'s publish path.
88-
- FAIL IF the audit has been weakened — e.g. the prompt no longer requires the qualitative pass, a `FAIL IF` can be ignored, the failure-reporting step that opens a `security-audit-failure` issue and exits non-zero has been removed, or the `AUDIT_PAT` pre-check is removed or bypassed.
101+
- FAIL IF the audit has been weakened — e.g. the prompt no longer requires the qualitative pass, a `FAIL IF` can be ignored, the failure-reporting step that opens a `security-audit-failure` issue and exits non-zero has been removed, or the `AUDIT_PAT` pre-check is removed or bypassed.

docs/specs/deploy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Every release produces three artifact groups under one version and changelog:
1515

1616
Human-driven steps, in order:
1717

18-
1. **Update dependencies page** — run `node website/scripts/generate-deps.js` and review the diff in `website/src/data/dependencies.json`. Commit if changed.
18+
1. **Update dependency snapshots** — run `node website/scripts/generate-deps.js` and review the diffs in `website/src/data/dependencies-npm.json` and `website/src/data/dependencies-cargo.json`. Commit if changed.
1919
2. **Draft release notes and bump version** — run `/release-notes` in Claude Code at the repo root. The slash command (defined in [.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)) walks the merge commits and squash-merged PRs since the last tag, recommends a `breaking.added.bugfix` version bump, runs `./scripts/bump-version.sh X.Y.Z`, and edits `CHANGELOG.md` for the same version. Review and edit the resulting diff if needed.
2020
3. **Commit and tag**`git commit -am "Release vX.Y.Z"` then `git tag vX.Y.Z`.
2121
4. **Push**`git push && git push origin vX.Y.Z`. This triggers CI (Stage 1).

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

standalone/.node-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
22.22.3

standalone/src-tauri/build.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ fn bundle_node_runtime() -> Result<(), Box<dyn Error>> {
2929
println!("cargo:rerun-if-changed={}", node_source.display());
3030
validate_node_binary(&node_source, &target)?;
3131

32+
// The supply-chain page (website/src/data/dependencies-runtime.json) discloses
33+
// an exact Node.js version. Fail the build if the binary we're about to bundle
34+
// doesn't match standalone/.node-version, so the disclosed version provably
35+
// equals what ships. CI installs the pin via setup-node's node-version-file.
36+
let pinned_version = read_pinned_node_version(&manifest_dir)?;
37+
verify_node_version(&node_source, &host, &target, &pinned_version)?;
38+
3239
let binaries_dir = manifest_dir.join("binaries");
3340
fs::create_dir_all(&binaries_dir)?;
3441

@@ -84,6 +91,70 @@ fn reject_macos_dynamic_node(node_source: &Path) -> Result<(), Box<dyn Error>> {
8491
Ok(())
8592
}
8693

94+
fn read_pinned_node_version(manifest_dir: &Path) -> Result<String, Box<dyn Error>> {
95+
let pin_path = manifest_dir
96+
.parent()
97+
.ok_or("manifest dir has no parent")?
98+
.join(".node-version");
99+
println!("cargo:rerun-if-changed={}", pin_path.display());
100+
101+
let raw = fs::read_to_string(&pin_path)
102+
.map_err(|err| format!("failed to read {}: {err}", pin_path.display()))?;
103+
let version = raw.trim().trim_start_matches('v').to_owned();
104+
105+
let is_exact = version.split('.').count() == 3
106+
&& version
107+
.split('.')
108+
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
109+
if !is_exact {
110+
return Err(format!(
111+
"{} must pin an exact Node.js version (MAJOR.MINOR.PATCH), found {version:?}",
112+
pin_path.display()
113+
)
114+
.into());
115+
}
116+
117+
Ok(version)
118+
}
119+
120+
fn verify_node_version(
121+
node_source: &Path,
122+
host: &str,
123+
target: &str,
124+
pinned: &str,
125+
) -> Result<(), Box<dyn Error>> {
126+
if host != target {
127+
// Can't execute a foreign-arch binary; the operator supplied it via
128+
// MOUSETERM_NODE_BINARY and is responsible for matching the pin.
129+
println!(
130+
"cargo:warning=skipping Node.js version check when cross-compiling to {target}; \
131+
ensure the bundled runtime is v{pinned}"
132+
);
133+
return Ok(());
134+
}
135+
136+
let output = Command::new(node_source).arg("--version").output()?;
137+
if !output.status.success() {
138+
return Err(format!("failed to run `{} --version`", node_source.display()).into());
139+
}
140+
141+
let actual = String::from_utf8(output.stdout)?
142+
.trim()
143+
.trim_start_matches('v')
144+
.to_owned();
145+
if actual != pinned {
146+
return Err(format!(
147+
"bundled Node.js {actual} does not match the standalone/.node-version pin {pinned}. \
148+
Install the pinned version (CI uses actions/setup-node with \
149+
node-version-file: standalone/.node-version) or update the pin and regenerate \
150+
website/src/data/dependencies-runtime.json."
151+
)
152+
.into());
153+
}
154+
155+
Ok(())
156+
}
157+
87158
fn resolve_node_binary(host: &str, target: &str) -> Result<PathBuf, Box<dyn Error>> {
88159
if let Some(path) = env::var_os("MOUSETERM_NODE_BINARY").or_else(|| env::var_os("NODE_BINARY"))
89160
{

vscode-ext/src/pty-manager.ts

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { fork, ChildProcess, execFileSync } from 'child_process';
1+
import { fork, ChildProcess } from 'child_process';
22
import * as path from 'path';
3-
import * as fs from 'fs';
43
import { log } from './log';
54

65
export interface PtyCallbacks {
@@ -95,56 +94,22 @@ let child: ChildProcess | null = null;
9594
let childReady = false;
9695
let pendingMessages: any[] = [];
9796
const callbackSet = new Set<PtyCallbacks>();
98-
let cachedNodePath: string | null = null;
99-
100-
function findSystemNode(): string {
101-
if (cachedNodePath) return cachedNodePath;
102-
103-
// On Windows, use the host's execPath (Electron's Node). VSCode's own
104-
// integrated terminal uses node-pty against Electron, so this works for us
105-
// too — and avoids the bogus Unix-path fallback below that was causing
106-
// multi-second fork stalls.
107-
if (process.platform === 'win32') {
108-
cachedNodePath = process.execPath;
109-
return cachedNodePath;
110-
}
111-
112-
// Try common locations first (avoids shell invocation)
113-
const candidates = [
114-
process.env.NVM_BIN && path.join(process.env.NVM_BIN, 'node'),
115-
'/usr/local/bin/node',
116-
'/usr/bin/node',
117-
'/opt/homebrew/bin/node',
118-
].filter(Boolean) as string[];
119-
120-
for (const p of candidates) {
121-
try {
122-
if (fs.statSync(p).isFile()) {
123-
cachedNodePath = p;
124-
return p;
125-
}
126-
} catch { /* not found, try next */ }
127-
}
128-
129-
// Fall back to PATH lookup via env (portable, no 'which' dependency)
130-
try {
131-
cachedNodePath = execFileSync('/usr/bin/env', ['node', '-e', 'process.stdout.write(process.execPath)'], {
132-
encoding: 'utf-8',
133-
timeout: 3000,
134-
}).trim();
135-
return cachedNodePath;
136-
} catch {
137-
// Last resort
138-
cachedNodePath = '/usr/local/bin/node';
139-
return cachedNodePath;
140-
}
97+
// Always run the pty host under the editor's own Node — Electron's bundled
98+
// runtime (process.execPath, re-execed as Node via ELECTRON_RUN_AS_NODE, which
99+
// is inherited through `env` at the fork site). VSCode's integrated terminal
100+
// drives node-pty against Electron the same way, and node-pty ships N-API
101+
// prebuilds that load across runtimes, so there's no need to hunt for a
102+
// user-installed system Node — which was unreliable and, on Windows, caused
103+
// multi-second fork stalls.
104+
function resolveNodeBinary(): string {
105+
return process.execPath;
141106
}
142107

143108
function ensureChild(extensionPath: string): ChildProcess {
144109
if (child && child.connected) return child;
145110

146111
const hostScript = path.join(extensionPath, 'dist', 'pty-host.js');
147-
const nodePath = findSystemNode();
112+
const nodePath = resolveNodeBinary();
148113

149114
child = fork(hostScript, [], {
150115
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],

website/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"scripts": {
88
"dev": "vite-react-ssg dev",
99
"predev": "node scripts/generate-changelog.js",
10-
"prebuild": "node scripts/generate-deps.js && node scripts/generate-changelog.js",
10+
"prebuild": "node scripts/generate-changelog.js",
1111
"build": "vite-react-ssg build",
1212
"preview": "vite preview",
1313
"pretest": "node scripts/generate-changelog.js",
@@ -19,7 +19,8 @@
1919
"dormouse-lib": "workspace:*",
2020
"react": "^19.2.6",
2121
"react-dom": "^19.2.6",
22-
"react-router-dom": "^6.30.3"
22+
"react-router-dom": "^6.30.3",
23+
"tailwind-variants": "^3.2.2"
2324
},
2425
"devDependencies": {
2526
"@tailwindcss/vite": "^4.3.0",

0 commit comments

Comments
 (0)