You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PASS — node 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.
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.
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.
PASS — Root package.json line 6-11: devEngines.runtime.version = "24.18.0", an exact MAJOR.MINOR.PATCH (not a bare major).
PASS — standalone/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.04→x86_64-unknown-linux-gnu, macos-latest→aarch64-apple-darwin, windows-latest→x86_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.
PASS — release.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.node → devEngines.runtime → engines.node) is guaranteed to read the pinned devEngines.runtime.version.
PASS — pnpm-workspace.yaml contains minimumReleaseAge: 1440 (top-level, last line).
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".
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.
INFO — pnpm-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 productiondependencies 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.
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.yamlsecrets.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 nopermissions: 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.
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
BLOCKER — default_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.yamlverify-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: FAIL — default_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
PASS — authorizeConnection (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.
PASS — HostAcl.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.
PASS — RemoteHost.#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.
PASS — RemoteHostResult, 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.
PASS — RemoteHostService.#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) andrecord.hostId === hostId (lib/src/remote/host/acl.ts:34-41) before any record is persisted.
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
PASS — DEFAULT_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).
PASS — assertConnectSrcBaked 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.
PASS — originAllowedByConnectSrc 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.
PASS — performEnrollment (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
PASS — JsonFileStore.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).
PASS — FileHostStateStore.#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).
PASS — deploy/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).
PASS — install-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".
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.
PASS — bin/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
PASS — server/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.
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
PASS — install-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.
PASS — server/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.
PASS — install-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.
PASS — cmd_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
PASS — isPublicHttpsPushEndpoint (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))).
PASS — app.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.
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.
PASS — RelayHub.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
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: FAIL — default_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).
Audit failed at 2026-08-26T05:06Z. Run · Transcript
Security audit
Supply chain
Supply Chain Audit
Scope:
## Dependency Supply Chainonly.FAIL IF results
PASS —
node website/scripts/generate-deps.jsproduces no diff. Workspace was already installed (pnpm install --frozen-lockfilealso 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) andgit status --porcelain website/src/data//git diff --stat website/src/data/were both empty before and after. Tree left clean.PASS —
.github/workflows/ci.ymllines 33-45 (Dependency disclosure is currentstep) runsnode website/scripts/generate-deps.jsthengit diff --quiet -- website/src/data/, printing::error::andexit 1on a diff. Runs afterpnpm install --frozen-lockfile(line 24), consistent with the generator's real-node_modulesrequirement.PASS — Derived from
pnpm-workspace.yamlindependently of the spec's enumeration. The 10 workspace packages resolve to package names as follows:productDependencyFiltersinwebsite/scripts/generate-deps.js):dor(dirdor),dormouse(dirvscode-ext),dormouse-standalone(dirstandalone),dormouse-lib(dirlib),dormouse-sidecar(dirstandalone/sidecar),server(dirserver).dor-lib-common(a realdependenciesentry ofdorandlib),server-lib-common(a realdependenciesentry ofserverandlib) — confirmed by readingdor/package.json,server/package.json,lib/package.json.canopy(Storybook-only; verified@diffplug/xterm-addon-webgl-sdf's non-registry GitHub-tarball resolution inpnpm-lock.yamlline 649 is referenced only fromcanopy/package.json, nowhere else),website→ package namedormouse-website(runs in a visitor's browser, not installed).All 10 accounted for; none missing from both roots and exclusions.
PASS — Root
package.jsonline 6-11:devEngines.runtime.version="24.18.0", an exactMAJOR.MINOR.PATCH(not a bare major).PASS —
standalone/src-tauri/build.rs:verify_node_version(line 191) runsnode --versionon the binary about to be bundled and errors on mismatch against thedevEngines.runtime.versionpin (read viaread_pinned_node_version, which also independently validates the exact-version format, lines 176-186). The one skip is exactly the documented one:if host != targetemits acargo:warningand returnsOk(())(lines 197-205), justified by inability to execute a foreign-arch binary. Checkedrelease.yml'sbuild-standalonematrix (lines 27-36):ubuntu-22.04→x86_64-unknown-linux-gnu,macos-latest→aarch64-apple-darwin,windows-latest→x86_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.PASS —
release.yml'sbuild-standalonejob installs Node viaactions/setup-nodewithnode-version-file: package.json(lines 46-48). Rootpackage.jsonhas novoltaorengineskey (grepfor both returned nothing), sosetup-node's precedence resolution (volta.node→devEngines.runtime→engines.node) is guaranteed to read the pinneddevEngines.runtime.version.PASS —
pnpm-workspace.yamlcontainsminimumReleaseAge: 1440(top-level, last line).PASS —
.github/renovate.json:enabledManagers=["github-actions", "npm", "cargo"](includes bothnpmandcargo).minimumReleaseAgepackage rules present formatchManagers: ["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 ofgit 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 onlypnpm-lock.yaml(lockfile-only version bumps within existing ranges, no new dependency); chore(deps): update pnpm to v11.22.0 #443 touchespackage.json(+1/-1, thepackageManager/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-sdfresolved from a GitHub Releases tarball URL (pnpm-lock.yamlline 649-650), matching AGENTS.md's description of thesdf-branch xterm.js fork. Confirmed viagrepthat it is declared only incanopy/package.json, which is a deliberately-excluded, non-shipped Storybook lab per the spec's own text. Not a disclosure gap.INFO —
pnpm-workspace.yaml'sallowBuildspermits install/build scripts for@swc/core,esbuild,node-pty,sharpand explicitly denieskeytarand@vscode/vsce-sign. Traced each withpnpm why:node-ptyis a genuine productiondependenciesentry ofvscode-extandstandalone/sidecar(native PTY bindings — required, expected, and already disclosed: confirmed present in the generatedwebsite/src/data/dependencies-npm.json).esbuildis a devDependency everywhere it appears (vscode-ext,dor,standalone) — a build tool, not shipped.sharpis a transitive production dependency ofdormouse-website'sascii-splash— butwebsiteis 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/coreappears inallowBuildsbut 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.keytarand@vscode/vsce-signare transitive devDependencies of@vscode/vsce/ovsx(extension packaging tooling only) and are correctly denied build-script execution.pnpm install --frozen-lockfileafter all this and it reported no pending/ignored build-script warnings, confirming no package with an install script is un-triaged byallowBuilds.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_targetoutsidetend-*.yaml: PASS.grep -rl pull_request_target .github/workflowsreturns onlytend-review.yaml.ci.ymlandchromatic.ymleach declare workflow-levelpermissions: contents: readwith no job overriding it (verified full file contents).release.ymldeclares workflow-levelcontents: read;build-standalone/build-vscodejobs declarecontents: read, id-token: write, attestations: write; thesecurity-auditjob declarescontents: read, actions: write(the explicitly named exception);publish-vscodehas no job-level block and inherits the workflow-levelcontents: read. No non-agent-managed job exceeds the allowed set.Automated Maintainer (tend)
workflow-audit.yamllower bound: PASS.SINCE=$(gh api ".../workflows/workflow-audit.yaml/runs?status=success&per_page=1" --jq '.workflow_runs[0].created_at')— server-setcreated_at, not pusher-controlled (.github/workflows/workflow-audit.yamllines 79-84).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-botrole: PASS.GET /collaborators/dormouse-bot/permission→{"permission":"write","role_name":"write", ...,"permissions":{"admin":false,"maintain":false,"push":true,...}}. Neithermaintainnoradmin.release-attest: branch policyv*(tag) only. Tag creation/update is admin-only via "Tag operations". No secrets.security-audit: branch policiesmain(branch) +v*(tag).mainupdates are admin-only via "Merge access";v*via "Tag operations". Secrets:AUDIT_PAT,CLAUDE_CODE_OAUTH_TOKEN.tend: branch policymainonly, admin-gated via "Merge access". Secrets:CLAUDE_CODE_OAUTH_TOKEN,TEND_BOT_TOKEN.vscode-extension-publish: branch policyv*(tag) only, admin-gated via "Tag operations", plus arequired_reviewersprotection rule (nedtwigg,edgartwigg,prevent_self_review: true). Secrets:OVSX_PAT,VSCE_PAT.GET /actions/secrets): onlyCHROMATIC_PROJECT_TOKEN— matches "absent at repo level" forAUDIT_PAT,TEND_BOT_TOKEN,CLAUDE_CODE_OAUTH_TOKEN,OVSX_PAT,VSCE_PAT,ANTHROPIC_API_KEY. Environment secret listings above match the table exactly.release-attesthas an empty secret list and declares no env vars in the workflow (onlyid-token/attestationspermissions) — matches. Org-level secrets (GET /actions/organization-secrets): empty — matches "no org secret visible to this repo."CHROMATIC_PROJECT_TOKENin.config/tend.yamlsecrets.allowed: PASS —.config/tend.yamlline 5.workflow-audit.yamlliveness (48h): PASS. Workflowstate: active. Last successful run2026-08-25T07:54:12Z; current time2026-08-26T05:02:11Z→ ~21.1h elapsed, well under 48h.tend-*.yamlunpinned actions: PASS (tag pins accepted for this file class). All 8tend-*.yamlfiles useactions/checkout@v7andmax-sixty/tend/claude@0.1.18— tag-pinned, not@main/unpinned.GITHUB_TOKENpermissions beyond the allowed set: FAIL..github/workflows/tend-mention.yaml'sverifyjob (job body spans lines 92-343) declares nopermissions:block, and the file has no workflow-levelpermissions:block either (confirmed: nopermissions:key appears beforejobs:in any of the 8tend-*.yamlfiles). Effective permissions forverifytherefore fall back to the repository default, which is confirmed below to bewrite— 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 everytend-*.yamlfile, plusworkflow-audit.yamlandsecurity-audit.yaml, declares an explicit permissions block within the allowed set (tend-mention.yaml's sibling jobsrelayat line 76 —contents: writeonly — andhandleat 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 ambientsecrets.GITHUB_TOKENand contains noactions/checkout; the only token it uses issecrets.TEND_BOT_TOKENexplicitly (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 requiresreadandfalserespectively. 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 theverify-job finding immediately above (fixing this setting toreadwould putverify's inherited default back inside the allowed set, since "any read permission" is explicitly permitted).VS Code Extension Releases
vscode-extension-publishenvironment binding /VSCE_PAT+OVSX_PATscoping: PASS.publish-vscodejob inrelease.yml(line 306-307) declaresenvironment: name: vscode-extension-publish. Repo-wide grep forVSCE_PAT|OVSX_PATunder.github/workflows/**returns only lines 336 and 348 ofrelease.yml, both insidepublish-vscode.release.yml'sbuild-standalonejob has a "Generate ephemeral Tauri updater key" step (tauri signer generate --ci --write-keys ... --force) and no reference anywhere inrelease.ymlto a production signing secret (noAPPLE_SIGN_PASS,EV_SIGN_PIN, or productionTAURI_SIGNING_PRIVATE_KEY).Desktop Releases
scripts/sign-and-deploy.shthree properties: PASS. Verifies GitHub artifact attestations (gh attestation verifyinverify_downloaded_artifact), verifies SHA-256 manifests (check_sha256_manifest/sha256sum -c), and uses PIV-backed Windows signing (jsign --storetype PIV ... --alias AUTHENTICATIONinsign_windows).Reporting a Vulnerability
GET /repos/diffplug/dormouse/private-vulnerability-reporting→{"enabled":true}.CI Validation Contract
security-audit.yamlgate integrity (dispatch,gh run watch --exit-status,publish-vscode'sneeds:edge): PASS. All three present inrelease.yml'ssecurity-auditjob (gh workflow run "$workflow" ...;gh run watch "$run_id" ... --exit-status) andpublish-vscode.needsincludessecurity-audit.security-audit.yamlitself:state: active..github/audit/application-security.mdscope is exactly## Remote Control; it shares no##heading withsupply-chain.md(## Dependency Supply Chain) orci-and-secrets.md(the six sections above)..github/audit/orchestrator.mdstep 2 requires blocking in a Bashuntilloop 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.shuses 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.shreads them directly from.github/audit/(no duplicated copies) and hard-fails if any is missing..github/audit/insideworkflow-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/.##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.mdclaims 1 (Remote Control),supply-chain.mdclaims 1 (Dependency Supply Chain),ci-and-secrets.mdclaims the remaining 6. No overlaps, no omissions./) 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 theimpeccable.styleskill referenced inREADME.md). Neither is security-sensitive, andnode_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 outputstep coverage: FAIL (one clause). The step (security-audit.yamllines 157-187) redacts exactly two files:$RUNNER_TEMP/claude-execution-output.json(the transcript) andaudit-report.md. It does not redactaudit-supply-chain.md,audit-ci-secrets.md, oraudit-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..github/audit/orchestrator.mdstep 4 instructs the agent itself to check[ -s audit-supply-chain.md ] && [ -s audit-ci-secrets.md ] && [ -s audit-application.md ]before writingPASS, and asserts "The reporting step checks this independently... but do not make it do that work." That is not true of the shipped workflow: theSurface result, file or close issuestep insecurity-audit.yaml(lines 198-323) reads onlyaudit-status.txt(for PASS/FAIL/MISSING) andaudit-report.md(for issue body text) — it never inspectsaudit-supply-chain.md/audit-ci-secrets.md/audit-application.mdfor existence or non-emptiness. There is therefore no mechanical backstop if the orchestrator LLM writesPASSwithout 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-toolscontext,security-audit.yamllines 82-83).Qualitative findings
default_workflow_permissionsiswriteandcan_approve_pull_request_reviewsistrueat 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 thetend-mention.yamlverify-job finding above) and is a standing risk for every futuretendregeneration: the nightly tend regen writes YAML, not repo settings, so any job it produces that omits apermissions:block inherits write-everything rather than read-only until this setting is corrected via the GitHub UI/API (PUT /repos/diffplug/dormouse/actions/permissions/workflowwithdefault_workflow_permissions: read, can_approve_pull_request_reviews: false). This is a straightforward, high-confidence, directly-actionable finding.Archive audit transcriptstep insecurity-audit.yamluploads 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 emptyaudit-report.md, classifies the runMISSING/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 anupload-artifactstep foraudit-*.mdalongside the transcript, or correcting the prose to stop claiming fragments are uploaded..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.security-audit-failureissue open/close lifecycle, the PASS/FAIL/MISSING three-way distinction, and the--disallowed-tools "Workflow"/ allowedTask/Agentsplit all match the document's description and are implemented as claimed.PASS/FAIL verdict: FAIL —
default_workflow_permissions/can_approve_pull_request_reviewsare both misconfigured (BLOCKER), with a live downstream instance intend-mention.yaml'sverifyjob, 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(mechanicalFAIL IFchecks), plus a qualitativepass 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 APIaccess and no
AUDIT_PATwere used — this is a pure code-and-specs review.FAIL IF results
Trust boundary
authorizeConnection(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 returnsallowedonly when all pass.host.policy(ConnectionPolicy, the Host's own origin/rpId) is spread before the request'schallengefield, so nothing on the wire can override the freshness binding. No Server-supplied claim is trusted: the Server's owncheckConnect2(server/src/handshake.ts:167-225) is a courtesy pre-filter, andRemoteHost.#onConnect2(lib/src/remote/host/remote-host.ts:411-459) always calls the realauthorizeConnectionregardless of what the relay forwarded.HostAcl.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\(acrosslib/,server/,server-lib-common/(excluding tests): the only three hits areremote-host.ts:376(this.#ceremony.approve(...)),service.ts:250(this.#pendingPairing(...).approve(...), a different object's method), andpairing.ts:235(this.#acl.approve(...)). An approval is matched against the immutablepairingId, never a mutableclientIdalone:RemoteHostService.#pendingPairing(lib/src/host/remote/service.ts:259-266) throws unlesspending.pairingId === pairingId, andRemoteHost.#approvePairing(remote-host.ts:367-390) re-checksstate.pending.pairingId !== pairingIdindependently.RemoteHost.#onPair(remote-host.ts:331-364) runsisPairingRequest(incoming)itself before any use, and rejects withpair-result approved:false, error:'malformed-request'on failure — it does not rely on the Server's identical check inHandshake.checkPair(handshake.ts:128-153).requestedLabel(andaccountId) are reduced withboundedPairingAccount/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.RemoteHostResult,HostStatusEvent,PairingQueueEvent,RemoteHostConsoleStatus(lib/src/host/remote/service-protocol.ts:55-169) declare nohostTokenfield anywhere.EnrollResultis{ hostId, serverUrl }only (line 148-151). Grep forhostTokenacrosslib/,vscode-ext/,standalone/,server/shows every occurrence is either a type declaration, an inbound field (EnrollParams/AdoptParams.enrollment, by design), a Bearer-header use inpush-delivery.ts:71, or a comment.RemoteHostService.#adopt(service.ts:287-320) checks, in order:!existing(the store's ownloadEnrollment()returned null),this.#allowed(enrollment.serverUrl)(the sameoriginAllowedByConnectSrcgate as#enroll), andfilterAclRecords(enrollment.hostId, params.aclRecords ?? [])which appliesisHostAclRecord(full shape guard,server-lib-common/src/security/acl.ts:44-58) andrecord.hostId === hostId(lib/src/remote/host/acl.ts:34-41) before any record is persisted.HostAcl,HostChallengeIssuer,PairingCeremony,authorizeConnection,verifyPasskeyAssertion, device-key signing/verification,boundedPushText/boundedPairingLabel) is imported fromserver-lib-common/src/security/byserver/src/*.ts,lib/src/remote/host/remote-host.ts, and the Pocket client paths — confirmed by import statements inconnection.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
DEFAULT_REMOTE_CONNECT_SRCis'https://*.dormouse.sh wss://*.dormouse.sh'verbatim in bothscripts/csp-defaults.mjs:18andlib/src/host/remote/connect-src.ts:22.CONNECT_SRC_SOURCE_PATTERNis the identical regex literal/^((?:https?|wss?):)\/\/([^/:]+)(?::(\*|\d+))?$/iin both files (csp-defaults.mjs:26,connect-src.ts:73).assertConnectSrcBakedis called bystandalone/scripts/build-sidecar-proxy.mjs:48and byvscode-ext/scripts/esbuild.mjsin both branches: thewatchbranch calls it once afterctx.rebuild()and before enteringctx.watch()(line 63), and the non-watch branch calls it afteresbuild.build(line 67).resolveRemoteConnectSrc(csp-defaults.mjs:48-62) throws on any override source that failsisSupportedSource, which uses the sameCONNECT_SRC_SOURCE_PATTERN.originAllowedByConnectSrcgatesRemoteHostService.#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) returnsfalseon aURLconstructor 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.performEnrollment(lib/src/remote/host/enrollment.ts:80-95) setsredirect: 'error'.hostFetchinpush-delivery.ts:56-74also setsredirect: 'error'for both thepushDevicesread and thesendPushwrite.Credentials at rest
JsonFileStore.writeAtomic(server/src/state.ts:92-97) callsmkdir(stateDir, { recursive: true, mode: 0o700 })andwriteFile(tmp, ..., { mode: 0o600 })before renaming. Greppedserver/src/forwriteFile(,appendFile(,createWriteStream(: the only writer of any state-directory file is this onewriteAtomicmethod (used byAccountStore,HostStore,PushSubscriptionStore,VapidStore— no other write path exists).FileHostStateStore.#write(lib/src/host/remote/host-state-store.ts:176-202) doesmkdir(dir, {mode:0o700}), best-effortchmod(dir, 0o700)on non-Windows, andwriteFile(tmp, ..., {mode:0o600})before rename.VsCodeHostStateStore.saveEnrollment/clearEnrollment(vscode-ext/src/remote-host-store.ts:84-96) usecontext.secrets.store/.delete(VS CodeSecretStorage, i.e. the OS keychain) for the enrollment;loadAcl/saveAcl(lines 111-128) usecontext.globalState, which is the documented, deliberate placement (ACL is public-key data).deploy/local/install-macos.sh:434-444generates the setup password via/usr/bin/xxd -p -l 32 -c 32 /dev/urandom(oropenssl rand -hex 32as 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 32regression).install-macos.sh:446setsumask 077immediately before the heredoc that writesconfig/server.env, and line 461 additionally doeschmod 0600 "$ENV_FILE". Line 367 doeschmod 0700 "$CONFIG_DIR" "$STATE_DIR".$ENV_FILEalready exists, the installer's only action on it ischmod 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 iscmd_show_passwordin the generatedbin/manage(lines 810-823), gated by an explicit y/N confirmation and a TTY check.bin/manage'scmd_verify(install-macos.sh:669-673) doesgrep -q "DORMOUSE_SETUP_PASSWORD" "$PLIST"and callsfailif it matches,passotherwise.The setup password
server/src/app.ts:230-235precomputesexpectedPasswordHash = sha256(config.setupPassword)and compares withtimingSafeEqual(sha256(provided), expectedPasswordHash)— both operands are fixed-length SHA-256 digests, so the compare never branches on input length.readPasswordGated(lines 240-249) callsdelay(PASSWORD_FAILURE_DELAY_MS)(250 ms, line 149) on every failure before responding.cors(...)registration inserver/src/app.tsisapp.use('/api/*', cors({ origin: '*', allowHeaders: [...] }))(line 260) — the/ws/hostand/ws/clientupgrade routes are registered separately and are not under this middleware. No route inapp.tssets or reads a cookie; every credential is a header (Authorization: Bearer) or a JSON body field.Network posture
install-macos.sh:473-474dies with an explicit message unlessconfig/server.envcontainsDORMOUSE_BIND_HOST=127.0.0.1exactly.bin/manage'scmd_verify(lines 701-711) probeshttp://$tsip:$PORT/api/helloover the node's Tailscale IPv4 and fails if it answers.server/src/config.ts:64:const bindHost = env.DORMOUSE_BIND_HOST?.trim() || undefined;— unset or blank both collapse toundefined.server/test/bind-host.test.mjsspawnsdist/index.jsas a real child process (spawn(process.execPath, [ENTRYPOINT], ...)) rather than calling library functions directly.install-macos.sh:241-257: if an existingconfig/server.env'sDORMOUSE_ORIGINdiffers from the freshly-derived one, the script prints a detailed warning anddies, refusing to write.cmd_verify's Funnel check (install-macos.sh:736-743) grepsts serve status+ts funnel statusoutput forfunnel on(case-insensitive) across both, and fails if found.What crosses the boundary
isPublicHttpsPushEndpoint(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))).app.ts:602(pushSendroute):currentPushSubscriptionsForHost(c.get('host').hostId)takes the Host id from the authenticated bearer token (requireHostmiddleware), never from the request body. Line 596-597 rejects whendevicePublicKeysis absent, non-array, empty, or contains a non-string./api/push/subscriptions(line 542-569) and/api/push/devices(line 571-582) take nodevicePublicKeyinput parameter at all — both are scoped purely by the presented credential.toPushText/push-delivery.ts:43-45calls the sharedboundedPushTextfromserver-lib-common. Server side:app.ts:610-619calls the sameboundedPushTextonbody.title/body.bodybefore building the payload. Both import fromserver-lib-common/src/security/push.ts:143-161, the single implementation.RelayHub.onClientFrame(server/src/relay.ts:221-299) only forwardspair/connect/connect2/msgafter theHandshakeGatechecks (for the first three) or theestablishedflag (formsg); no other frame type is forwarded pre-authorization, and unknown types get anerrorreply, not a forward.onHostFrame'smsgcase (lines 168-173) checksclient.established && client.hostId === host.hostIdbefore relaying to the client, and#isCurrentClientHost(lines 321-327) re-validates the client/host binding after every async gate check inonClientFrame.Cloud-hosted mode
accountIdassignment inserver/src/*.ts: all three (app.ts:271,313,378) useSELFHOST_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 byreadPasswordGated(the shared setup password).Qualitative findings
BLOCKER: none found.
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_DOMAINvsPUSH_SUBSCRIBE_DOMAINinserver-lib-common/src/security/deviceKey.ts:22andpush.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-copiedboundedPushTextmirror (it cannot import the shared module) is byte-identical in its regex character classes and code-point-safe truncation logic toserver-lib-common/src/security/push.ts— no drift.DEFAULT_CHALLENGE_TTL_MS= 2 min,HostChallengeIssuer.consumedeletes 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 exacthostId/challenge/devicePublicKeyinto the signed payload (deviceAuthPayload,pushSubscribePayload).verifyPasskeyAssertion(server-lib-common/src/security/passkey.ts) does not enforce authenticatorsignCountmonotonicity, which would normally guard against a cloned authenticator — but this is standard for platform/passkey-only deployments (many passkey providers always reportsignCount: 0), is not claimed anywhere in the spec, and does not weaken any property SECURITY.md asserts; noted for completeness only, not a finding.HostAcl.approveis reachable only throughPairingCeremony.approve, which is reachable only throughRemoteHost.#approvePairing, which is reachable only from aRemoteHostService.#approvecall gated on the immutablepairingIdmatching the currently-pending ticket for thatclientId.adoptis 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 reachHostAcl.approvedirectly: the webview only ever sends{clientId, pairingId, label?}over the bridge, and the Server only ever relays apairframe that both sides independently shape-validate before any UI or storage effect.hostToken/setup-password/VAPID-key logging acrossserver/src/,lib/src/remote/,lib/src/host/remote/, andvscode-ext/src/remote-host*.ts— noconsole.*call includes any of these identifiers.dor-control-server.js's token (a different credential, thedorCLI control-socket secret) is never sent on the wire either — the handshake is a mutual HMAC proof-of-possession withtimingSafeEqualcomparison, mirroring the pattern inserver/src/state.ts.lib/src/host/agent-browser-host.ts'skillDaemon(session)(lines 209-216) buildspath.join(agentBrowserStateDir(), \${session}.pid`)and passes the parsed content toprocess.kill(), with no character sanitization onsession— 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. Thesessionvalue reachingkillDaemon(viapopOut/popIn, called fromAgentBrowserPanel.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 reusingscreenshotPath's replace) beforekillDaemon'spath.join`. Not a Remote Control finding — surfaced here because the qualitative sweep is scoped to the whole repository per the audit's domain split.dor-lib-common/src/spawn.ts'sspawnAndCaptureusescross-spawnwith an argv array (spawn(binary, args, {...}), noshell: true), and a repository-wide grep forexecSync/child_process.exec(/shell: trueacrosslib/,server/,standalone/,vscode-ext/,dor/,dor-lib-common/,canopy/returned no hits outside test files.standalone/src-tauri/src/lib.rs): session-persistence commands (load_session/save_session/clear_session) key their filename onwindow.label(), sanitized throughsession_file_name()(line 754) to[A-Za-z0-9_-]only, with traversal defused and covered by an existing test.pty_spawnforwards webview-suppliedcwd/shell/argsto 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).IframePanel.tsx's handling of cross-origin content (the agent-browser/iframe proxy) validatese.origin === proxyOriginbefore acting on anypostMessage, and forwards only three narrow non-command fields into UI state — never intovscode.postMessage, so an embedded iframe cannot forge apty:spawnoragentBrowser:*command.lib/src/lib/vscode-message-token.tsgates extension→webview messages with a per-boot random token, defeating spoofing from a same-window script.lib/src/lib/session-restore.tsruns shape guards (isPersistedSessionV3etc.) and falls back tonull/empty rather than trusting a malformed blob;local-json-store.ts'sloadJsonwrapsJSON.parsein 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.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 IFchecks in## Remote Controlverified PASS withdirect 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 inpractice 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 IFchecks with noBLOCKER-level qualitative findings. CI and secrets failed: the repository's
effective GitHub Actions default permissions are
writewithcan_approve_pull_request_reviews: true, which directly violates theAutomated Maintainer
FAIL IFbackstop in SECURITY.md, and this is not justa theoretical misconfiguration —
tend-mention.yaml'sverifyjob declaresno
permissions:block of its own (and there is no workflow-level defaulteither), 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.
FAIL IFchecks passed with evidence; onlyINFO-level qualitative notes (one dead
allowBuildsentry).default_workflow_permissions/can_approve_pull_request_reviewsmisconfigured (BLOCKER) with a livedownstream instance in
tend-mention.yaml; plus two furtherFAIL IFviolations in the audit's own redaction coverage and fragment-existence
backstop.
FAIL IFchecks in## Remote Controlpassed with evidence; one WARNING outside that scope (unsanitizedsession-name path construction in
killDaemon(), bounded by the terminal'sexisting local-shell trust model).