Skip to content

[security-audit] FAIL on 2026-08-27 #447

Description

@github-actions

Audit failed at 2026-08-26T05:06Z. Run · Transcript

Security audit

Supply chain

Supply Chain Audit

Scope: ## Dependency Supply Chain only.

FAIL IF results

  1. PASSnode website/scripts/generate-deps.js produces no diff. Workspace was already installed (pnpm install --frozen-lockfile also run explicitly as a second check, Already up to date, 467ms). Generator ran successfully (wrote 48 npm deps, 13 direct + 474 transitive cargo deps, 1 bundled runtime) and git status --porcelain website/src/data/ / git diff --stat website/src/data/ were both empty before and after. Tree left clean.

  2. PASS.github/workflows/ci.yml lines 33-45 (Dependency disclosure is current step) runs node website/scripts/generate-deps.js then git diff --quiet -- website/src/data/, printing ::error:: and exit 1 on a diff. Runs after pnpm install --frozen-lockfile (line 24), consistent with the generator's real-node_modules requirement.

  3. PASS — Derived from pnpm-workspace.yaml independently of the spec's enumeration. The 10 workspace packages resolve to package names as follows:

    • Roots (6, matching productDependencyFilters in website/scripts/generate-deps.js): dor (dir dor), dormouse (dir vscode-ext), dormouse-standalone (dir standalone), dormouse-lib (dir lib), dormouse-sidecar (dir standalone/sidecar), server (dir server).
    • Edges reached from those roots (2): dor-lib-common (a real dependencies entry of dor and lib), server-lib-common (a real dependencies entry of server and lib) — confirmed by reading dor/package.json, server/package.json, lib/package.json.
    • Excluded with stated reasons (2): canopy (Storybook-only; verified @diffplug/xterm-addon-webgl-sdf's non-registry GitHub-tarball resolution in pnpm-lock.yaml line 649 is referenced only from canopy/package.json, nowhere else), website → package name dormouse-website (runs in a visitor's browser, not installed).
      All 10 accounted for; none missing from both roots and exclusions.
  4. PASS — Root package.json line 6-11: devEngines.runtime.version = "24.18.0", an exact MAJOR.MINOR.PATCH (not a bare major).

  5. PASSstandalone/src-tauri/build.rs: verify_node_version (line 191) runs node --version on the binary about to be bundled and errors on mismatch against the devEngines.runtime.version pin (read via read_pinned_node_version, which also independently validates the exact-version format, lines 176-186). The one skip is exactly the documented one: if host != target emits a cargo:warning and returns Ok(()) (lines 197-205), justified by inability to execute a foreign-arch binary. Checked release.yml's build-standalone matrix (lines 27-36): ubuntu-22.04x86_64-unknown-linux-gnu, macos-latestaarch64-apple-darwin, windows-latestx86_64-pc-windows-msvc — all three are host-native (no cross-compiled target such as universal macOS or arm64 Windows), so the skip is never exercised by the release matrix today.

  6. PASSrelease.yml's build-standalone job installs Node via actions/setup-node with node-version-file: package.json (lines 46-48). Root package.json has no volta or engines key (grep for both returned nothing), so setup-node's precedence resolution (volta.nodedevEngines.runtimeengines.node) is guaranteed to read the pinned devEngines.runtime.version.

  7. PASSpnpm-workspace.yaml contains minimumReleaseAge: 1440 (top-level, last line).

  8. PASS.github/renovate.json: enabledManagers = ["github-actions", "npm", "cargo"] (includes both npm and cargo). minimumReleaseAge package rules present for matchManagers: ["npm", "cargo"] at three update types: patch"1 day", minor"3 days", major"14 days".

Qualitative findings

  • INFO — Checked all commits since the last successful audit (2026-08-24, issue [security-audit] FAIL on 2026-08-22 #417) via gh api repos/diffplug/dormouse/commits (local clone is shallow, 1 commit, so GitHub API was used instead of git log). Three dependency-touching Renovate merges in that window: chore(deps): update storybook to v10.5.10 (chore(deps): update storybook to v10.5.10 #442), chore(deps): update pnpm to v11.22.0 (chore(deps): update pnpm to v11.22.0 #443), chore(deps): update dependency hono to v4.13.3 (chore(deps): update dependency hono to v4.13.3 #441). Inspected each commit's file list: chore(deps): update storybook to v10.5.10 #442 and chore(deps): update dependency hono to v4.13.3 #441 touch only pnpm-lock.yaml (lockfile-only version bumps within existing ranges, no new dependency); chore(deps): update pnpm to v11.22.0 #443 touches package.json (+1/-1, the packageManager/pnpm version field) plus the lockfile. None added a new production dependency or a new workspace root. No BLOCKER or WARNING findings here.

  • INFO — Only one non-registry lockfile resolution exists: @diffplug/xterm-addon-webgl-sdf resolved from a GitHub Releases tarball URL (pnpm-lock.yaml line 649-650), matching AGENTS.md's description of the sdf-branch xterm.js fork. Confirmed via grep that it is declared only in canopy/package.json, which is a deliberately-excluded, non-shipped Storybook lab per the spec's own text. Not a disclosure gap.

  • INFOpnpm-workspace.yaml's allowBuilds permits install/build scripts for @swc/core, esbuild, node-pty, sharp and explicitly denies keytar and @vscode/vsce-sign. Traced each with pnpm why:

    • node-pty is a genuine production dependencies entry of vscode-ext and standalone/sidecar (native PTY bindings — required, expected, and already disclosed: confirmed present in the generated website/src/data/dependencies-npm.json).
    • esbuild is a devDependency everywhere it appears (vscode-ext, dor, standalone) — a build tool, not shipped.
    • sharp is a transitive production dependency of dormouse-website's ascii-splash — but website is the one workspace package the spec deliberately excludes from the disclosed graph (runs in a visitor's browser / build-time-only image processing, nothing installed on a user's machine). Consistent with the stated exclusion rationale; not a new gap.
    • @swc/core appears in allowBuilds but does not currently resolve in the lockfile at all (grep -n swc pnpm-lock.yaml — no hits). This is harmless dead configuration (possibly a leftover from an optional/platform-conditional transitive dependency in a prior lockfile state), not a security issue, but worth a maintainer cleanup pass.
    • keytar and @vscode/vsce-sign are transitive devDependencies of @vscode/vsce/ovsx (extension packaging tooling only) and are correctly denied build-script execution.
    • Ran pnpm install --frozen-lockfile after all this and it reported no pending/ignored build-script warnings, confirming no package with an install script is un-triaged by allowBuilds.
  • No newly added or upgraded runtime dependency in the audit window introduces new install-script surface, no lockfile entry resolves outside the npm/cargo registries except the already-excluded canopy tarball, and no reachable-but-undisclosed package was found.

CI and secrets

Domain: ci-and-secrets

Scope: ## GitHub Actions Policies, ## Automated Maintainer (tend), ## VS Code Extension Releases, ## Desktop Releases, ## Reporting a Vulnerability, ## CI Validation Contract.

FAIL IF results

GitHub Actions Policies

  • pull_request_target outside tend-*.yaml: PASS. grep -rl pull_request_target .github/workflows returns only tend-review.yaml.
  • Non-agent-managed workflow effective write perms beyond the allowed set: PASS. ci.yml and chromatic.yml each declare workflow-level permissions: contents: read with no job overriding it (verified full file contents). release.yml declares workflow-level contents: read; build-standalone/build-vscode jobs declare contents: read, id-token: write, attestations: write; the security-audit job declares contents: read, actions: write (the explicitly named exception); publish-vscode has no job-level block and inherits the workflow-level contents: read. No non-agent-managed job exceeds the allowed set.

Automated Maintainer (tend)

  • workflow-audit.yaml lower bound: PASS. SINCE=$(gh api ".../workflows/workflow-audit.yaml/runs?status=success&per_page=1" --jq '.workflow_runs[0].created_at') — server-set created_at, not pusher-controlled (.github/workflows/workflow-audit.yaml lines 79-84).
  • Admin-gating rulesets: PASS. GET /rulesets/16757376 ("Merge access"): target: branch, conditions.ref_name.include: ["~DEFAULT_BRANCH"], rules: [{"type":"update"}], bypass_actors: [{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"exempt"}] (role 5 = admin). GET /rulesets/16757382 ("Tag operations"): target: tag, ref_name.include: ["~ALL"], rules: [{"type":"creation"},{"type":"update"}], same admin-only bypass actor. Both match the spec exactly.
  • dormouse-bot role: PASS. GET /collaborators/dormouse-bot/permission{"permission":"write","role_name":"write", ...,"permissions":{"admin":false,"maintain":false,"push":true,...}}. Neither maintain nor admin.
  • Environment deployment-branch-policies all admin-gated: PASS.
    • release-attest: branch policy v* (tag) only. Tag creation/update is admin-only via "Tag operations". No secrets.
    • security-audit: branch policies main (branch) + v* (tag). main updates are admin-only via "Merge access"; v* via "Tag operations". Secrets: AUDIT_PAT, CLAUDE_CODE_OAUTH_TOKEN.
    • tend: branch policy main only, admin-gated via "Merge access". Secrets: CLAUDE_CODE_OAUTH_TOKEN, TEND_BOT_TOKEN.
    • vscode-extension-publish: branch policy v* (tag) only, admin-gated via "Tag operations", plus a required_reviewers protection rule (nedtwigg, edgartwigg, prevent_self_review: true). Secrets: OVSX_PAT, VSCE_PAT.
  • Secret inventory placement: PASS for every named secret. Repo-level secrets (GET /actions/secrets): only CHROMATIC_PROJECT_TOKEN — matches "absent at repo level" for AUDIT_PAT, TEND_BOT_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, OVSX_PAT, VSCE_PAT, ANTHROPIC_API_KEY. Environment secret listings above match the table exactly. release-attest has an empty secret list and declares no env vars in the workflow (only id-token/attestations permissions) — matches. Org-level secrets (GET /actions/organization-secrets): empty — matches "no org secret visible to this repo."
  • CHROMATIC_PROJECT_TOKEN in .config/tend.yaml secrets.allowed: PASS.config/tend.yaml line 5.
  • workflow-audit.yaml liveness (48h): PASS. Workflow state: active. Last successful run 2026-08-25T07:54:12Z; current time 2026-08-26T05:02:11Z → ~21.1h elapsed, well under 48h.
  • tend-*.yaml unpinned actions: PASS (tag pins accepted for this file class). All 8 tend-*.yaml files use actions/checkout@v7 and max-sixty/tend/claude@0.1.18 — tag-pinned, not @main/unpinned.
  • Agent-managed workflow effective GITHUB_TOKEN permissions beyond the allowed set: FAIL. .github/workflows/tend-mention.yaml's verify job (job body spans lines 92-343) declares no permissions: block, and the file has no workflow-level permissions: block either (confirmed: no permissions: key appears before jobs: in any of the 8 tend-*.yaml files). Effective permissions for verify therefore fall back to the repository default, which is confirmed below to be write — i.e. write access across roughly nine scopes (contents, issues, pull-requests, checks, deployments, packages, security-events, statuses, discussions), far beyond the allowed set (contents: write, pull-requests: write, issues: write, id-token: write, actions: read, or any read scope). Every other job in every tend-*.yaml file, plus workflow-audit.yaml and security-audit.yaml, declares an explicit permissions block within the allowed set (tend-mention.yaml's sibling jobs relay at line 76 — contents: write only — and handle at line 354 — contents: write, pull-requests: write, actions: read, issues: write — are both fine). Mitigating context: verify's single step ("Verify bot engagement") never references the ambient secrets.GITHUB_TOKEN and contains no actions/checkout; the only token it uses is secrets.TEND_BOT_TOKEN explicitly (line 330), an already-disclosed PAT. So today's blast radius from this specific gap is near zero, but the grant itself is exactly the "job that declares nothing textually grants nothing while its token carries nine write scopes" case the document names — it would activate the moment a future step in that job used the default token or added a checkout step, with no diff to catch it.
  • default_workflow_permissions / can_approve_pull_request_reviews: FAIL. GET /repos/diffplug/dormouse/actions/permissions/workflow{"default_workflow_permissions":"write","can_approve_pull_request_reviews":true}. Both clauses violated: the spec requires read and false respectively. This is the repository setting SECURITY.md names as "the backstop for every permission bullet in this document" — and it is also the direct cause of the verify-job finding immediately above (fixing this setting to read would put verify's inherited default back inside the allowed set, since "any read permission" is explicitly permitted).

VS Code Extension Releases

  • vscode-extension-publish environment binding / VSCE_PAT+OVSX_PAT scoping: PASS. publish-vscode job in release.yml (line 306-307) declares environment: name: vscode-extension-publish. Repo-wide grep for VSCE_PAT|OVSX_PAT under .github/workflows/** returns only lines 336 and 348 of release.yml, both inside publish-vscode.
  • No production desktop signing secrets in CI / ephemeral Tauri updater key: PASS. release.yml's build-standalone job has a "Generate ephemeral Tauri updater key" step (tauri signer generate --ci --write-keys ... --force) and no reference anywhere in release.yml to a production signing secret (no APPLE_SIGN_PASS, EV_SIGN_PIN, or production TAURI_SIGNING_PRIVATE_KEY).

Desktop Releases

  • scripts/sign-and-deploy.sh three properties: PASS. Verifies GitHub artifact attestations (gh attestation verify in verify_downloaded_artifact), verifies SHA-256 manifests (check_sha256_manifest / sha256sum -c), and uses PIV-backed Windows signing (jsign --storetype PIV ... --alias AUTHENTICATION in sign_windows).

Reporting a Vulnerability

  • Private vulnerability reporting enabled: PASS. GET /repos/diffplug/dormouse/private-vulnerability-reporting{"enabled":true}.

CI Validation Contract

  • security-audit.yaml gate integrity (dispatch, gh run watch --exit-status, publish-vscode's needs: edge): PASS. All three present in release.yml's security-audit job (gh workflow run "$workflow" ...; gh run watch "$run_id" ... --exit-status) and publish-vscode.needs includes security-audit. security-audit.yaml itself: state: active.
  • Dedicated application-security subagent, not merged with other domains: PASS. .github/audit/application-security.md scope is exactly ## Remote Control; it shares no ## heading with supply-chain.md (## Dependency Supply Chain) or ci-and-secrets.md (the six sections above).
  • Orchestrator non-turn-ending wait: PASS. .github/audit/orchestrator.md step 2 requires blocking in a Bash until loop polling for the three fragment files, explicitly re-issued past the 10-minute Bash cap under a 25-minute deadline, and explicitly forbids ending the turn to wait.
  • .github/audit/ prompt files present / scripts/security-audit-local.sh uses the same files: PASS. All five files (_preamble.md, orchestrator.md, supply-chain.md, ci-and-secrets.md, application-security.md) exist; security-audit-local.sh reads them directly from .github/audit/ (no duplicated copies) and hard-fails if any is missing.
  • .github/audit/ inside workflow-audit.yaml's diff window: PASS. git log --all --since="$SINCE" ... -- .github/workflows/ .config/tend.yaml .github/audit/ (workflow-audit.yaml line 107-108) explicitly includes .github/audit/.
  • Every ## section owned by exactly one domain: PASS. SECURITY.md has 8 ## headings (Remote Control, Dependency Supply Chain, GitHub Actions Policies, Automated Maintainer (tend), VS Code Extension Releases, Desktop Releases, Reporting a Vulnerability, CI Validation Contract). application-security.md claims 1 (Remote Control), supply-chain.md claims 1 (Dependency Supply Chain), ci-and-secrets.md claims the remaining 6. No overlaps, no omissions.
  • Union of qualitative scopes covers every top-level repo path: PASS, with a caveat. Application-security's scope ends "...and the repository root files," which by this document's own convention (every directory reference elsewhere carries a trailing /) most naturally reads as loose files at repo root (AGENTS.md, README.md, package.json, etc.), all of which are covered. Two git-tracked top-level directories are not literally named by any domain's enumeration: .vscode/ (launch.json, tasks.json — editor config) and .impeccable/ (design.json — data for the impeccable.style skill referenced in README.md). Neither is security-sensitive, and node_modules (also top-level) is correctly excluded since it is gitignored, generated, and not part of the tracked repository. See qualitative findings for the ambiguity; not counted as a hard FAIL given the low severity and genuine textual ambiguity, but flagged rather than silently passed.
  • Redact secrets from agent output step coverage: FAIL (one clause). The step (security-audit.yaml lines 157-187) redacts exactly two files: $RUNNER_TEMP/claude-execution-output.json (the transcript) and audit-report.md. It does not redact audit-supply-chain.md, audit-ci-secrets.md, or audit-application.md — the three per-domain fragments the bullet explicitly names as sinks the step must cover. The step does fail closed for the two files it touches (|| { rm -f "$TRANSCRIPT" audit-report.md; exit 1; }). See qualitative findings for the related spec/code drift this connects to.
  • Orchestrator can report PASS while a subagent left no fragment: FAIL. .github/audit/orchestrator.md step 4 instructs the agent itself to check [ -s audit-supply-chain.md ] && [ -s audit-ci-secrets.md ] && [ -s audit-application.md ] before writing PASS, and asserts "The reporting step checks this independently... but do not make it do that work." That is not true of the shipped workflow: the Surface result, file or close issue step in security-audit.yaml (lines 198-323) reads only audit-status.txt (for PASS/FAIL/MISSING) and audit-report.md (for issue body text) — it never inspects audit-supply-chain.md / audit-ci-secrets.md / audit-application.md for existence or non-emptiness. There is therefore no mechanical backstop if the orchestrator LLM writes PASS without having actually verified all three fragments; the entire guarantee rests on the orchestrator prompt being followed correctly, which is exactly the "a prompt is not a control" failure mode this document calls out elsewhere (in the --allowed-tools context, security-audit.yaml lines 82-83).

Qualitative findings

  • BLOCKERdefault_workflow_permissions is write and can_approve_pull_request_reviews is true at the repository level (GET /repos/diffplug/dormouse/actions/permissions/workflow). SECURITY.md is explicit that this is "the backstop for every permission bullet in this document" and that "the repository setting is the only place to fix it durably — a YAML edit does not survive the nightly regen." Today it is misconfigured in the permissive direction on both axes. It has a live consequence right now (see the tend-mention.yaml verify-job finding above) and is a standing risk for every future tend regeneration: the nightly tend regen writes YAML, not repo settings, so any job it produces that omits a permissions: block inherits write-everything rather than read-only until this setting is corrected via the GitHub UI/API (PUT /repos/diffplug/dormouse/actions/permissions/workflow with default_workflow_permissions: read, can_approve_pull_request_reviews: false). This is a straightforward, high-confidence, directly-actionable finding.
  • WARNING — Spec/code drift in the audit's own resilience story. SECURITY.md's CI Validation Contract states: "Fragments are uploaded with the transcript, so an orchestrator that dies mid-merge still ships whatever the domains found." The Archive audit transcript step in security-audit.yaml uploads only ${{ runner.temp }}/claude-execution-output.json; the three fragment files live in the job's checkout working directory and are never independently uploaded, posted, or otherwise published. If the orchestrator agent completes all three subagents but is cut off (turn/cost/time limit) before running its own Step-3 merge, the fragments are lost when the ephemeral runner is torn down — the reporting step then sees an empty audit-report.md, classifies the run MISSING/INCONCLUSIVE, and the human-facing issue explicitly states "This is not a security finding," even though real subagent findings (potentially BLOCKERs) may exist nowhere else. Whether those findings survive at all then depends on incidental capture inside the SDK transcript JSON, which is not what the document claims. Recommend either adding an upload-artifact step for audit-*.md alongside the transcript, or correcting the prose to stop claiming fragments are uploaded.
  • INFO.vscode/ and .impeccable/ are git-tracked top-level directories not literally named in any domain's qualitative-scope enumeration (see the FAIL IF note above). Low risk (editor config, design-token JSON) — recommend tightening the enumeration text to remove the ambiguity next time SECURITY.md's CI Validation Contract section is touched, the same way the two previously-orphaned larger gaps (canopy/, docs/, etc.) were closed.
  • No other weakening found in the audit's own machinery: the AUDIT_PAT pre-check, the security-audit-failure issue open/close lifecycle, the PASS/FAIL/MISSING three-way distinction, and the --disallowed-tools "Workflow" / allowed Task/Agent split all match the document's description and are implemented as claimed.

PASS/FAIL verdict: FAILdefault_workflow_permissions/can_approve_pull_request_reviews are both misconfigured (BLOCKER), with a live downstream instance in tend-mention.yaml's verify job, plus two further FAIL IF violations in the audit's own redaction coverage and fragment-existence backstop.

Application security

Application Security Audit

Scope: ## Remote Control (mechanical FAIL IF checks), plus a qualitative
pass over Remote Control and the rest of the repository (lib/, server/,
server-lib-common/, standalone/, vscode-ext/, dor/, dor-lib-common/,
canopy/, deploy/, docs/, and the repository root files). No GitHub API
access and no AUDIT_PAT were used — this is a pure code-and-specs review.

FAIL IF results

Trust boundary

  1. PASSauthorizeConnection (server-lib-common/src/security/connection.ts:96-163) evaluates the passkey assertion (verifyPasskeyAssertion), the device signature (verifyDeviceChallengeSignature), and the ACL (host.acl.authorize) independently, never short-circuiting, and returns allowed only when all pass. host.policy (ConnectionPolicy, the Host's own origin/rpId) is spread before the request's challenge field, so nothing on the wire can override the freshness binding. No Server-supplied claim is trusted: the Server's own checkConnect2 (server/src/handshake.ts:167-225) is a courtesy pre-filter, and RemoteHost.#onConnect2 (lib/src/remote/host/remote-host.ts:411-459) always calls the real authorizeConnection regardless of what the relay forwarded.
  2. PASSHostAcl.approve (server-lib-common/src/security/acl.ts:124-136) has exactly one caller in non-test code: PairingCeremony.approve (server-lib-common/src/security/pairing.ts:232-243). Grepped \.approve\( across lib/, server/, server-lib-common/ (excluding tests): the only three hits are remote-host.ts:376 (this.#ceremony.approve(...)), service.ts:250 (this.#pendingPairing(...).approve(...), a different object's method), and pairing.ts:235 (this.#acl.approve(...)). An approval is matched against the immutable pairingId, never a mutable clientId alone: RemoteHostService.#pendingPairing (lib/src/host/remote/service.ts:259-266) throws unless pending.pairingId === pairingId, and RemoteHost.#approvePairing (remote-host.ts:367-390) re-checks state.pending.pairingId !== pairingId independently.
  3. PASSRemoteHost.#onPair (remote-host.ts:331-364) runs isPairingRequest(incoming) itself before any use, and rejects with pair-result approved:false, error:'malformed-request' on failure — it does not rely on the Server's identical check in Handshake.checkPair (handshake.ts:128-153). requestedLabel (and accountId) are reduced with boundedPairingAccount/boundedPairingLabel (remote-host.ts:348-352) before being stored in the ticket, so every downstream consumer (queue projection, modal, ACL record) sees the bounded value.
  4. PASSRemoteHostResult, HostStatusEvent, PairingQueueEvent, RemoteHostConsoleStatus (lib/src/host/remote/service-protocol.ts:55-169) declare no hostToken field anywhere. EnrollResult is { hostId, serverUrl } only (line 148-151). Grep for hostToken across lib/, vscode-ext/, standalone/, server/ shows every occurrence is either a type declaration, an inbound field (EnrollParams/AdoptParams.enrollment, by design), a Bearer-header use in push-delivery.ts:71, or a comment.
  5. PASSRemoteHostService.#adopt (service.ts:287-320) checks, in order: !existing (the store's own loadEnrollment() returned null), this.#allowed(enrollment.serverUrl) (the same originAllowedByConnectSrc gate as #enroll), and filterAclRecords(enrollment.hostId, params.aclRecords ?? []) which applies isHostAclRecord (full shape guard, server-lib-common/src/security/acl.ts:44-58) and record.hostId === hostId (lib/src/remote/host/acl.ts:34-41) before any record is persisted.
  6. PASS — Every security primitive (HostAcl, HostChallengeIssuer, PairingCeremony, authorizeConnection, verifyPasskeyAssertion, device-key signing/verification, boundedPushText/boundedPairingLabel) is imported from server-lib-common/src/security/ by server/src/*.ts, lib/src/remote/host/remote-host.ts, and the Pocket client paths — confirmed by import statements in connection.ts, handshake.ts, remote-host.ts, app.ts. No parallel/forked implementation of any of these was found.

Where a Host may reach a relay server

  1. PASSDEFAULT_REMOTE_CONNECT_SRC is 'https://*.dormouse.sh wss://*.dormouse.sh' verbatim in both scripts/csp-defaults.mjs:18 and lib/src/host/remote/connect-src.ts:22. CONNECT_SRC_SOURCE_PATTERN is the identical regex literal /^((?:https?|wss?):)\/\/([^/:]+)(?::(\*|\d+))?$/i in both files (csp-defaults.mjs:26, connect-src.ts:73).
  2. PASSassertConnectSrcBaked is called by standalone/scripts/build-sidecar-proxy.mjs:48 and by vscode-ext/scripts/esbuild.mjs in both branches: the watch branch calls it once after ctx.rebuild() and before entering ctx.watch() (line 63), and the non-watch branch calls it after esbuild.build (line 67). resolveRemoteConnectSrc (csp-defaults.mjs:48-62) throws on any override source that fails isSupportedSource, which uses the same CONNECT_SRC_SOURCE_PATTERN.
  3. PASSoriginAllowedByConnectSrc gates RemoteHostService.#allowed (service.ts:324-330), which is called from #start (line 112, Host start-up), #enroll (line 179), and #adopt (line 300) — all three paths. originAllowedByConnectSrc (connect-src.ts:118-140) returns false on a URL constructor throw, an empty hostname, an unmatched scheme group, or a source string that fails to parse — i.e. it fails closed on every unparseable input.
  4. PASSperformEnrollment (lib/src/remote/host/enrollment.ts:80-95) sets redirect: 'error'. hostFetch in push-delivery.ts:56-74 also sets redirect: 'error' for both the pushDevices read and the sendPush write.

Credentials at rest

  1. PASSJsonFileStore.writeAtomic (server/src/state.ts:92-97) calls mkdir(stateDir, { recursive: true, mode: 0o700 }) and writeFile(tmp, ..., { mode: 0o600 }) before renaming. Grepped server/src/ for writeFile(, appendFile(, createWriteStream(: the only writer of any state-directory file is this one writeAtomic method (used by AccountStore, HostStore, PushSubscriptionStore, VapidStore — no other write path exists).
  2. PASSFileHostStateStore.#write (lib/src/host/remote/host-state-store.ts:176-202) does mkdir(dir, {mode:0o700}), best-effort chmod(dir, 0o700) on non-Windows, and writeFile(tmp, ..., {mode:0o600}) before rename. VsCodeHostStateStore.saveEnrollment/clearEnrollment (vscode-ext/src/remote-host-store.ts:84-96) use context.secrets.store/.delete (VS Code SecretStorage, i.e. the OS keychain) for the enrollment; loadAcl/saveAcl (lines 111-128) use context.globalState, which is the documented, deliberate placement (ACL is public-key data).
  3. PASSdeploy/local/install-macos.sh:434-444 generates the setup password via /usr/bin/xxd -p -l 32 -c 32 /dev/urandom (or openssl rand -hex 32 as fallback) — 32 random bytes, and the length guard is [ ${#SETUP_PASSWORD} -ge 64 ], i.e. 64 hex characters, matching the 32-byte claim (not a -ge 32 regression).
  4. PASSinstall-macos.sh:446 sets umask 077 immediately before the heredoc that writes config/server.env, and line 461 additionally does chmod 0600 "$ENV_FILE". Line 367 does chmod 0700 "$CONFIG_DIR" "$STATE_DIR".
  5. PASS — When $ENV_FILE already exists, the installer's only action on it is chmod 0600 (line 466-467) — no write of its contents, i.e. byte-for-byte preservation. The setup password is printed nowhere else in the script; the only print path is cmd_show_password in the generated bin/manage (lines 810-823), gated by an explicit y/N confirmation and a TTY check.
  6. PASSbin/manage's cmd_verify (install-macos.sh:669-673) does grep -q "DORMOUSE_SETUP_PASSWORD" "$PLIST" and calls fail if it matches, pass otherwise.

The setup password

  1. PASSserver/src/app.ts:230-235 precomputes expectedPasswordHash = sha256(config.setupPassword) and compares with timingSafeEqual(sha256(provided), expectedPasswordHash) — both operands are fixed-length SHA-256 digests, so the compare never branches on input length. readPasswordGated (lines 240-249) calls delay(PASSWORD_FAILURE_DELAY_MS) (250 ms, line 149) on every failure before responding.
  2. PASS — The only cors(...) registration in server/src/app.ts is app.use('/api/*', cors({ origin: '*', allowHeaders: [...] })) (line 260) — the /ws/host and /ws/client upgrade routes are registered separately and are not under this middleware. No route in app.ts sets or reads a cookie; every credential is a header (Authorization: Bearer) or a JSON body field.

Network posture

  1. PASSinstall-macos.sh:473-474 dies with an explicit message unless config/server.env contains DORMOUSE_BIND_HOST=127.0.0.1 exactly. bin/manage's cmd_verify (lines 701-711) probes http://$tsip:$PORT/api/hello over the node's Tailscale IPv4 and fails if it answers.
  2. PASSserver/src/config.ts:64: const bindHost = env.DORMOUSE_BIND_HOST?.trim() || undefined; — unset or blank both collapse to undefined. server/test/bind-host.test.mjs spawns dist/index.js as a real child process (spawn(process.execPath, [ENTRYPOINT], ...)) rather than calling library functions directly.
  3. PASSinstall-macos.sh:241-257: if an existing config/server.env's DORMOUSE_ORIGIN differs from the freshly-derived one, the script prints a detailed warning and dies, refusing to write.
  4. PASScmd_verify's Funnel check (install-macos.sh:736-743) greps ts serve status + ts funnel status output for funnel on (case-insensitive) across both, and fails if found.

What crosses the boundary

  1. PASSisPublicHttpsPushEndpoint (server/src/push-endpoint.ts:92-103) is called at registration (app.ts:500) and rejects non-https:, credentialed URLs, localhost/.localhost, and literal private/reserved IPs. createPublicPushAgent/createPublicLookup (push-endpoint.ts:128-156) are wired into delivery (push.ts:267, createWebPushSender) and reject a hostname if any DNS answer is blocked (push-endpoint.ts:139-143, addresses.find(({address}) => !isPublicNetworkAddress(address))).
  2. PASSapp.ts:602 (pushSend route): currentPushSubscriptionsForHost(c.get('host').hostId) takes the Host id from the authenticated bearer token (requireHost middleware), never from the request body. Line 596-597 rejects when devicePublicKeys is absent, non-array, empty, or contains a non-string. /api/push/subscriptions (line 542-569) and /api/push/devices (line 571-582) take no devicePublicKey input parameter at all — both are scoped purely by the presented credential.
  3. PASS — Host side: toPushText/push-delivery.ts:43-45 calls the shared boundedPushText from server-lib-common. Server side: app.ts:610-619 calls the same boundedPushText on body.title/body.body before building the payload. Both import from server-lib-common/src/security/push.ts:143-161, the single implementation.
  4. PASSRelayHub.onClientFrame (server/src/relay.ts:221-299) only forwards pair/connect/connect2/msg after the HandshakeGate checks (for the first three) or the established flag (for msg); no other frame type is forwarded pre-authorization, and unknown types get an error reply, not a forward. onHostFrame's msg case (lines 168-173) checks client.established && client.hostId === host.hostId before relaying to the client, and #isCurrentClientHost (lines 321-327) re-validates the client/host binding after every async gate check in onClientFrame.

Cloud-hosted mode

  1. PASS — Grepped every accountId assignment in server/src/*.ts: all three (app.ts:271,313,378) use SELFHOST_ACCOUNT_ID (= 'owner', server-lib-common/src/remote/wire.ts:67). No self-serve signup route exists — every account-creating path (/api/setup/begin, /api/setup/finish) is gated by readPasswordGated (the shared setup password).

Qualitative findings

BLOCKER: none found.

  • INFO — Remote Control code matches its specs (docs/specs/remote-security-model.md, docs/specs/server.md) essentially line-for-line everywhere checked: the pairing ceremony, connection establishment, challenge domain separation (DEVICE_AUTH_DOMAIN vs PUSH_SUBSCRIBE_DOMAIN in server-lib-common/src/security/deviceKey.ts:22 and push.ts:36, preventing a signature captured in one protocol context from being replayed in the other), the relay's dumb-pipe/handshake-gate split, and the credentials-at-rest table. No spec drift was found in this domain. lib/pocket/public/sw.js's hand-copied boundedPushText mirror (it cannot import the shared module) is byte-identical in its regex character classes and code-point-safe truncation logic to server-lib-common/src/security/push.ts — no drift.
  • INFO — Challenges are single-use, TTL-bounded (256-bit random, DEFAULT_CHALLENGE_TTL_MS = 2 min, HostChallengeIssuer.consume deletes on first redemption whether or not it later fails) and domain-separated by both purpose (DEVICE_AUTH_DOMAIN/PUSH_SUBSCRIBE_DOMAIN) and by binding the exact hostId/challenge/devicePublicKey into the signed payload (deviceAuthPayload, pushSubscribePayload). verifyPasskeyAssertion (server-lib-common/src/security/passkey.ts) does not enforce authenticator signCount monotonicity, which would normally guard against a cloned authenticator — but this is standard for platform/passkey-only deployments (many passkey providers always report signCount: 0), is not claimed anywhere in the spec, and does not weaken any property SECURITY.md asserts; noted for completeness only, not a finding.
  • INFO — Traced every writer that can reach a Host's ACL: HostAcl.approve is reachable only through PairingCeremony.approve, which is reachable only through RemoteHost.#approvePairing, which is reachable only from a RemoteHostService.#approve call gated on the immutable pairingId matching the currently-pending ticket for that clientId. adopt is the one non-ceremony writer and is bounded exactly as SECURITY.md describes (FAIL IF 5, above) — it cannot touch a machine that already has an enrollment, and every record it carries is independently shape- and hostId-validated. Neither a compromised webview nor a compromised Server can reach HostAcl.approve directly: the webview only ever sends {clientId, pairingId, label?} over the bridge, and the Server only ever relays a pair frame that both sides independently shape-validate before any UI or storage effect.
  • INFO — No credential leak found: grepped for hostToken/setup-password/VAPID-key logging across server/src/, lib/src/remote/, lib/src/host/remote/, and vscode-ext/src/remote-host*.ts — no console.* call includes any of these identifiers. dor-control-server.js's token (a different credential, the dor CLI control-socket secret) is never sent on the wire either — the handshake is a mutual HMAC proof-of-possession with timingSafeEqual comparison, mirroring the pattern in server/src/state.ts.
  • WARNING — Outside Remote Control, in the local browser-automation feature: lib/src/host/agent-browser-host.ts's killDaemon(session) (lines 209-216) builds path.join(agentBrowserStateDir(), \${session}.pid`)and passes the parsed content toprocess.kill(), with no character sanitization on session— only a non-empty-string check. Its siblingscreenshotPath(session, ext)two lines away (line 263) explicitly sanitizes withsession.replace(/[^A-Za-z0-9._-]/g, '_'), and the code comment there is clearly aware of the risk. The sessionvalue reachingkillDaemon(viapopOut/popIn, called from AgentBrowserPanel.tsxwith the session name a browser surface was opened with) can originate fromdor ab --session (dor/src/commands/agent-browser.ts:191), where **--sessionis deliberately exempted from theKEY_PATTERNcharset check that--keygets** (line 187-189) — the code comments say--sessionis for "a raw agent-browser session name," but that raw string still flows unsanitized into apath.joinused for both a file read and aprocess.kill()target. Practical impact is bounded by the terminal's own trust model: reaching this path already requires the ability to run arbitrary commands inside a Dormouse-hosted terminal, which is equivalent to the local user's own shell access — so the traversal cannot reach anything the same local user's ordinary shell commands could not already read or kill. It is nonetheless a real inconsistency against the pattern used one function away, worth fixing for defense-in-depth by applying the sameKEY_PATTERN-style sanitization (or reusing screenshotPath's replace) before killDaemon's path.join`. Not a Remote Control finding — surfaced here because the qualitative sweep is scoped to the whole repository per the audit's domain split.
  • INFO — Command construction is sound everywhere checked: dor-lib-common/src/spawn.ts's spawnAndCapture uses cross-spawn with an argv array (spawn(binary, args, {...}), no shell: true), and a repository-wide grep for execSync/child_process.exec(/shell: true across lib/, server/, standalone/, vscode-ext/, dor/, dor-lib-common/, canopy/ returned no hits outside test files.
  • INFO — Tauri IPC (standalone/src-tauri/src/lib.rs): session-persistence commands (load_session/save_session/clear_session) key their filename on window.label(), sanitized through session_file_name() (line 754) to [A-Za-z0-9_-] only, with traversal defused and covered by an existing test. pty_spawn forwards webview-supplied cwd/shell/args to the sidecar unvalidated, but this is the terminal's core function (spawning a shell wherever asked) and the webview here is first-party app content, not remote or workspace-supplied content — consistent with the product's stated threat model (SECURITY.md's own framing: an authorized remote Client is deliberately equivalent to a person at the keyboard; the local webview is inside the trust boundary by design).
  • INFO — VS Code webview boundary: IframePanel.tsx's handling of cross-origin content (the agent-browser/iframe proxy) validates e.origin === proxyOrigin before acting on any postMessage, and forwards only three narrow non-command fields into UI state — never into vscode.postMessage, so an embedded iframe cannot forge a pty:spawn or agentBrowser:* command. lib/src/lib/vscode-message-token.ts gates extension→webview messages with a per-boot random token, defeating spoofing from a same-window script.
  • INFO — Persisted state read back from disk is schema-validated before use everywhere sampled: lib/src/lib/session-restore.ts runs shape guards (isPersistedSessionV3 etc.) and falls back to null/empty rather than trusting a malformed blob; local-json-store.ts's loadJson wraps JSON.parse in try/catch with an optional validator. Server-side, HostStore.list/PushSubscriptionStore.list (server/src/state.ts) drop malformed rows rather than propagate them, matching the documented "hand-editing is the revocation mechanism" design.
  • INFO — Revocation-has-no-mechanism and no-audit-trail are accurately described as accepted gaps, not re-reported: confirmed HostAcl.revokeDevice/revokePasskey (server-lib-common/src/security/acl.ts:196-203) have no callers anywhere in non-test code.

Summary

All 27 mechanical FAIL IF checks in ## Remote Control verified PASS with
direct code evidence. The qualitative pass over Remote Control found no
BLOCKER or WARNING — the implementation matches its specs closely and every
adversarial question in the audit brief (webview/Server write access to the
ACL, credential exposure, challenge domain separation, untrusted-input
handling) resolves cleanly. One WARNING was found in the broader repository
sweep (agent-browser session-name path traversal in killDaemon), bounded in
practice by the terminal's own trust model but worth a small fix for
consistency with its sibling function.

Summary

Overall verdict: FAIL

The nightly audit fans out to three independent domains. Supply chain and
application security both passed their mechanical FAIL IF checks with no
BLOCKER-level qualitative findings. CI and secrets failed: the repository's
effective GitHub Actions default permissions are write with
can_approve_pull_request_reviews: true, which directly violates the
Automated Maintainer FAIL IF backstop in SECURITY.md, and this is not just
a theoretical misconfiguration — tend-mention.yaml's verify job declares
no permissions: block of its own (and there is no workflow-level default
either), so it inherits the over-broad repo-wide grant. That domain also
surfaced two issues in the audit pipeline's own machinery (secret redaction
doesn't cover the per-domain fragment files, and there's no mechanical
backstop for a missing-fragment PASS) plus a spec/code drift in SECURITY.md's
claim that fragments are uploaded with the transcript. None of the three
domains reported UNVERIFIABLE checks standing in for work that could have
been done.

  • Supply chain: PASS — all 8 FAIL IF checks passed with evidence; only
    INFO-level qualitative notes (one dead allowBuilds entry).
  • CI and secrets: FAILdefault_workflow_permissions/
    can_approve_pull_request_reviews misconfigured (BLOCKER) with a live
    downstream instance in tend-mention.yaml; plus two further FAIL IF
    violations in the audit's own redaction coverage and fragment-existence
    backstop.
  • Application security: PASS — all 27 FAIL IF checks in ## Remote Control passed with evidence; one WARNING outside that scope (unsanitized
    session-name path construction in killDaemon(), bounded by the terminal's
    existing local-shell trust model).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions