STAGE 1 / MILESTONES 9–10

Harness Platform M9–M10: choosing where an agent is allowed to work

How Harness turns M8's Workspace capability into an explicit trusted local adapter and a Docker-by-default disposable adapter—with bounded text worktrees, no host mounts, and auditable cleanup.

01 / MILESTONE CONTRACT

M8 defined workspace authority. M9 and M10 make two deliberate ways to exercise it.

Harness now has an explicit developer-only adapter for trusted host work and a Docker-by-default adapter for disposable execution. Both implement the same Workspace capability, carry only bounded text state, produce reviewable outputs, and fail instead of silently widening authority.

The pairing matters because “workspace” is no longer an abstract interface or an ambient current directory. The caller must choose a backend at a trusted launch boundary, and an omitted choice resolves to the isolated one. The merged implementation is pinned at 6e1e578.

M9–M10 delivery boundarytext
M9 · LocalWorkspace
  developer-only host adapter
  bounded text I/O and exact reviewed argv
  symlink, identity and race defenses
  cancellation, snapshot, diff and disposal
  no claim of OS isolation

M10 · DockerWorkspace
  default native adapter
  clean copied worktree · never a repository mount
  digest-pinned image · network disabled
  bounded CPU, memory, PIDs, disk, time and output
  validated patch and declared artifacts only

shared boundary
  operational adapters are shipped
  Pi TaskAgent selection remains unchanged until M16

02 / WHY THEY TRAVEL TOGETHER

One contract serves two trust models without pretending they are equivalent.

Local mode is useful for fast, trusted platform development where the command and host are already inside the developer's trust boundary. Docker mode is for disposable, copied-state execution where the repository, credentials, network and host services must remain outside the container. Shipping both against one contract makes the difference a visible configuration decision instead of scattered conditionals inside tools.

03 / EXPLICIT BACKEND SELECTION

Docker is the default, local requires two affirmative choices, and failure stays failure.

createNativeWorkspace selects Docker when the backend is omitted or named explicitly. Local construction succeeds only when the caller chooses backend: "local" and supplies developerOnly: true. A missing daemon, unavailable image, invalid digest or configuration error is returned; there is no catch block that quietly executes on the host.

The safe default is a decision tree with no fallback edgeAt merge 6e1e578, the trusted native selector chooses Docker when backend is omitted or explicitly docker. Local execution requires both backend local and developerOnly true; Docker startup failure remains an error and never becomes ambient host execution. This selector is not yet the upstream Pi TaskAgent dispatch path.
Trusted selector behaviortext
createNativeWorkspace(config)
├── backend omitted ──────────────► DockerWorkspace
├── backend: "docker" ───────────► DockerWorkspace
├── backend: "local"
│   ├── developerOnly: true ─────► LocalWorkspace
│   └── otherwise ───────────────► configuration error
├── unknown or malformed config ─► configuration error
└── Docker open failure ─────────► error · never local fallback

04 / BOUNDED WORKSPACE DOMAIN

Both backends reduce a worktree to the same small, inspectable data model.

Adapter state is a set of relative, regular, non-executable UTF-8 text files. Default limits bound each file, the whole tree, file count, command output and execution time; configuration can raise each ceiling only fourfold. Snapshots are content-addressed and diffs are generated as bounded Git patches.

Shared adapter limitstext
defaults
├── one file        1 MiB
├── total tree      8 MiB
├── file count      2,000
├── command output  1 MiB
└── command time    30 seconds

configurable ceiling
└── at most 4 × each default

accepted workspace content
├── relative UTF-8 text files
├── regular non-executable files
└── content-addressed SHA-256 snapshots

rejected
├── binary / NUL content
├── symlinks, hard links and special files
├── executable modes and cross-device traversal
└── .git and known credential-bearing paths in Docker capture

The restriction is intentional. A text-oriented coding lane is much easier to validate across a trust boundary than a lossless clone of every filesystem object. Support for binaries or executable artifacts would need a separate reviewed contract rather than an accidental relaxation here.

05 / LOCAL DEVELOPER ADAPTER

LocalWorkspace makes host execution explicit and checks every boundary it can own.

On open, the adapter resolves the workspace root, records its device and inode identity, captures a bounded snapshot, and emits lifecycle evidence when an observer is present. It accepts relative paths only and rejects Git internals, traversal, links, hard links, special files, executable files and cross-device movement.

Trusted local mode narrows ambient authority without claiming isolationLocalWorkspace adapts M8 directly to the developer machine, so its defenses constrain paths, file types, sizes, command identity, cancellation and post-command scope rather than pretending to isolate same-user hostile code. macOS and Linux use different descriptor-safe open strategies.

06 / PATH AND RACE DEFENSES

Validation is tied to checked file descriptors, not only preflight path strings.

A safe path check followed by an ordinary open leaves room for a symlink substitution. The implementation therefore uses platform-specific no-follow opens. macOS usesO_NOFOLLOW_ANY; Linux walks anchored directory descriptors through /proc/self/fd and refuses each linked component. Other platforms fail as unsupported rather than dropping to a weaker open.

Parent directories must already exist for writes, file identity is rechecked, and a root identity change invalidates the operation. Those rules also keep recursive directory creation from becoming an unreviewed capability.

07 / PROCESSES AND CHANGE SCOPE

Commands are exact vectors, and their entire resulting tree is reviewed.

Local mode does not accept command prefixes or shell strings. Trusted configuration lists each complete argument vector, and execution uses shell: false with a minimal environment. Timeout and cancellation share one signal, and the executor kills the process group so a detached child cannot outlive the rejected operation.

Local command admissiontext
trusted launch configuration
└── allowedCommands: exact complete argv arrays

execution
├── shell: false
├── minimal environment
├── bounded stdout + stderr
├── shared timeout and AbortSignal
└── detached process group killed on timeout or cancellation

change admission
├── capture bounded tree before command
├── capture bounded tree after command
├── compute changed paths
└── reject any change outside allowed_paths

After a command, Harness captures the tree again and compares every changed path againstallowed_paths. Exact paths, directory wildcards and the explicit global wildcard are supported. This constrains what may change; it does not restrict what an already-admitted command can read from the workspace.

08 / COPY, NEVER MOUNT

DockerWorkspace treats repository state as an input message.

Configuration is validated before the source tree is read. The source is then captured through the same local safety machinery, with .git omitted and common credential paths rejected before Docker starts. The current text tree and requested argv are serialized into bounded JSON and delivered over stdin to one fresh container.

Each Docker command crosses a copied-state validation loopDockerWorkspace captures a sanitized bounded text tree, sends it over stdin to one fresh M3-runner container per command, receives a bounded JSON envelope, validates every returned path and scope change, and carries only accepted text state forward. The repository itself is never mounted.

There is no host repository bind mount to remount, escape or accidentally make writable. Accepted state lives in host memory between commands; each new command reconstructs that state inside a new tmpfs workspace.

09 / CONTAINER BOUNDARY

M10 extends the reviewed M3 runner instead of inventing a second sandbox path.

Disposable workspace mode reuses the existing container planner and cleanup machinery. It requires a digest-pinned image, makes the root filesystem read-only, disables network, drops capabilities, forbids privilege escalation, and bounds CPU, memory, PIDs, disk, output and time. The container receives no host home, Docker socket, SSH agent, proxy secrets or repository mount.

One DockerWorkspace commandtext
one execute() call
├── serialize current text tree + argv as bounded JSON
├── send request over stdin
├── start one fresh container with the M3 runner
│   ├── immutable digest-pinned Node image
│   ├── read-only root filesystem
│   ├── /workspace and /tmp on bounded tmpfs
│   ├── network none · capabilities dropped · no-new-privileges
│   ├── 1 CPU · 512 MiB memory · 128 PIDs
│   └── no home, repo mount, Docker socket, SSH agent or proxy secrets
├── execute direct argv through the reviewed bootstrap
├── return { version: 1, files, result }
├── remove the container
└── validate the envelope before accepting the next in-memory tree

The reviewed Node 22+ bootstrap reconstructs files, spawns the exact argv without a shell, kills the process group on timeout, and scans the result without following links. The container is removed before execute() settles.

10 / RETURNED-STATE VALIDATION

Container output is untrusted until the host accepts every byte and path.

The bootstrap returns a versioned envelope containing the resulting text tree and command result. The host requires the exact expected keys and types, then revalidates paths, encoding, modes, link counts, file count, byte limits and changed-path scope. A malformed, oversized or out-of-scope result fails without becoming the next workspace state.

11 / PATCH, ARTIFACTS AND RETENTION

The adapter returns review material, not an opaque mutated worktree.

Disposal can export a bounded patch from the initial tree to accepted state plus only the artifact paths declared by the task. A caller may request one audited lease of at most one hour, but only when it supplies a lifecycle observer. The lease retains bounded state and outputs for inspection; it never keeps a container alive.

Workspace output lifecycletext
exportOutputs()
├── patch
│   └── bounded Git tree diff from initial to accepted state
└── artifacts
    └── only explicitly declared artifact paths

retain(ms)
├── can be called once
├── maximum lease: 1 hour
├── requires a lifecycle observer
├── retains accepted state and outputs
└── never retains a live container

dispose()
├── exports final bounded outputs
├── clears retained in-memory state
├── removes temporary host runner data
└── emits a lifecycle event

12 / LIFECYCLE EVIDENCE

Open, snapshot, retention, expiry and disposal are first-class events.

M9–M10 add a versioned workspace.lifecycle event. It records the backend and phase, with a snapshot identifier or expiry time where relevant. It intentionally excludes file contents, command vectors and credentials. That creates an audit spine without turning the event store into a second workspace or secret archive.

13 / TWO VERIFICATION LANES

Fast conformance stays offline; real isolation requires a real daemon.

The ordinary suite checks both adapters' contracts without requiring Docker, so PR and merge CI remain deterministic. A separate live suite starts real containers and attacks the boundary with links, out-of-scope writes, oversized output, timeouts, disk pressure, memory pressure, PID exhaustion, detached descendants, cancellation and retention expiry.

Different gates answer different questionsM9–M10 combine shared offline conformance, a branch-specific Harness exit gate, PR and exact-merge CI/CodeQL, and a separate local live-Docker lane. The scheduled/manual hosted Docker workflow exists but had no run at publication audit time, so local isolation evidence and hosted merge evidence remain separate.

14 / WHAT LIVE DOCKER FOUND

The daemon exposed lifecycle races the offline contract could not.

The first live pass found a disposal race around container completion. Tightening cleanup made removal deterministic before an operation settles. The same review cycle strengthened local opens against ancestor symlink substitution and changed cancellation from killing one process to killing the whole process group.

Those corrections are why a live lane is complementary to fakes: a fake can prove that the adapter asks for cleanup, but only the runtime can show whether container and process lifecycles actually close under interruption.

15 / MACHINE-READABLE SCOPE

The branch-specific exit gate tied 693 tests to an admitted 23-path change.

The retained run report records the task manifest, head and base identities, changed paths, zero scope violations and the full offline suite. Ten live tests were skipped in that lane by design. The resulting artifact is content-addressed and attached to the PR workflow, keeping branch evidence distinct from later publication checks.

Verification ledgertext
branch head                         b77a4e9638513f8cbd04b52f2b63a971e81c2600
merged main                         6e1e578747484bbad5a3651601c7b57854cc771f
pull request                        #11 · merged
changed paths                       23
diff                                +1,250 / -38

branch task exit gate
├── tests                           693 / 693 passed
├── test files                      43 passed · 1 skipped
├── live Docker tests               10 skipped by design
├── scope violations                0
└── retained artifact digest        sha256:884ae0cfcd3e4ab569b150ed5d268a327a856afad494e6ad9f31dea092ac0a79

publication live-Docker audit
├── exact merge                     6e1e578
├── tests                           10 / 10 passed
├── duration                        7.05 seconds
├── Docker                          Desktop 4.87.0 · Engine 29.7.2 · linux/arm64
└── remaining harness-* containers  0

16 / CHECKS-GATED DELIVERY

PR review state, exact-merge CI and CodeQL all resolve to public commits.

PR #11 merged branch head b77a4e9 into exact main commit 6e1e578. PR CI and CodeQL passed before merge; fresh CI and CodeQL runs passed on the exact merge. The merge workflow correctly skipped the branch-only task exit gate, so the retained gate artifact belongs to the PR run—not the merge run.

Public delivery recordtext
PR #11
├── title    feat(workspace): deliver M9 local and M10 Docker adapters
├── merged   2026-09-09 19:53:55 UTC
├── head     b77a4e9638513f8cbd04b52f2b63a971e81c2600
└── merge    6e1e578747484bbad5a3651601c7b57854cc771f

pull-request verification
├── CI       34397633607 · passed
├── CodeQL   34397629888 · passed
└── artifact gate-evidence-34397633607 · retained

exact-merge verification
├── CI       34397797152 · passed
└── CodeQL   34397797085 · passed

workspace-live workflow
├── manual + weekly Monday 03:43 UTC
└── hosted runs at publication audit: 0

17 / VERIFIED RESULT

The exact merge passes both the portable suite and a local hostile-fixture audit.

Hosted exact-merge CI passed strict TypeScript, all 693 offline tests and the golden fixture. For this publication, the focused Docker suite then passed 10 of 10 checks in 7.05 seconds on Docker Desktop 4.87.0, Engine 29.7.2, linux/arm64. No harness-* container remained, and the source repository was still clean at the exact merge afterward.

18 / CURRENT TRUTH

The strongest claims are specific—and leave the next integration work visible.

SurfaceWhat shipped or was observedWhat remains open
Backend selectionThe native selector defaults to Docker and requires a double opt-in for local mode.Pi TaskAgent and the legacy service do not yet use this selector; that integration is planned for M16.
Local safetyPaths, file identities, exact argv, time, output and post-command changes are bounded.LocalWorkspace is a trusted developer adapter, not isolation from hostile same-user processes.
Docker isolationEach command used a fresh constrained container and no host repository mount.This is tested containment, not a security certification or penetration-test result.
Filesystem domainBoth adapters operate on a bounded regular UTF-8 text tree.Binary files, executable files, links and special files are deliberately outside this milestone.
Allowed pathsOut-of-scope modifications are rejected before becoming accepted adapter state.allowed_paths limits writes and changes; it is not a filesystem read allowlist.
RetentionA bounded state/output lease can survive briefly for inspection and emits expiry evidence.No container is retained, and retention is not durable remote storage.
Live verificationTen hostile-fixture Docker tests passed locally against the exact merge.The new hosted scheduled/manual Docker workflow had not run at publication audit time.
PerformanceThe focused local live suite completed in 7.05 seconds on one disclosed setup.There was no load, throughput, latency, cost or concurrency benchmark.

19 / FILE GUIDE

The implementation is concentrated in one package and one reused sandbox runner.

20 / WHAT IS NEXT

Give agents canonical tools, then connect the chosen workspace to Pi.

M9 and M10 answer where an admitted effect can run. M11 is the next functional layer: a canonical read, search, edit, patch and test suite expressed through the Workspace capability. M16 is where native selection reaches Pi TaskAgent dispatch. Credential brokering remains a later, separate capability rather than a reason to pass host secrets into either adapter.

Forward milestone boundarytext
M11  canonical read / search / edit / patch / test tool suite
M16  make native workspace selection part of Pi TaskAgent dispatch
M35  broker short-lived credentials without returning host secrets

near-term operations
1     run and retain the hosted workspace-live lane
2     exercise adapters through the real agent loop
3     add controlled concurrency and resource-pressure measurements
4     keep local mode explicit as integration expands

21 / EVIDENCE LEDGER

Every delivery claim resolves to a public source or a disclosed local audit.

CONTINUE EXPLORING

Inspect the workspace adapters—and the runtime integration they deliberately leave open.

The pinned public merge contains the explicit LocalWorkspace and DockerWorkspace adapters, selector, bounded state transfer, lifecycle evidence, and isolation checks described here.