Skip to content

Remote sandboxes over the owner protocol, and signing with a key held elsewhere - #223

Closed
geekgonecrazy wants to merge 16 commits into
devfrom
basecamp/remote-sandboxes
Closed

geekgonecrazy wants to merge 16 commits into
devfrom
basecamp/remote-sandboxes

Conversation

@geekgonecrazy

@geekgonecrazy geekgonecrazy commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

What if you want to run an agent's work in a VM that mounts nothing from the host, and to keep the agent's keys where they belong.

Remote sandboxes

A VM works in a remote sandbox of a view: a tree materialized from the repository's owner over iroh, speaking the same database-owner protocol (no second protocol). Changes recorded in the sandbox land in the repository through the owner.

  • Reach a repository's owner from a remote sandbox (902ca4f), with the owner's iroh endpoints able to bind IPv4 only (d4c0d8d).
  • Record in the sandbox's cache exactly as on the repository, and land the change through the owner (9e8d77c, ae0537a). Remote sandboxes live inside Repository, so any caller records and provenance lands (b9a63e3).
  • Read a sandbox's recorded state: diff, restore, log (93e8af3). Never treat a sandbox's pointer file as untracked (83f319f).
  • A sandbox's cache takes its view's vault (9aa898f). Expeditions' draft views render as themselves and have their vault indexed (3001ba7).
  • Render a view's tree without touching disk (e3cb25a), and stage and seal a view as it renders (555a7d2).

Signing with a key held elsewhere

  • Sign attestations with a key held elsewhere (e95b122): atomic intent attest --prepare prints the document and the bytes to sign, and --signed <file> records an attestation made from them, checked against --identity's key and the intent as it is now (5c1dc77).
  • Tokens name the server they are for, as aud (6875cd2).

Merged dev

dev is merged in, including Ed25519 change signing (#214). In atomic-agent/src/identity.rs the merge takes dev's version as-is. That supersedes b5ef87b ("never record agent work under the human's key"): dev falls back to the default signer on purpose (a_human_identity_selected_falls_back_to_the_default_signer).

Testing

  • a real-VM suite runs on this branch: runs record and land through the owner from VMs with no host mounts, forked and cold.
  • cargo test -p atomic-agent --lib passes after the merge.
  • I haven't run the full workspace suite on the merged branch yet.

geekgonecrazy and others added 16 commits September 24, 2026 02:35
`attest_value` splits into `prepare_attestation` (author, content hash and
the exact bytes to sign) and `attach_proof`, so a signer that keeps its key
outside this process — a browser's WebCrypto, a hardware token — produces
the same `eddsa-jcs-2022` attestation the CLI does. `attest_value` is now
those two halves around a local `Signer`.

`atomic-canonical-wasm` exposes that to the browser: prepare, attach and
verify attestations, plus the DID and canonical JSON of a document, all
over JSON strings and byte arrays. `build.sh` builds it with wasm-bindgen;
`smoke.mjs` signs with a non-extractable WebCrypto key, verifies, and
checks tampering is caught.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`atomic intent attest --prepare` prints the document an attestation
signs and the exact bytes to sign, needing only the identity's public
key. `--signed <file>` records an attestation a key holder produced from
it: it must be signed by `--identity`'s key and attest the intent as it
is now, so a signature over a stale or altered intent is refused.

This lets a sandbox that holds only an agent's public identity attest as
that agent, with the key kept by a signing service outside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When an agent identity was named (option or ATOMIC_AGENT_IDENTITY) but
couldn't be used — missing, not an agent, unreadable — recording fell
back to the plus-tag author on the human's default key, putting a
person's key on work an agent did, exactly when the caller said it was
an agent's. It now records unkeyed instead, and says so in the log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Self-signed request tokens now carry `aud`: the bare server URL they
were minted for, normalised. A server that checks it refuses a token
minted for somewhere else, so one leaked from one server can't be
replayed at another within its five minutes. Verifiers that ignore
unknown claims are unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`Repository::materialize_view_entries` hands each entry of a view — path,
inode, kind, mode, bytes, content hash, conflict-marker line — to a
sink, in path order with directories first, plus the view's Merkle state.
Read-only: no working tree, stat cache or conflict state is written, so
it runs beside other readers. It serves a remote sandbox its tree and
baseline.

The per-file rendering is now one function, `render_view_file`, shared
with `materialize_parallel`, which keeps writing to disk as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`.atomic-sandbox` showed up as untracked inside a sandbox, so
`record --all` would record it; a remote pointer carries a token.
Ignore it, and the sandbox's local cache `.atomic-sandbox.d`, like
`.atomic`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Remote sandboxes use the database owner's protocol rather than a second
one: iroh is another transport beside the local socket, answered by the
same handler. Each remote frame carries a view-scoped sandbox token
(in memory only, hashed); a remote caller gets Ping, the provenance
requests and Materialize, each checked against its token — its own view,
the sessions and turns it started, checkpoints only for changes its view
can see. Opening, renewing and closing sandboxes, and shutdown, stay
local.

The client picks its route from .atomic-sandbox, so provenance from a
remote sandbox goes through the same OwnerJournalSink as a local hook.

`atomic sandbox create --remote` mints the token and writes the
pointer (the owner's iroh address, the view, the token; 0600);
`materialize` writes the view's tree inside the sandbox; `renew` and
`close` manage the token. The owner's iroh key persists in .atomic so
pointers survive a restart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A change carries the repository's internal node ids and inode numbers,
so a sandbox that records without the repository must read the
repository's own rows. atomic-core's pristine::slice exports and imports
them byte for byte: a skeleton (the view's tree, inodes and positions,
directories, ids of every visible change) and, per record, a graph slice
for the inodes it touches (each file's content and name chain, one hop of
neighbours so find_block resolves as it does on the repository, its CRDT
rows and conflicts). The view is imported flattened, with the
repository's own Merkle state. Inodes the cache adds itself start at
2^62, clear of the repository's.

The change store can hold content spans without their change files, and
consults them first. Repository::{export,import}_sandbox_{skeleton,slice}
wrap both sides; sandbox_slice_inodes is what a cache asks for — changed
files and the directories on the way to changed or new paths.

status: a tracked file missing from disk is "already deleted" only when
the graph holds its vertex; a cache has the tree before the graph.

The parity test records the same edit on a repository and on a cache
built from it — modify, deletions spanning changes, delete, add (existing
and new directory), move, move and edit, and a second record after the
first lands — and requires identical hashes and bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two more requests on the owner protocol, checked against the sandbox's
token like the rest: FileStates hands the cache the repository's rows and
content for the inodes record will read (only inodes on the view), and
SubmitChange takes the V3 bytes the cache recorded.

The owner applies a submitted change only if it is what it claims and
names nothing outside the view: the bytes hash to the claimed hash; the
view is still at the state it was recorded against; every change it
depends on or refers to that the repository knows is visible on the view;
every node its file operations name is a visible change's
(FileOps::referenced_node_ids); no path touches .atomic, .atomic-sandbox
or .atomic-sandbox.d; and it is new. Submissions go in one at a time.
Refusals write nothing.

In the sandbox, materialize also builds the cache: an ordinary
repository under .atomic-sandbox.d/cache whose working tree is the
sandbox, so a remote pointer opens it and status, log and diff work
locally. record hydrates first, records without saving or applying,
submits, then takes the view's new skeleton and marks the tree clean.
When a change lands, a path the sandbox added gets the repository's
inode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cache holds a view's rows, not its content. diff and restore now
load the content they compare against first (FileStates, as record
does), and log fetches the change files the view has and the cache lacks
— a new Changes request, answered only for changes on the token's view,
each file kept only if its bytes hash to what it claims.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e lands

The remote path moves out of individual commands into Repository, so
every caller gets it — the atomic CLI and an agent's turn hooks alike. A
process installs one RemoteSandboxLink (the atomic binary installs the
owner client at startup); in a remote sandbox's cache, record hydrates
first, write_recorded submits instead of applying (a change that didn't
land fails the record), diff and restore hydrate, log fetches change
files, and publish_provenance_checkpoint publishes the checkpoint in the
repository.

PublishProvenance, the owner's side: the sandbox's own session, and
every change the provenance explains on its view; the graph arrives
serialized, so the repository publishes what the sandbox hashed.

Two authorization fixes the agent flow showed: asking after a session
nobody has written to is allowed (the store answers "no such turn"),
and BindCheckpointHash names a provenance graph, not a change.

An agent's session-start, turn-start, turn-end and session-end in a
remote sandbox now leave its change on the view and its session ledger
and provenance in the repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The vault arrives as files, as after a pull, so building the cache
bootstraps the vault tables from them; intents written in the sandbox
are then recorded and land on the view like any other change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…vault

A draft's structural changes (files it adds, moves, deletes) wait in the
deferred tree journal until the view is checked out; TREE is the
checked-out view's. Rendering any other view — materialize_view_entries,
so Materialize and the skeleton after a submit — now projects that
view's journal in a write transaction that is thrown away, and the
skeleton's tree is the view as rendered (implicit directories, inode 0,
left out). Before, a file added on an expedition's draft never appeared
on it.

When a sandbox's change lands, the vault files it touched are indexed
into the repository's vault from the view's content, as a pull does from
the files it writes (vault_record_files, factored out of the working-copy
deflate). An intent created in an expedition is in `intent list` again.

Tests: record parity and a submitted change on a draft, a second record
building on the first, and the agent flow on a draft view off dev.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
materialize_view_to (behind `sandbox stage` and `seal`) listed files
from TREE, which is the checked-out view's: a file another view added
was missing from its image. It now writes materialize_view_entries,
which projects the view's own tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ATOMIC_IROH_IPV4_ONLY=1 makes the owner endpoint and the client dialer
open no IPv6 socket. Under some microVM network backends (smolvm TSI, in
a fork) creating one takes the process down, so a sandbox inside such a
VM could not materialize or submit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Superseded by #224.

@geekgonecrazy
geekgonecrazy deleted the basecamp/remote-sandboxes branch September 26, 2026 02:53
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