| 1 | CRITICAL | Plugin `invokeCommand` has no timeout | **CLOSED 2026-07-20** — verified in code. Every guest call (`allocate` / `command_dispatch` / `deallocate`) runs under a per-invocation deadline: `budgetMs` = manifest `resources.cpu_budget_ms` → `plugins.cpu_budget_ms` → hard 100 ms floor, applied via `context.WithTimeout` (`Server/plugin/sandbox_wazero.go:257-268`). The runtime is built `WithCloseOnContextDone(true)` (`sandbox_wazero.go:69-73`) so an expired deadline interrupts a runaway guest (`for {}`), and `releaseClosedModule` (`sandbox_wazero.go:326-338`) drops the closed module so the next dispatch re-instantiates lazily instead of bricking the plugin (regression W1-1). Landed in PR #1182 (`0f58ddd` budget, `2111976` W1-1). Pinned by `TestWazeroCPUBudgetOverrunDoesNotBrickPlugin` (`sandbox_wazero_test.go:271`) |
| 2 | CRITICAL | Plugin storage has no per-plugin key isolation | **CLOSED 2026-07-20** — the finding's premise does not hold against the code. Isolation is structural, not a check that can be skipped: every `Storage*` call passes the caller's `Instance.ID` as the namespace and exposes no parameter by which a caller — let alone a guest module — could name another plugin's namespace (`Server/plugin/host_storage.go:26-73`), and `plugin_kv PRIMARY KEY (plugin_id, key)` (`Server/migrations/015_plugins.sql:13-18`) makes the same split the storage layout. Every query filters on `plugin_id` (`Server/db/plugin_queries.go:87-135`). This PR adds `TestStorageKeysIsolatedPerPlugin` pinning it (same key from two plugins does not collide; scan/delete do not cross namespaces) plus the missing key-size cap the file's doc comment already promised |
| 3 | CRITICAL | Plugin per-command ACL missing (auto-registration) | **CLOSED 2026-07-20 (this PR)** — the manifest is now the per-command ACL. `plugin.json` gains a `commands` block; `RegisterCommand` refuses any name the manifest did not declare (`Server/plugin/host_commands.go:31-45`, `ErrCommandNotDeclared`), which is the single choke point both `list_commands` auto-registration (`sandbox_wazero.go:146-153`) and direct registration route through. A guest can therefore no longer widen its own command surface, and an admin can see the full command list before enabling. Declared names are validated to the dispatcher's canonical form, deduplicated, and capped at 64 (`manifest.go:207-233`). Cross-plugin hijack was already refused and stays refused. Pinned by `TestRegisterCommandRequiresManifestDeclaration` + `TestManifestCommandsValidation` |
| 4 | CRITICAL | No rate limit on event delivery to plugins | **CLOSED 2026-07-20 (no guest code on the event path)** — there is no guest delivery to rate-limit. Note what *is* wired, so this is not mistaken for an absent call site: `EventSink.Dispatch` has exactly one caller outside the `plugin` package's tests — `Server/ws/hub.go:1034`, invoked on **every** broadcast message whenever an operator enables plugins (`Server/api/router.go:134-139` sets `h.pluginSink` when the registry is non-nil), on the hub's broadcast goroutine while `seqMu` is held. What makes the finding unreachable is one level down: `Dispatch`'s loop body invokes no guest code in either build (it touches no `inst.module`), and no production code calls `EventSink.Subscribe` — only tests — so `subs` is empty and the loop never iterates. A plugin cannot slow the hub by handling events slowly because no plugin ever handles one. Recorded as a gate rather than left silent: the SECURITY GATE comment on `Server/plugin/host_events.go` requires the per-plugin rate limit, the `invokeCommand` CPU deadline, and off-hub-goroutine delivery to land *in the same change* that wires guest delivery — and flags that the hot call site already exists, so wiring is a one-line `Subscribe` away, not a new integration. `TestEventDeliveryHasNoGuestPath` fails if delivery appears without that review |
| 5 | CRITICAL | Plugin HTTP capability allows data exfiltration to allowlisted hosts | **OPEN — accepted residual risk (2026-07-20)**. Not fixable by hardening: an allowlisted host is by definition a permitted destination, so a plugin holding `http` can POST anything it can read to it. Closing it properly needs egress content policy (per-plugin request/response body inspection, byte budgets, per-plugin allowlists instead of one server-wide list) — a plugin-runtime redesign, not a patch. Standing mitigations, all verified in code: (a) `plugins.enabled` defaults false; (b) `plugins.http_allowlist` defaults empty and an empty allowlist denies every host, so the capability is inert until an operator names a destination; (c) the manifest must declare `http`, which is visible to the admin before enabling; (d) no host import is wired, so guest code cannot call `HTTPDo` at all today; (e) SSRF hardening (allowlist dot-boundary matching, guarded dial that vets every resolved IP before connecting, redirect re-checks, 5 MiB response cap) confines reach to public allowlisted hosts. Residual risk accepted for alpha/beta: an operator who both enables plugins and allowlists a host trusts the plugins they install with data those plugins can read |
| 6 | HIGH | `Server/store/` untested | SUPERSEDED — `store/` was removed 2026-07-19 (single data layer, A-2026-07-05); its behavior lives in `db/` + sqlc (`db/dbgen/`), tested against in-memory SQLite |
| 7 | HIGH | Client `src/lib`/`src/stores` <10% unit coverage | CLOSED since audit — large vitest suite exists (164 files / ~4.4k tests as of 2026-08-04, ~94% statements); `src/lib` + `src/stores` are also mutation-tested (Stryker) |
| 8 | HIGH | Unpinned critical npm packages | RESOLVED 2026-08-05 — the dependency policy is decided and written down in docs/contributing.md (lockfiles authoritative + npm ci-only installs + weekly Dependabot with deliberate majors + per-PR audit gates); DC-11 was this finding's tracker |
| 10 | MEDIUM | Audit-trail write failures silently ignored | RESOLVED 2026-07-20 — every audit write routes through `db.WriteAudit` (`Server/db/audit.go:37`), which enqueues to the async writer and, on the synchronous fallback, logs failures via `slog.Error` with action/actor/target context. Nothing is silently dropped |
| 11 | MEDIUM | E2E not in CI / no .nvmrc | RESOLVED — better than planned: the full web e2e suite runs on every PR (`client-e2e`, non-blocking pending a flakiness soak) and the `@parity` subset gates merges (`client-e2e-parity`, blocking). Node is pinned by `setup-node` to 20 in CI |
| **CRITICAL** | `Server/plugin/sandbox_wazero.go:162,211` | `invokeCommand` has **no timeout** — a looping plugin hangs the goroutine indefinitely |
| **CRITICAL** | `Server/plugin/host_storage.go` | Storage capability has **no key isolation** — plugin can read/write ANY key in the plugin store, not just its own |
| **CRITICAL** | `Server/plugin/host_http.go:162-240` | HTTP capability allows plugins to **exfiltrate data** by POSTing captured payload to any allowlisted host |
| **CRITICAL** | `Server/plugin/registry.go:129-133` | All commands auto-registered if manifest declares `commands` capability — **no per-command ACL** |
| **CRITICAL** | `Server/plugin/host_events.go` | **No rate limit** on event delivery to plugins — malicious plugin could slow server by processing events slowly |
| MEDIUM | `Server/plugin/host_http.go:64-96` | DNS rebinding TOCTOU between `rejectPrivateAddrs` check and TCP dial — not fully atomic |
| LOW | `Server/plugin/registry.go` | Default (non-Wazero) stub build doesn't emit WARN if plugins are configured but stub is running |
| LOW | `Server/plugin/loader.go` | Plugin discovery walks filesystem on every server start — no manifest caching |
| 4 | CRITICAL | Plugin | Rate-limit event delivery to plugins (token bucket per plugin) to prevent event flooding DoS | `Server/plugin/host_events.go` |
| 5 | HIGH | Tests | Add `store/` package tests — data persistence is completely untested; cover SQLiteStore CRUD and query methods | `Server/store/` |
| 6 | HIGH | Tests | Add TypeScript unit tests for `src/lib/` (api.ts, ws.ts, dispatcher.ts) and `src/stores/` — currently <10% unit coverage | `Client/tauri-client/src/lib/` |
| 7 | HIGH | Deps | Pin critical npm packages to exact versions: `@tauri-apps/plugin-updater`, `vite`, `@tauri-apps/plugin-store` | `Client/tauri-client/package.json` |
| 8 | MEDIUM | Architecture | Refactor `Server/api/auth_handler.go` to route through `AuthService` — currently bypasses service layer | `Server/api/auth_handler.go` |
| 9 | MEDIUM | Code Quality | Silence audit trail loss — wrap `database.LogAudit()` calls with `slog.Error` on failure instead of `_ =` | `Server/admin/handlers_*.go` |
| 10 | MEDIUM | CI/CD | Add E2E gate to `ci.yml` on push to main (not only on PR) + add `.nvmrc` for local Node version enforcement | `.github/workflows/ci.yml` |
### Bonus (quick wins)
- Add `.catch()` handlers to `.then()` chains in `InviteManager.ts`, `file-upload.ts`, `attachments.ts`
- Add `context.WithTimeout` guard in `Server/ws/voice_leave.go` (`livekit.RemoveParticipant` has `//nolint:contextcheck`)
- Add HTTPS-only URL validation in `Server/ws/command.go` (existing TODO)