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.

Stage 1 / Milestone 3 contract (condensed)text · source
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 run

02 / 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.

Boundary shift from M2 to M3text
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 stream

The 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.

M3 wraps the local loop in one permissioned service pathThe interactive client speaks a project-owned JSON-RPC protocol to one agent-server connection. Each session can start one kernel run, which selects a model and exposes only reviewed host or Docker-backed tools. Sanitized events are persisted and streamed from the same boundary.

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.

65changed files
13,615insertions
352deletions
1 PRmerged delivery

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.

Project-owned ACP v1 surfacetext · source
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 implemented

Requests 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.

Agent-server session state (condensed)text · source
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 shutdown

05 / 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.

Canonical permission event ordertext · source
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 run

A 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.

Permission asks are a fail-closed state machineAn ask decision is a pause, not permission. Only one correlated allow response can resume the side effect. Denial, timeout, cancellation, disconnect, EOF, or a missing resolver all converge on the denied result.

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.

Fail-closed terminal confirmation (abridged)typescript · source
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-to-container plan (condensed)shell · source
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.

Network decision without false precisiontext · source
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 access

Cleanup 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.

A manifest becomes a Docker plan only when every boundary is representableThe runner first proves that a manifest rule can be represented without widening access. It canonicalizes mount sources and fingerprints their identity, metadata, and tree shape; rechecks them immediately before spawn; builds a fixed Docker argument vector; and verifies ownership before cleanup.

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.

OpenAI-compatible request boundary (condensed)text · source
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 turn

The 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.

One safe event for persistence and transporttext · source
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.

  1. 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.

  2. 02

    “Allow for this run” was not cached for the inner boundary

    The sandbox could prompt twice when it rechecked the same approved process.exec action and subject. A run-scoped key now suppresses only that duplicate process ask; filesystem and network decisions remain independent.

  3. 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.

  4. 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.

  5. 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.

  6. 06

    The harness blocked its own package-manager metadata

    The first M3 exit-gate attempt saw .pnpm-store/v11/index.db outside the manifest scope. The solution redirected pnpm’s store metadata to temporary storage; it did not widen allowed_paths to 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.

Verification at the M3 mergeshell
$ 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 gate
333 / 333workspace tests
24 / 24test files
1 / 1golden scenario
2 workflowsCI + CodeQL green

The 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.

M3 has strong control-path evidence, not deployment evidenceThe public repository and CI prove the typed service control path using loopback sockets and injected provider and Docker boundaries. The M3-specific dogfood report is development-session evidence. Real provider, real container, remote TLS, recovery, and capacity evidence are still open.

12 / CURRENT TRUTH

The service boundary is implemented; the operating system around it is not.

SurfaceWhat the evidence supportsWhat remains open
ACP transportThe 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 lifecycleA session accepts one atomic kernel run; cancellation, duplicate prompts, limits, and shutdown are explicit.Replay, resume, reconnection, distributed ownership, and scheduling remain M4.
PermissioningAsk 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 planThe 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 adapterFifty 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 streamRedacted 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 exposureLoopback 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 evidenceHosted 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.

Contracts and proof

14 / WHAT IS NEXT

M4 has to turn a safe local session into a recoverable system.

  1. 01

    Make session ownership durable

    Move session and event state from file-local SQLite to Postgres with replay-safe ownership, idempotent resume, and explicit recovery after process failure.

  2. 02

    Schedule work without dissolving Git truth

    Add the control plane, task-state copy, artifact registry, and signed object-storage access while keeping the reviewed manifest authoritative.

  3. 03

    Prove the real operating lanes

    Retain evidence from a reviewed image, real Docker cleanup, a live provider, remote TLS, failure injection, and bounded load before making isolation or capacity claims.

  4. 04

    Export an audit trail that survives the service

    Project the append-only event stream into durable object storage without creating a second mutable truth or leaking model/tool secrets.

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.

CONTINUE EXPLORING

Inspect the service boundary—and the live operating lanes it leaves open.

The pinned public merge contains the project-owned ACP service, kernel permission handshake, manifest-derived Docker plan, OpenAI-compatible adapter, interactive TUI, and offline evidence described here.