STAGE 1 / MILESTONE 3
Harness Platform M3: putting permission around the agent loop
How M3 wraps the Harness kernel in a typed WebSocket service with correlated approvals, a manifest-derived Docker plan, an OpenAI-compatible adapter, and explicit live-operation gates.
01 / MILESTONE CONTRACT
M3 puts a permissioned service boundary around the agent loop.
The local kernel can now run behind a typed WebSocket service, pause before side effects, accept one correlated operator decision, select a real provider adapter, and route a policy-authorized command into a manifest-derived Docker plan.
The milestone is not “the agent is now safe in production.” It is a narrower and more useful result: the control path from interactive client to model, policy, tool, audit event, and cleanup is explicit enough to test adversarially. Live provider calls, live containers, remote TLS, session recovery, and capacity evidence remain outside this release.
M3 — Services
├── agent-server: project-owned ACP over WebSocket
│ └── exactly one kernel run per session
├── sandbox-runner: one Docker container per run
│ ├── allowed_paths become non-widening mounts
│ └── network is none unless policy explicitly allows it
├── models: OpenAI-compatible provider adapter
└── TUI: live events + explicit permission ask flow
default evidence lane
├── real loopback WebSocket
├── injected provider HTTP responses
└── injected argv-only Docker executor
outside the default lane
├── live provider call
└── live Docker run02 / FROM M2 TO M3
M2 made evidence credible. M3 makes one run remotely operable.
M2 ended with a local kernel, a read-only board, opt-in telemetry, and a hardened MCP subprocess client. It deliberately stopped before a long-lived agent service, interactive permission negotiation, provider HTTP, or contained execution. M3 adds those seams without moving scheduling into the kernel or replacing the typed event stream.
M2 M3
──────────────────────────────────── ────────────────────────────────────
MCP stdio client ACP WebSocket agent service
message-shape ACP package versioned request + event contract
read-only terminal viewer interactive approval client
policy decisions in local flows kernel pauses on correlated asks
tool interfaces reviewed host-tool boundaries
Docker dev environment manifest-derived run sandbox
FakeModel protocol OpenAI-compatible provider adapter
local evidence surfaces redacted SQLite + live event streamThe architectural constraint still matters: the kernel remains a library that consumes a model, tool registry, budget, policy callbacks, and an event observer. WebSocket, terminal interaction, Docker, provider credentials, and SQLite stay outside it. That keeps the loop testable and lets each boundary fail without becoming a private kernel feature.
03 / ONE CHECKS-GATED DELIVERY
The whole permission handshake landed as one task contract and one checks-gated pull request.
Unlike M1 and M2’s branch chains, M3 is one cohesive implementation commit because the server, kernel, sandbox, provider adapter, and TUI share a single permission and execution boundary. Commit 6a6141d was merged through pull request #1 into public main at defbf7b.
Those numbers come from the public Git comparison from M2 to the M3 merge. The shared development page’s “157 files” summary does not match the committed range, so it is not used as release evidence.
04 / ACP WEBSOCKET SERVICE
A connection negotiates once; a session runs once; events stream in order.
@harness/acp now owns both the wire schemas and a bounded WebSocket client. Initialization requires the exact protocol version plus streaming and permissioning capabilities. The server advertises its model names and explicitly reports session replay as unavailable.
protocol version: harness/acp/1
request methods
├── initialize
├── session/new
├── session/prompt
├── permission/respond
└── session/cancel
server notification
└── session/event { sessionId, seq, event }
advertised M3 capabilities
├── streaming: true # ordered event notifications
├── permissioning: true
└── sessions: false # replay/resume is not implementedRequests and responses are strict JSON-RPC objects with bounded UTF-8 fields. The transport caps frames, pending requests, queued inbound messages, sessions per connection, and the time a created-but-unused session may live. Binary frames, unknown methods, invalid event envelopes, duplicate initialization, and unsupported capabilities become typed failures.
connection
├── initialize exactly once
├── at most 32 total sessions per connection by default
└── every session starts in created
session/prompt
├── atomically changes created → running before the first await
├── rejects a concurrent or second prompt
├── clamps requested budgets to manifest limits
├── streams redacted events with monotonic sequence numbers
└── ends completed | failed | canceled
disconnect or cancel
├── deny every pending permission
├── abort the model/tool path cooperatively
└── wait for in-flight cleanup before server shutdown05 / PERMISSION HANDSHAKE
An ask is now a real pause between intent and side effect.
Before this milestone, a headless CLI path could execute an ask decision even though the security contract said explicit approval was required. M3 moves authorization into the kernel’s tool-call path. The policy compiler returns allow, ask, or deny; only ask creates a permission record and suspends execution.
tool.call
policy.decision effect = ask
permission.requested single-use permissionId + callId + sessionId
... kernel is paused; the side effect has not run ...
permission.resolved decision = allow | deny
tool.result
deny paths
├── explicit no
├── invalid or missing terminal response
├── permission timeout
├── session cancellation
├── WebSocket disconnect
└── missing resolver in a headless runA permission ID is single-use and bound to the session, tool call, action, subject, and scope. Duplicate or stale responses are rejected. A run-scoped allow is cached only for that matching action and subject, which lets the inner sandbox check reuse an approval without turning it into a global grant. A hard deny is never overridable.
06 / INTERACTIVE TUI
The viewer becomes a protocol client without becoming a permissive shell.
harness-view connect validates a ws:// or wss://endpoint, rejects embedded URL credentials, requires TLS for non-loopback hosts, negotiates ACP, creates a session, streams events, and prompts when a permission request arrives. Only an explicit y or yes is approval. Non-interactive input, EOF, invalid input, confirmation-reader errors, and cancellation all deny.
function isExplicitAllow(answer) {
if (answer === undefined) return false
const normalized = answer.trim().toLowerCase()
return normalized === 'y' || normalized === 'yes'
}
// A non-interactive terminal returns false before reading stdin.The client also checks the stream instead of trusting presentation order. Sequence gaps, duplicate event IDs, a permission resolution that disagrees with the submitted decision, more than one terminal event, or a final result that conflicts with agent.stopped closes the session as a protocol failure. Terminal control characters and connection secrets are sanitized before rendering.
07 / DOCKER-PER-RUN BOUNDARY
The sandbox refuses rules Docker cannot express without widening them.
The runner does not invent policy. It compiles the same manifest rules as the rest of the harness, asks when required, and converts the effective decisions into an execution plan. Exact files and explicit directory/** entries may become writable mounts; traversal, absolute paths, unsafe wildcards, links, devices, sockets, nested filesystems, missing sources, and directory shapes that would authorize unnamed descendants fail before Docker is called.
manifest + workspace + command
→ compile process.exec, fs.read, fs.write, network
→ canonicalize every allowed_paths source
→ reject traversal, unsafe wildcard, link, device, socket,
nested filesystem, missing source, or widened directory scope
→ fingerprint selected mount identity, metadata, and tree shape
→ revalidate immediately before spawn
→ build Docker argv
docker run
--pull never
--read-only
--network <none|bridge>
--cap-drop ALL
--security-opt no-new-privileges=true
--pids-limit 128
--memory 512m
--cpus 1
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m
--user <workspace-owner-uid>:<workspace-owner-gid>The workspace itself is recursively read-only. Writable submounts cannot contain nested mounts. The runner fingerprints their identity, metadata, and tree shape, revalidates immediately before spawn, rejects root-owned workspaces, isolates the Docker client configuration, accepts only a local Unix socket, and does not forward host environment secrets or mount the Docker socket into the container.
manifest network decision
├── deny → Docker --network none
├── ask → explicit operator decision
│ ├── deny → none
│ └── allow → bridge
├── allow → ordinary Docker bridge
└── subject-pattern map → reject
Docker cannot express host-specific egress here without widening accessCleanup is part of the result, not a best-effort afterthought. The runner uses its private container-ID file when available. If that proof is missing or invalid, a deterministic name lookup is removable only when the private lease label matches. The runner emits sandbox.started only after Docker returns with an owned container identifier, and sandbox.stopped only after removal is verified. Cleanup uncertainty becomes a typed error and deliberately omits the stopped event.
08 / PROVIDER MODEL ADAPTER
“OpenAI-compatible” becomes a bounded translation layer, not a fetch wrapper.
OpenAICompatibleModel implements the existing model protocol without making the kernel provider-aware. It normalizes messages, tools, provider options, token limits, tool-call arguments, finish reasons, and usage. The service reads credentials only at its process boundary; keys, organization IDs, and project IDs never enter ACP requests, manifests, events, or sandbox environments.
construction
├── absolute http:// or https:// base URL
├── plaintext only for loopback
├── credentials require HTTPS
└── model, headers, sizes, and timeout are bounded
one completion
├── POST /chat/completions with stream: false
├── messages + tools + provider options normalized to bounded JSON
├── redirect: error
├── request and response size limits
├── caller cancellation + wall-clock timeout
├── tool calls and finish reason cross-checked
└── usage checked or conservatively estimated
no implicit retry loop: one request remains one observable model turnThe adapter bounds JSON depth and node count, message and tool arrays, request and response bytes, stream chunks, identifiers, headers, and endpoint length. It refuses plaintext credentials, redirects, legacy function_call responses, malformed tool JSON, duplicate call IDs, contradictory finish reasons, and inconsistent usage totals. Provider errors are converted to sanitized typed failures with a retained request ID when safe.
09 / REDACTION AND AUDIT
The value persisted to SQLite is the value allowed onto the wire.
Tool arguments and results are model-controlled data. Before an event crosses the service boundary, the event package makes a non-mutating copy, replaces credential-shaped fields and common inline secrets, bounds recursive traversal, and round-trips the candidate through the event schema. The agent server then starts the SQLite append and emits that same safe object as the next ACP notification.
untrusted tool/model value
→ bounded JSON normalization
→ redact credential-shaped keys and inline token patterns
→ schema round-trip the copied harness event
→ append safe event to SQLite
→ emit the same safe event over ACP with sequence number
redaction failure
→ abort the session
→ emit sanitized EVENT_REDACTION_FAILED
This is a deterministic process-boundary guard, not general DLP.Persistence and transport failures abort the session rather than silently dropping audit history. With sandboxing enabled, the SQLite file must live outside the mounted workspace, so even a broad reviewed path rule cannot hand the container its own audit database.
10 / WHAT BROKE
The hard bugs lived between correct components.
The shared development record is most useful as chronology: it shows the implementation repeatedly passing a focused slice and then failing where two boundaries met. The public source, tests, and merged commit remain the authority for what ultimately shipped.
- 01
Headless ask was not actually fail-closed
The pre-M3 CLI could execute an ask decision. The fix moved permission resolution into the kernel contract, so a missing resolver now denies instead of inheriting an interface-specific shortcut.
- 02
“Allow for this run” was not cached for the inner boundary
The sandbox could prompt twice when it rechecked the same approved
process.execaction and subject. A run-scoped key now suppresses only that duplicate process ask; filesystem and network decisions remain independent. - 03
Canceling a prompt could leave permission evidence dangling
Cancellation now resolves every pending permission as deny, aborts cooperatively, closes late-opening sockets, and waits for tool cleanup before shutdown finishes.
- 04
A correct path at planning time could change before spawn
Symlinks, hard links, nested mounts, and identity changes forced a second synchronous validation immediately before Docker plus conservative rejection of shapes that cannot be represented exactly.
- 05
Audit code could itself fail on hostile values
Deep, cyclic, proxy-backed, BigInt, or oversized tool values could break event generation after a side effect. JSON normalization, depth/node/byte limits, and a sanitized fatal audit error now close that gap.
- 06
The harness blocked its own package-manager metadata
The first M3 exit-gate attempt saw
.pnpm-store/v11/index.dboutside the manifest scope. The solution redirected pnpm’s store metadata to temporary storage; it did not widenallowed_pathsto make the gate pass.
11 / VERIFIED RESULT
Three evidence classes answer three different questions.
At the exact public merge commit, a fresh source checkout passed all 333 tests in 24 files, strict TypeScript, the one deterministic golden scenario, and the M3 manifest validator. The suite includes a real loopback WebSocket integration while provider and Docker behavior stay behind injected offline boundaries.
$ git checkout defbf7bcf72fc72452b4adc81b099f3fc6c523cf
$ pnpm test
Test Files 24 passed (24)
Tests 333 passed (333)
$ pnpm typecheck
# exit 0
$ pnpm evals
kernel-0001-golden · 1 step · 5 events · 37 tokens
1/1 scenarios passed
$ pnpm harness validate tasks/m3-services.yaml
valid task manifest: m3-services
public post-merge CI
├── frozen install
├── strict typecheck
├── 333 tests
├── 1/1 golden eval
└── canonical kernel-0001 exit gateThe public post-merge CI run repeated frozen installation, typecheck, all tests, the golden eval, and the canonicalkernel-0001 exit gate. The parallel CodeQL run passed. That scan is useful evidence, not a claim that vulnerabilities are impossible.
12 / CURRENT TRUTH
The service boundary is implemented; the operating system around it is not.
| Surface | What the evidence supports | What remains open |
|---|---|---|
| ACP transport | The repository owns a bounded JSON-RPC-over-WebSocket contract and tests it through a real loopback socket. | ACP is project-owned, not proof of compatibility with an external agent-protocol ecosystem. |
| Session lifecycle | A session accepts one atomic kernel run; cancellation, duplicate prompts, limits, and shutdown are explicit. | Replay, resume, reconnection, distributed ownership, and scheduling remain M4. |
| Permissioning | Ask decisions pause and require one correlated allow; all missing or interrupted responses deny. | There is no multi-party approval policy, delegated operator identity, or remote authorization service. |
| Sandbox plan | The manifest becomes a fail-closed Docker argument vector with mount, identity, resource, and cleanup checks. | The default suite injects an executor. It does not launch a real image or prove Docker-daemon isolation. |
| Provider adapter | Fifty focused tests cover mapping, hostile payloads, bounds, errors, cancellation, and credential handling. | No live provider was called; model-token streaming and automatic retries are deliberately absent. |
| Audit stream | Redacted events are ordered into local SQLite and the live ACP stream from the same service boundary. | The redactor is not general DLP, and local SQLite is not a durable distributed audit service. |
| Remote exposure | Loopback is the default; non-loopback binding requires a token and an explicit plaintext acknowledgement. | The service itself does not terminate TLS, and query-token logging must be controlled by the proxy. |
| Operational evidence | Hosted CI, CodeQL, fresh offline checks, and one task-specific development gate passed. | There is no live Docker/provider smoke, remote deployment proof, load test, soak test, or capacity number. |
The right description is permissioned local agent service boundary. It is not yet a hosted multi-tenant control plane, a production container isolation service, a provider benchmark, or a replay-safe distributed agent platform.
13 / FILE GUIDE
The code is large, but each trust boundary has one obvious home.
Protocol and service
- packages/acp/src/protocol.ts — strict JSON-RPC methods, results, events, and bounds.
- services/agent-server/src/connection.ts — one-run sessions, permissions, persistence, and event streaming.
- services/agent-server/src/websocket.ts — frame, queue, authorization hook, origin, and close behavior.
Execution boundary
- services/sandbox-runner/src/mounts.ts — canonicalization, non-widening patterns, and fingerprints.
- services/sandbox-runner/src/plan.ts — policy enforcement and Docker argv construction.
- services/sandbox-runner/src/runner.ts — lifecycle, cancellation, ownership, and cleanup verification.
Model and operator
- packages/models/src/openai-compatible.ts — provider translation, bounds, errors, and credential hygiene.
- apps/tui/src/interactive.ts — connect flow, stream validation, prompt decisions, and cancellation.
- packages/events/src/redact.ts — deterministic process-boundary redaction.
Contracts and proof
- tasks/m3-services.yaml — scope, permissions, budget, and acceptance.
- SECURITY.md — trusted inputs, container assumptions, and remote transport limits.
- EVENTS.md — permission and sandbox lifecycle ordering.
14 / WHAT IS NEXT
M4 has to turn a safe local session into a recoverable system.
15 / EVIDENCE LEDGER
Conversation for chronology; pinned source and reruns for claims.
The owner-supplied shared conversation establishes the development order, audit findings, and the package-store failure. Its private URL and private model deliberation are not part of this public note. Claims about shipped behavior come from the pinned Git tree, its tests, the merged pull request, hosted checks, and fresh read-only verification.