Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fcf92f8
Prune Tauri dependencies and add URL opener
nedtwigg May 23, 2026
d55061b
Add Cargo dependency snapshot
nedtwigg May 23, 2026
9448d1e
Rename dependency snapshots
nedtwigg May 23, 2026
655f426
Rename dependencies page to supply chain
nedtwigg May 23, 2026
5963f86
Fix stale README link to renamed supply-chain page
nedtwigg May 23, 2026
925a0d3
Rename Dependencies page component to SupplyChain
nedtwigg May 23, 2026
1a6b295
Revert "Prune Tauri dependencies and add URL opener"
nedtwigg May 25, 2026
7442e66
Drop requirement/scope/features from direct Cargo dependency listing
nedtwigg May 26, 2026
cbd2193
Summarize tend secret blast radius up front in SECURITY.md
nedtwigg May 26, 2026
9efb6a4
Normalize license strings: slash to OR, MIT first
nedtwigg May 26, 2026
9ba67bc
Add Author column to direct Cargo table and align table spacing
nedtwigg May 26, 2026
c0bb0e7
Unify Supply Chain link styling and add security overview
nedtwigg May 26, 2026
9c2aa81
Tighten Supply Chain copy
nedtwigg May 26, 2026
c945c42
List bundled Node.js runtime in supply chain
nedtwigg May 26, 2026
9b50840
Always run pty host under Electron's bundled Node
nedtwigg May 26, 2026
b1e58cb
Remove unused totalDependencyCount in SupplyChain
nedtwigg May 26, 2026
4d9c91f
Make the disclosed Node.js runtime version provable
nedtwigg May 26, 2026
e53657b
Clarify Cargo crates aren't all shipped in the final binary
nedtwigg May 26, 2026
0dab997
Bump bundled Node.js runtime to 22.22.3
nedtwigg May 26, 2026
1136672
Don't regenerate dependency snapshots during website build
nedtwigg May 26, 2026
380a434
Polish Supply Chain page and fill in libappindicator crate metadata
nedtwigg May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
node-version-file: standalone/.node-version

- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ This project was built with a combination of Claude, Codex, and Devin. We make h

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

[Production dependencies](https://dormouse.sh/dependencies)
[Supply chain](https://dormouse.sh/supply-chain)
27 changes: 20 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@ Dormouse is a terminal, so users trust it with shells, source trees, credentials

## Dependency Supply Chain

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.
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).

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

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.
- every npm dependency (direct and transitive)
- every cargo dependency (direct is listed separately from transitive)
- the Node.js runtime bundled as a Tauri sidecar in the standalone app

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.
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.

- FAIL IF `node website/scripts/generate-deps.js` changes `website/src/data/dependencies.json` when run from a clean checkout.
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`.

- 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.
- 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`).
- 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).
- 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).
- FAIL IF `pnpm-workspace.yaml` is missing `minimumReleaseAge: 1440`.
- FAIL IF `.github/dependabot.yml` is missing npm coverage for `/` or Cargo coverage for `/standalone/src-tauri`.
- FAIL IF `.github/dependabot.yml` is missing dependency cooldown windows.
Expand All @@ -30,7 +37,13 @@ GitHub Actions are always pinned by commit hash, not version tag. Dependabot wil

## Automated Maintainer (tend)

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.
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.

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.

- `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.**
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The . and the bot's trusted identity. fragment reads as a leftover from an edit — it has no verb, and the bot's trusted identity is already covered by the bold sentence that follows. Suggest dropping it:

Suggested change
- `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.**
- `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. **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.**

- `CLAUDE_CODE_OAUTH_TOKEN`: bounded Anthropic API-credit abuse, capped by the bot account's spend limit.
- `CHROMATIC_PROJECT_TOKEN`: lets the attacker corrupt snapshot testing; mitigated by rotation, and any abuse is visible in Chromatic's own dashboard.

**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.

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

- FAIL IF `.github/workflows/security-audit.yaml` is missing, disabled, or no longer invoked from `release.yml`'s publish path.
- 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.
- 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.
2 changes: 1 addition & 1 deletion docs/specs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Every release produces three artifact groups under one version and changelog:

Human-driven steps, in order:

1. **Update dependencies page** — run `node website/scripts/generate-deps.js` and review the diff in `website/src/data/dependencies.json`. Commit if changed.
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.
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.
3. **Commit and tag** — `git commit -am "Release vX.Y.Z"` then `git tag vX.Y.Z`.
4. **Push** — `git push && git push origin vX.Y.Z`. This triggers CI (Stage 1).
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions standalone/.node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.22.3
71 changes: 71 additions & 0 deletions standalone/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ fn bundle_node_runtime() -> Result<(), Box<dyn Error>> {
println!("cargo:rerun-if-changed={}", node_source.display());
validate_node_binary(&node_source, &target)?;

// The supply-chain page (website/src/data/dependencies-runtime.json) discloses
// an exact Node.js version. Fail the build if the binary we're about to bundle
// doesn't match standalone/.node-version, so the disclosed version provably
// equals what ships. CI installs the pin via setup-node's node-version-file.
let pinned_version = read_pinned_node_version(&manifest_dir)?;
verify_node_version(&node_source, &host, &target, &pinned_version)?;

let binaries_dir = manifest_dir.join("binaries");
fs::create_dir_all(&binaries_dir)?;

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

fn read_pinned_node_version(manifest_dir: &Path) -> Result<String, Box<dyn Error>> {
let pin_path = manifest_dir
.parent()
.ok_or("manifest dir has no parent")?
.join(".node-version");
println!("cargo:rerun-if-changed={}", pin_path.display());

let raw = fs::read_to_string(&pin_path)
.map_err(|err| format!("failed to read {}: {err}", pin_path.display()))?;
let version = raw.trim().trim_start_matches('v').to_owned();

let is_exact = version.split('.').count() == 3
&& version
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
if !is_exact {
return Err(format!(
"{} must pin an exact Node.js version (MAJOR.MINOR.PATCH), found {version:?}",
pin_path.display()
)
.into());
}

Ok(version)
}

fn verify_node_version(
node_source: &Path,
host: &str,
target: &str,
pinned: &str,
) -> Result<(), Box<dyn Error>> {
if host != target {
// Can't execute a foreign-arch binary; the operator supplied it via
// MOUSETERM_NODE_BINARY and is responsible for matching the pin.
println!(
"cargo:warning=skipping Node.js version check when cross-compiling to {target}; \
ensure the bundled runtime is v{pinned}"
);
return Ok(());
}

let output = Command::new(node_source).arg("--version").output()?;
if !output.status.success() {
return Err(format!("failed to run `{} --version`", node_source.display()).into());
}

let actual = String::from_utf8(output.stdout)?
.trim()
.trim_start_matches('v')
.to_owned();
if actual != pinned {
return Err(format!(
"bundled Node.js {actual} does not match the standalone/.node-version pin {pinned}. \
Install the pinned version (CI uses actions/setup-node with \
node-version-file: standalone/.node-version) or update the pin and regenerate \
website/src/data/dependencies-runtime.json."
)
.into());
}

Ok(())
}

fn resolve_node_binary(host: &str, target: &str) -> Result<PathBuf, Box<dyn Error>> {
if let Some(path) = env::var_os("MOUSETERM_NODE_BINARY").or_else(|| env::var_os("NODE_BINARY"))
{
Expand Down
57 changes: 11 additions & 46 deletions vscode-ext/src/pty-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { fork, ChildProcess, execFileSync } from 'child_process';
import { fork, ChildProcess } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { log } from './log';

export interface PtyCallbacks {
Expand Down Expand Up @@ -95,56 +94,22 @@ let child: ChildProcess | null = null;
let childReady = false;
let pendingMessages: any[] = [];
const callbackSet = new Set<PtyCallbacks>();
let cachedNodePath: string | null = null;

function findSystemNode(): string {
if (cachedNodePath) return cachedNodePath;

// On Windows, use the host's execPath (Electron's Node). VSCode's own
// integrated terminal uses node-pty against Electron, so this works for us
// too — and avoids the bogus Unix-path fallback below that was causing
// multi-second fork stalls.
if (process.platform === 'win32') {
cachedNodePath = process.execPath;
return cachedNodePath;
}

// Try common locations first (avoids shell invocation)
const candidates = [
process.env.NVM_BIN && path.join(process.env.NVM_BIN, 'node'),
'/usr/local/bin/node',
'/usr/bin/node',
'/opt/homebrew/bin/node',
].filter(Boolean) as string[];

for (const p of candidates) {
try {
if (fs.statSync(p).isFile()) {
cachedNodePath = p;
return p;
}
} catch { /* not found, try next */ }
}

// Fall back to PATH lookup via env (portable, no 'which' dependency)
try {
cachedNodePath = execFileSync('/usr/bin/env', ['node', '-e', 'process.stdout.write(process.execPath)'], {
encoding: 'utf-8',
timeout: 3000,
}).trim();
return cachedNodePath;
} catch {
// Last resort
cachedNodePath = '/usr/local/bin/node';
return cachedNodePath;
}
// Always run the pty host under the editor's own Node — Electron's bundled
// runtime (process.execPath, re-execed as Node via ELECTRON_RUN_AS_NODE, which
// is inherited through `env` at the fork site). VSCode's integrated terminal
// drives node-pty against Electron the same way, and node-pty ships N-API
// prebuilds that load across runtimes, so there's no need to hunt for a
// user-installed system Node — which was unreliable and, on Windows, caused
// multi-second fork stalls.
function resolveNodeBinary(): string {
return process.execPath;
}

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

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

child = fork(hostScript, [], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
Expand Down
5 changes: 3 additions & 2 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"dev": "vite-react-ssg dev",
"predev": "node scripts/generate-changelog.js",
"prebuild": "node scripts/generate-deps.js && node scripts/generate-changelog.js",
"prebuild": "node scripts/generate-changelog.js",
"build": "vite-react-ssg build",
"preview": "vite preview",
"pretest": "node scripts/generate-changelog.js",
Expand All @@ -19,7 +19,8 @@
"dormouse-lib": "workspace:*",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"tailwind-variants": "^3.2.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
Expand Down
Loading
Loading