Technical audit (2026-10-19)¶
A whole-repository read: architecture, security, the data layer, the web
console, the sidecar services, the SDKs and the personal install path. CI and
release mechanics were out of scope by request. Everything below is
read evidence (files and config read, greps counted) except where marked
run (scripts/ci/flyway-lint.sh was executed and passed: 380 migration
files, no duplicates). No Maven, npm or pytest run was made; nothing was
compiled or booted in this pass.
This audit stands on top of three existing audits rather than repeating them: STRUCTURAL-AUDIT-2026-10-14 (Java structure), PERSONAL-INSTALL-AUDIT-2026-10-19 (the live personal stack) and STANDARDS-AUDIT (2026-09-29, tooling). Where those already recorded a finding, this document says whether it still stands instead of scoring it again.
Working-tree caveat. The tree was mid-flight when this ran: 136 modified or
untracked paths, including an unfinished passkey/unlock feature under
core/admin/.../localauth/ and fixes documented in the personal-install audit.
Findings are about the code as it sits on disk, not a commit boundary.
1. Verdict¶
This codebase is unusually well-hardened for an open-source platform. The
things a brutal audit normally finds — missing encryption-at-rest, SSRF gaps,
permissive filter chains, hardcoded secrets, eval-based expression engines,
unauthenticated sidecars, localhost leaks — are all already closed, and the
closures carry SEC-*/FE-*/INF-* finding codes from previous passes. Every
sidecar shares one auth shape (bearer or X-Internal-Token, timing-safe
compare, fail-closed when the secret is unset). The personal compose stack
publishes nothing but loopback, runs containers read-only with all capabilities
dropped, and refuses to start without generated secrets.
The residual findings are concentrated in three places:
- The installer's browser wizard — the one new piece that has not been through a security pass (F1, F2).
- Frozen, not fixed, architecture violations — the ArchUnit baseline records ~350 live violations that are enforced against worsening but not yet repaired (F4).
- Deliberate personal-mode relaxations whose residual risk should be written down honestly (F5), plus a tail of known-open items carried from the personal-install audit (section 5).
None of the new findings blocks the personal install. F1 is worth fixing before the setup app ships to people who do not read terminals, because it is the one place where "drive a package manager from a web page" is actually reachable.
2. What was verified strong (so the next audit does not re-check it)¶
| Area | Evidence (read) |
|---|---|
| Secrets at rest | core/crypto/.../EncryptionService.java: AES-256-GCM, fresh random 96-bit IV per call, fail-fast boot on missing/malformed APP_ENCRYPTION_KEY, dual-key rotation window (APP_ENCRYPTION_KEY_PREVIOUS). No weaker cipher anywhere in main code. |
| JWT validation | core/security/.../SecurityConfig.java: issuer pinned, azp allowlist, optional audience check, algorithm pinned to RS256 in local mode (LocalJwtService rejects none/HS256 confusion). No localhost issuer fallback — boot fails loud without config, deliberately. |
| Public surface | PublicSurfaceRules.java: every permitAll entry carries a written justification; SecurityRuleSnapshotTest, PublicSurfaceSnapshotTest, AuthorizationCoverageTest, SensitiveRouteAuthorizationTest freeze the whole chain and fail on new mutating endpoints without rules. |
| SSRF | SsrfGuard.java + SafeHttpClients: a single guard covering loopback, RFC 1918, link-local, metadata names, IPv6-embedded IPv4 (mapped/compatible/6to4), Teredo, NAT64, single-label hostnames, embedded credentials — and a custom DNS resolver so the same validated answer reaches the socket (rebinding closed). Operator allow-list only. An ArchUnit rule (853d44fa) bans raw HTTP clients. |
| Tenant isolation | SchemaMultiTenantConnectionProvider: SET search_path per checkout, schema reset before pool return, connection aborted if reset fails. TenantSchemaGuard: server-generated t_<26 base32> names, one quoteIdentifier choke point, pg_/system schemas refused to destructive DDL. PgBouncer documented and configured session-only. |
| Sidecar auth | mcp-stdio-runner (spawn policy mirrors McpStdioCommandGuard, per-connection private HOME, launcher allowlist, seccomp profile), workspace-runner (bearer + timingSafeEqual, realpath folder confinement, process-group kill), desktop-agent (loopback + bearer), whatsapp-qr (timing-safe bearer, hashed session dir names), sidecar (X-Internal-Token, hmac.compare_digest, fails closed). |
| Install path | install.sh: truncation-safe main-at-bottom, HTTPS-only fetch, mandatory SHA256SUMS check, optional cosign keyless verification pinned to the release workflow identity. scripts/vegaduta: umask 077, .env chmod 600, secrets never overwritten, openssl/urandom only. |
| Expression evaluation | No SpEL StandardEvaluationContext anywhere; user scripts run on SimpleEvaluationContext (no method/type/bean resolvers); workflow expressions use a purpose-built engine with step/time budgets. |
| Data layer | flyway-lint.sh (run): 380 migrations in the shared controlplane+tenant namespace, zero version collisions. |
| Containers | All first-party images non-root (tool-sandbox's root is documented as deliberate for setpriv); Node 22.23.3 digest-pinned; compose :? mandatory secrets; BIND_ADDR=127.0.0.1 default; bounded json-file logging. |
| Web console | Enforced per-request nonce CSP built from runtime config (web/proxy.js + app/lib/csp.ts), frame-ancestors 'self', no dangerouslySetInnerHTML, tokens never in localStorage (cookie session via same-origin proxy), scanner-probe short-circuit, dev-only routes 404 in production. |
| RNG discipline | No Math.random on any credential path. randomUuid prefers crypto.randomUUID/getRandomValues; Math.random is last resort for non-secret UI ids on plain-HTTP LAN installs where secure-context APIs are absent — a documented trade-off, correct as written. |
3. New findings¶
Post-audit remediation (same day). F1, F2, F3 and F5 are fixed and tested; F6 is fixed; F4's
SafeHttpClientssubset burned down from 48 sites to 14 (all remaining sites deliberately raw — they target operator-configured private hosts like the sidecar, SearXNG, Langfuse, internal health probes and websocket realtime endpoints that a socket-level SSRF guard would refuse). Per-finding status is noted below.
F1. The setup wizard accepts unauthenticated state-changing POSTs on a fixed loopback port (medium) — FIXED¶
installer/server.go serves on 127.0.0.1:17890 by default (main.go:71
flag.IntVar(&c.port, "port", 17890, ...)) — a fixed, well-known port, not
a random one. POST /api/install, POST /api/docker/install and
POST /api/docker/start need no credential and check no Origin/Host
header (server.go:106-139). The handler decodes the body with
json.NewDecoder without a content-type check, so a fetch with
Content-Type: text/plain — a CORS "simple request" that browsers send
without a preflight — reaches it from any web page the user has open while
the wizard runs.
Reachable actions: dockerInstall runs brew install --cask docker-desktop /
winget install Docker.DockerDesktop (dockerbootstrap.go:24-41), and
api/install downloads a release and runs the full stack install. The release
itself is checksum-verified, so this cannot plant arbitrary binaries — the
impact is a drive-by "make this machine install Docker and a large application
stack", plus openBrowser opening tabs. Annoyance-class, not code-execution —
but the wizard's whole audience is non-technical users who will leave it open
in a browser tab while browsing elsewhere.
Smallest fix: at startup mint crypto/rand token, put it in the wizard URL
(http://127.0.0.1:<port>/?k=<token>), and require it on every /api/* POST;
optionally also reject POSTs whose Origin is not the loopback origin.
(--port 0 already exists as an escape, but the default is what ships.)
Fixed: the server mints a per-process setup token, carries it in the wizard
URL (?k=), requires Authorization: Bearer <token> on every /api/* call, and
rejects non-loopback Host headers. Installer tests pass.
F2. Release untar allows symlink escape (low — defence in depth) — FIXED¶
installer/release.go:290-315 checks that each tar member's path stays under
the install dir, but for tar.TypeSymlink it creates os.Symlink(hdr.Linkname,
target) without constraining Linkname. A member a = symlink to /etc
followed by member a/x = regular file writes through the link: the clean-path
check passes on the name while os.OpenFile follows the symlink. install.sh's
tar extraction does not have this gap (it does not synthesize links).
This is only reachable behind the checksum/cosign verification — i.e. a
compromised release pipeline, or a --release-url/file:// payload that the
checksum still covers. Low severity, but it is a one-line class of bug worth
closing: reject linknames that resolve outside dir (or drop symlink support
entirely — release tarballs are known content).
Fixed: unpackRelease now rejects absolute symlink targets, resolves each
member's parent through EvalSymlinks and compares against the resolved install
root, and confinement-checks the link's target — a a -> /etc + a/x archive
can no longer write outside dir. A first cut compared resolved paths against
the lexical root and broke on macOS (/var -> /private/var tempdirs); the
final version keeps a lexical root for name checks and a resolved root for
symlink checks. Installer tests pass.
F3. desktop-agent compares its bearer secret with === (low) — FIXED¶
services/desktop-agent/server.js:443:
header === \Bearer ${SECRET}`. Every sibling sidecar usescrypto.timingSafeEqual` (workspace-runner:69, whatsapp-qr:344-349, mcp-stdio
runner's secret check). On a loopback-only service this is a theoretical
side-channel rather than an exploitable one, but it is a one-line divergence
from the codebase's own convention — align it.
Fixed: server.js now uses crypto.timingSafeEqual (with a length guard)
like its siblings; node --test server.test.js passes.
F4. The frozen architecture baselines are large and still fully populated (medium — debt, not bug) — partial burn-down done¶
The ArchUnit freeze store (core/app/src/test/resources/archunit_store/)
currently records, per stored.rules and the store files:
| Rule | Recorded violations |
|---|---|
Controller depends on a repository or JPA entity (3b0a0201) |
~180 pairs |
Package inside a dependency cycle, any depth (9900f9ce) |
~147 packages |
Cross-area repository use (a6a2058b) |
~82 pairs |
Outbound HTTP client not via SafeHttpClients (853d44fa) |
~48 sites |
Constructor over 12 collaborator parameters (e802d0a3) |
~17 classes |
Rule 13 says baselines only shrink — these are recorded violations, not
absolved ones. The cross-area-repository and controller→repository entries are
the ones that matter most for the stated goal (hybrid tenancy / module
extraction per ADR-0015/0017): each is a place where an area's tables are its
public API. The 48 non-SafeHttpClients call sites are the security-relevant
subset — each is a place where the SSRF guard's resolver is bypassed (some are
legitimate: internal http://core/keycloak wiring, test fixtures; the store
does not distinguish). Recommend: annotate the store entries with the
justification so the residual set is honest, then schedule burn-down.
Burned down (48 → 14). A new GuardedJdkHttpClient
(core/security/.../net/GuardedJdkHttpClient.java) is a drop-in
java.net.http.HttpClient whose sockets resolve through SsrfGuard
(Apache-transported, streaming-aware, honours per-request timeouts and the
redirect policy; defaults() resolves the guard lazily so the operator's
allow-list installed at start-up is always in effect). Migrated: MCP OAuth
and Atlassian OAuth, the official MCP registry client, A2A, all Google and
Meta API clients, Slack/Teams/Discord/X/Telegram channel clients, the skill
registry and Brave search clients, x402 facilitator calls, image/video
generation clients, the OpenAI-compatible embedding client (guard injected as
a bean so ordering is explicit), the LLM blocking AND streaming paths (closing
the DNS-rebinding residual LlmHttpClientFactory itself documented),
DocumentImageFetcher, OutboundHttpTimeouts, GCP Cloud KMS, and Twilio's
network client (now NetworkHttpClient(RequestConfig) — no raw builder call).
The 14 deliberately-raw remainders target operator-configured private hosts
the guard would refuse: the sidecar, whisper provider, Langfuse, LiveKit
egress, internal health/version probes, SearXNG (WebSearchHttp), the
operator-configured web_task backend, eval's local-Ollama transport, and the
two websocket realtime providers (websocket transport is SafeWebSockets' job,
not this client's — newWebSocketBuilder on the guarded client fails closed
on purpose).
F5. Personal mode removes the approval gate on the machine's hands — residual risk is real, mitigations hold but should be stated (medium, by design) — DOCUMENTED¶
application-personal.yml:128 sets
app.assistant.local-shell.approval-required: false and :144 does the same
for desktop GUI actions. Deliberate — a one-person install shouldn't approve
every ls — but worth writing down what the residual actually is:
local_shellcommands run viaworkspace-runner(no platform secrets, confined to the mounted projects folder — good). But the runner container is on the default compose network with unrestricted egress: a prompt-injected command (a poisoned web page the agent fetched, an email it read) can exfiltrate workspace contents or reachcore:8080/postgres:5432— credentials are not in the runner's env, so the damage is bounded to the mounted folder plus whatever the network accepts without auth.desktoptools talk to a loopback agent that can click and type as the logged-in user — outside every container boundary.taint.mode: enforce(line 45) is the real mitigation: untrusted content can't drive the tools without an approval card. That makes the taint gate the load-bearing control for the entire personal posture;BuiltInToolClassificationsdecisions (e.g. thesearch_knowledge_base/local_shellinteraction already noted in the personal audit) deserve a dedicated test pass because they are now the only thing standing between "page says click OK" and a real click.
Not a bug — a posture statement that belongs in docs/personal-mode.md next to
the flags, so an owner opting out of approvals does it knowingly.
Done: docs/personal-mode.md now carries the residual-risk paragraph in the
Approvals section — local_shell runs sandboxed in the workspace-runner (no
secrets, mounted-folder confinement, but unrestricted egress), local_desktop_*
acts on the real screen outside every container boundary, and with
approval-required: false the taint gate (app.agents.taint.mode=enforce) is
the load-bearing control: it gates actions but does not stop reads or
exfiltration through already-allowed outbound paths.
F6. git-level hygiene observations (low) — FIXED¶
- 136 dirty/untracked paths at audit time from concurrent sessions, including
security-adjacent new code (
localauthpasskey/unlock) not yet committed. The personal audit already flags a known failing test (HookGuardChainTest) on this tree. State of HEAD ≠ state of disk. .dockerignorestill exists only at repo root,core/,web/— the September audit's list (app-builder, rl-trainer, voice-agent, mcp-stdio-runner, …) is unchanged, so those builds still send the whole service directory (including any local.venv—services/voice-agent/.venvexists in this checkout) as build context.- The stray empty
package-lock.jsonat repo root thatweb/next.config.js:195works around is still there; delete it rather than papering over it.
Done: the stray lockfile was already gone (the stale comment in
web/next.config.js referring to it is removed), and the missing
.dockerignores were added for the listed service dirs.
F7. Carried-forward open items, still standing (for the record)¶
From the personal-install audit (its own ranking, unchanged here): no published
release; embeddings auto ignoring a wizard-registered Ollama; STDIO MCP
unable to start in the container stack; browser automation never run against
real Chromium; local-model 500s on malformed tool calls; no identical-call
guard. From the standards audit: TypeScript strict: false remains (the ladder
documents 681 errors at flip time; ~35 files still read process.env.NEXT_PUBLIC_*
directly, down from 95); ~519 web files use any (down from ~1352 instances);
any/@ts-* suppression is thin (16) but the any surface is wide.
server.shutdown/spring.lifecycle.timeout-per-shutdown-phase remain unset.
Same-day status of the tractable subset. Done by concurrent commits and
this pass: RegisteredOllamaEmbeddingResolver (auto + wizard-registered
Ollama), RepeatToolCallGuard (identical-call guard), OllamaToolCallParseRetry
(local-model 500 on a malformed tool call), server.shutdown/timeout-per-shutdown-phase
(already set in application.yml — the earlier note was stale), wizard
preflight no longer fails on ports this stack itself publishes, HOST_RAM_GB/
HOST_ACCELERATOR are persisted to .env (documented in .env.example and
docs/configuration.md), server-side share pages and the agent-card proxy
prefer CORE_INTERNAL_URL over a localhost fallback that dialled the web
container itself, /connect badges Slack/SMS as "needs a public address" and
routes WhatsApp to the QR pairing (the personal-install route), and the doc
drift items (getting-started.md personal pointer, core.env.example personal
block) are in. Still open: a published release, STDIO MCP inside the container
stack, real-Chromium verification of browser automation, and the TypeScript
strict ladder.
Status 2026-10-21 (v0.1.9). Published release: done (signed release artifacts and pinned GHCR images; see
CHANGELOG.md 0.1.5). STDIO MCP in the container stack: the mcp-stdio-runner profile landed in 0.1.4
(--profile tools; the personal Tools/Browser add-on, also on Windows from 0.1.7). Browser automation was run against
a real Chromium in the personal-install audit (BrowserRemoteRealTest). The TypeScript strict ladder was not re-checked.
4. Process observations¶
- Gates that nothing runs still rot. Flyway lint passed today, but the
September structural audit found the boot check had rotted within days of
becoming stale; the same risk applies to every advisory gate. Not a code
finding — a reminder that
scripts/ci/is full of good gates whose value is zero when advisory. - Audit cadence is working. Three prior audits, and each documented
finding is measurably reduced (AgentService 2,375 → 664 lines; largest Java
file now 808; direct
process.envreads 95 → 35). The ratchet model — freeze, forbid growth, shrink opportunistically — is visibly doing its job. The cost is the frozen sets in F4 look permanent without scheduled burn-down.
5. Not verified¶
- Nothing was compiled, booted or tested in this pass. Findings are static reads
plus one
flyway-lint.shrun. services/app-builder,services/code-intel,services/rl-trainer,services/piece-hostinternals (beyond their auth posture), the Helm charts' runtime behaviour,infra/terraform, and the Java/Python SDKs' internals were skimmed, not audited line-by-line.- The F1 CSRF claim is argued from the Go stdlib's behaviour (no content-type check in the handler, no Origin check, CORS simple-request rules) — it was not exercised against a running wizard.
- Whether the 48 non-
SafeHttpClientssites in the freeze store include any tenant-controlled URL was not individually traced.
6. Smallest fixes first¶
| # | Change | Effort |
|---|---|---|
| 1 | Wizard: random token in URL + required on /api/* POSTs; check Origin/Host — done (F1, 0.1.3) |
hours |
| 2 | unpackRelease: refuse symlink targets escaping the install dir (or refuse symlinks) — done (F2, 0.1.3) |
hours |
| 3 | desktop-agent: timingSafeEqual on the bearer check — done (F3, 0.1.3) |
minutes |
| 4 | Personal mode: write the F5 residual-risk paragraph into docs/personal-mode.md — done (F5) |
hours |
| 5 | Annotate the F4 freeze-store entries with justification; burn down the SafeHttpClients subset — partly done (48 → 14, F4) |
days |
| 6 | Delete the stray root package-lock.json; add the missing .dockerignores — done (F6) |
hours |