Skip to content

fix(security): harden workspace boundaries and remote transports - #222

Merged
klNuno merged 9 commits into
masterfrom
fix/global-audit-hardening
Aug 28, 2026
Merged

fix(security): harden workspace boundaries and remote transports#222
klNuno merged 9 commits into
masterfrom
fix/global-audit-hardening

Conversation

@klNuno

@klNuno klNuno commented Aug 27, 2026

Copy link
Copy Markdown
Member

this branch confines Agent API project credentials across projects, timeline, search, snapshot, transcript and pulse reads. it validates worktree migration sources before mutation, makes checkpoint namespaces injective, and rejects non-UUID thread ingress.

web push now rejects redirects and private DNS answers, revalidates stored endpoints, bounds requests, and supports an optional host allowlist. remote clients route agent answers to the request source, abort stale socket dials, reject dropped PTY input, and settle dispatches only after a successful write. telemetry shutdown is bounded, os-release parsing skips non-assignments, and consent state commits only after persistence succeeds.

this branch is based on current master. the duplicate ConPTY fixture change was dropped during rebase because #219 already contains that fix.

BOITE_PUSH_ALLOWED_HOSTS stays optional so self-hosted push services keep working; redirects remain disabled and every resolved address must still be public.

verified with bun run check, 1,120 frontend tests, 813 Rust workspace tests, workspace clippy with warnings denied, production build, bundle budget, server smoke, WebSocket smoke and RemoteBackend smoke. cargo fmt --all -- --check remains red on pre-existing repository-wide rustfmt drift; CI does not run that gate.

klNuno added 9 commits August 27, 2026 18:09
worktree.migrate trusted `from` behind `Path::starts_with(base)`, which
resolves nothing: `<base>/../..` passed, then unlink_shared_artifacts,
remove_dir_all and rename ran on it.

classify_migration_source refuses the base itself, a relative step, a
link and anything deeper than one level. migrate_worktree_blocking
refuses a directory that is not a worktree of this repo. gone and
elsewhere keep their old answers.

thread_ref_prefix was not injective: `a.b` and `a-b` shared a namespace,
so forget and restore crossed threads. safe ids stay verbatim, the rest
go hex under `x_`, so every uuid keeps the refs it already has.

rpc::checked_thread_ids refuses a non-uuid on thread.create,
thread.spawn and checkpoint.
a token issued for one project held readproject and nothing asked which
project a read landed in: /v1/projects, /v1/timeline, /v1/search,
/v1/snapshot and /v1/transcript answered for the whole workspace, /mcp
included.

grant::reads_across answers that second question, routes::confined_to
asks it once. a thread key still reads everything, which is what
/v1/transcript exists for.

the scope rides into the query rather than over the answer:
store.search, search::transcripts and snapshot::take each take it.
store.thread_ids_of_project names the terminals a confined caller may
read, a transcript file naming its thread and not its project.
push.subscribe took any https host and send_one followed whatever came
back: an endpoint could redirect a post onto 169.254.169.254, and a name
could resolve inward after acceptable_endpoint had passed.

build_client now sets redirect::Policy::none, a PublicOnlyDns resolver
handing the connector public addresses only (the resolution the
connection itself uses, so nothing left to re-point), and a ten second
timeout. send_one calls acceptable_endpoint again.
BOITE_PUSH_ALLOWED_HOSTS narrows registration to named hosts.

tests use a loopback server, no network: a 302 stays a 302 with its
target never hit, a stored http endpoint errors before any connect.
Worker::shutdown sent its stop order down the bounded event channel. A
worker stuck in a slow flush leaves that channel full, so the send blocked
until the flush ended, before the deadline loop had even started: up to the
5s connect timeout at window close, which is exactly the freeze the deadline
exists to prevent. The order now travels on a flag the worker reads at the
top of its loop; the message is still sent, without blocking, only to wake a
worker parked in recv_timeout. Whatever is still queued is drained into the
final flush, and track refuses to enqueue once shutdown started.

parse_os_release_pretty_name aborted on the first line without an "=", so a
comment or a blank line above PRETTY_NAME reported plain "Linux" for a
machine that named itself in the file. Non-assignments are skipped now.

TelemetryRuntime::update mutated the in-memory sidecar before attempting the
write. A refused write left the window showing an opt-in the file never
recorded, and the queue sending under it, until the next launch read the
file back. The write comes first and memory only follows on success.
agent-answer.ts routes an answer on who wrote the request, not on
workspace.current(), which in dynamic mode is the local device.
handleBrowserAsk uses the same door.

socket.ts: a dial carries a generation and an abort, so close() during
buyTicket opens no WebSocket afterwards. sendInput checks readyState,
and RemoteBackend.pty.write rejects on its false; call sites got a
catch.

dispatches.ts types first, then settles delivered or dropped/
write_failed. settle_dispatch only moves a row out of queued, so
settling first lost the line.

tests: socket.test.ts, index.test.ts, agent-answer.test.ts,
dispatches.test.ts, each failing against the code before it.
Only fold case for text path comparisons on Windows. This keeps case-sensitive siblings outside the legacy migration base.
@klNuno
klNuno merged commit c874c98 into master Aug 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant