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 · 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 M1602 / 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.
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 fallback05 / 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.
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.
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_pathsAfter 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.
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 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 treeThe 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.
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 event12 / 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.
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.
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 016 / 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.
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: 017 / 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.
| Surface | What shipped or was observed | What remains open |
|---|---|---|
| Backend selection | The 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 safety | Paths, 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 isolation | Each command used a fresh constrained container and no host repository mount. | This is tested containment, not a security certification or penetration-test result. |
| Filesystem domain | Both adapters operate on a bounded regular UTF-8 text tree. | Binary files, executable files, links and special files are deliberately outside this milestone. |
| Allowed paths | Out-of-scope modifications are rejected before becoming accepted adapter state. | allowed_paths limits writes and changes; it is not a filesystem read allowlist. |
| Retention | A 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 verification | Ten hostile-fixture Docker tests passed locally against the exact merge. | The new hosted scheduled/manual Docker workflow had not run at publication audit time. |
| Performance | The 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.
Shared adapter contract
- packages/workspace/src/adapter-common.ts — bounds, tree validation, snapshots, patch construction and lifecycle helpers.
- packages/workspace/src/selector.ts — Docker default, explicit local opt-in and fail-closed construction.
Operational adapters
- packages/workspace/src/local.ts — descriptor-safe local file access, exact commands and scope checks.
- packages/workspace/src/docker.ts — sanitized capture, container requests, response validation and retained state.
- packages/workspace/src/container-program.ts — reviewed in-container reconstruction, direct execution and result scan.
Sandbox reuse
- services/sandbox-runner/src/plan.ts — disposable workspace plan, tmpfs and containment controls.
- services/sandbox-runner/src/executor.ts — process-group cancellation and bounded Docker client execution.
Proof and task scope
- packages/workspace/test/adapters.test.ts — shared and offline adapter conformance.
- packages/workspace/test/docker-live.test.ts — real-daemon hostile fixtures and cleanup assertions.
- .github/workflows/workspace-live.yaml — manual and scheduled hosted live lane.
- tasks/m9-m10-workspace-adapters.yaml — machine-readable milestone scope and exit criteria.
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.
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 expands21 / EVIDENCE LEDGER
Every delivery claim resolves to a public source or a disclosed local audit.
- Pull request #11 — reviewed delivery chronology and merge identity.
- PR CI run 34397633607 — strict typecheck, 693 offline tests, golden fixture and retained branch gate artifact.
- PR CodeQL run 34397629888 — Actions and JavaScript/TypeScript analysis on the branch head.
- exact-merge CI run 34397797152 — portable suite on
6e1e578. - exact-merge CodeQL run 34397797085 — Actions and JavaScript/TypeScript analysis on the merge commit.
- Publication audit — 10 of 10 focused live-Docker tests passed locally against the exact merge; the hosted live workflow had no run at audit time.