STAGE 1 / MILESTONE 8

Harness Platform M8: making workspace authority an injected capability

How M8 moves filesystem and process authority behind one injected Workspace contract, restricts tools to reviewed operations, and blocks direct host access without shipping an adapter early.

01 / MILESTONE CONTRACT

M8 does not open the workspace. It decides where that authority is allowed to live.

M7 proved that recorded intent and authorization can stand before a tool effect. M8 gives future filesystem and process effects one explicit place to enter that loop: an injected, snapshotted Workspace capability whose operations can be narrowed before a model-facing tool receives them.

The milestone is architecture made executable. Kernel and tool code can no longer reach the covered host modules directly without failing the offline import gate; model context never receives the Workspace object; and the service refuses to promise a tool when it has no adapter. The deliberately missing piece is equally important: M8 defines authority without implementing local or container-backed authority.

Stage 1 / Milestone 8 contract (condensed)text · source
M8 — Enforced workspace capability boundary

ship
├── one canonical Workspace contract in @harness/workspace
├── strict operation request and result dispatch
├── snapshotted, receiver-bound injected capabilities
├── frozen one-operation views for reviewed tools
├── capability-aware admission across both kernel paths
├── fail-closed Agent Server admission without an adapter
└── an offline host-import boundary for kernel and tools

preserve
├── M7 durable intent → policy → optional permission → effect order
├── legacy string workspace identity in runAgent events
├── the lexical WorkspacePathScope helper as a separate seed
└── typed tool observations for boundary failures

defer
├── LocalWorkspace and host path/link/race enforcement to M9
├── DockerWorkspace and resource isolation to M10
├── the five canonical development tools to M11
├── service lifecycle wiring and automatic disposal
└── live model, host-I/O, load, and production security proof

02 / AUTHORITY BOUNDARY

The agent receives a verb; trusted outer code supplies the authority behind it.

A model sees a reviewed tool name and JSON schema. If it asks for that tool, the M7 loop first records the intention and the policy decision. Only an allowed workspace call can receive a capability view, and that view exposes one declared operation rather than the full injected object. This keeps provider language, policy identity, and operational authority in separate layers.

One injected capability separates agent logic from host authorityAt M8 merge d14fc13, model output reaches filesystem or process authority only through a reviewed tool, the durable M7 policy fence, and a frozen one-operation view of an injected Workspace. No Workspace object enters model context, and M8 supplies no host adapter.
Lexical path scope versus operational capabilitytext · source
WorkspacePathScope                  Workspace
────────────────────────────────  ───────────────────────────────────────
root: absolute lexical identity   no host root or implementation selector
resolvePath(requested)            readFile(path)
                                  writeFile(path, contents)
                                  listFiles(path)
                                  execute({ argv, cwd?, timeoutMs?, signal? })
                                  diff()
                                  snapshot()
                                  dispose()

openWorkspace(root)
└── resolves lexical paths under root
    · performs no read, write, or process operation
    · does not become the capability supplied to a tool

The older openWorkspace() helper still resolves lexical paths against an absolute root. M8 renames that shape conceptually as WorkspacePathScope so it cannot be mistaken for something that reads, writes, executes, snapshots, or disposes. A future adapter may reuse its logic, but the helper alone is not a security boundary against links or filesystem races.

03 / WORKSPACE CONTRACT

Seven methods describe what an operational workspace may eventually do.

@harness/workspace now owns the canonical interface. The kernel re-exports these types for compatibility instead of maintaining a second definition. Paths remain transport-neutral strings, commands are argument vectors rather than shell strings, and snapshots carry an identifier, timestamp, and optional JSON metadata.

Canonical Workspace surface (condensed TypeScript)typescript · source
export interface Workspace {
  readFile(path: string): Promise<string>
  writeFile(path: string, contents: string): Promise<void>
  listFiles(path: string): Promise<string[]>
  execute(command: {
    argv: readonly [string, ...string[]]
    cwd?: string
    timeoutMs?: number
    signal?: AbortSignal
  }): Promise<CommandResult>
  diff(): Promise<string>
  snapshot(): Promise<WorkspaceSnapshot>
  dispose(): Promise<void>
}

WORKSPACE_CAPABILITIES = [
  'readFile', 'writeFile', 'listFiles', 'execute',
  'diff', 'snapshot', 'dispose',
]

The contract intentionally does not expose a host root, provider name, or implementation selector. That prevents model-facing code from branching on “local versus Docker” and keeps those choices at the launch boundary. It also means the interface alone does not promise containment, atomic writes, bounded output, or cleanup; adapters must earn those properties in later milestone gates.

04 / STRICT OPERATION DISPATCH

Both sides of an adapter call are treated as untrusted data shapes.

invokeWorkspaceOperation() accepts unknown, selects one of the seven canonical names, and validates the exact request shape before it touches the adapter. Paths and contents must be strings; argv must contain a nonempty program followed only by strings; timeouts are nonnegative safe integers; and an optional signal must be a native AbortSignal.

Strict dispatch turns an object boundary into a typed protocolM8's dispatcher accepts unknown input, checks exact own data properties, snapshots arguments, invokes one bound method, and validates the returned value. Required, malformed, unknown, and unsupported requests are typed failures; malformed input never reaches an adapter method.
Workspace operation admission and return validationtext · source
invokeWorkspaceOperation(workspace, unknownRequest)
  → require a non-array object and an exact own-key set
  → require one canonical operation name
  → reject accessors, symbols, exotic arrays, and malformed fields
  → copy caller-owned request data
  → brand + compose a supplied native AbortSignal
  → invoke exactly one captured, receiver-bound method
  → validate and detach the operation-specific result

typed failures
├── WORKSPACE_OPERATION_REQUIRED
├── WORKSPACE_OPERATION_MALFORMED
├── WORKSPACE_OPERATION_UNKNOWN
└── WORKSPACE_OPERATION_UNSUPPORTED

Results are checked too. File reads and diffs must return strings, listings must be dense string arrays, command results must have the declared exit/output shape, snapshot metadata must be detached JSON, and write/dispose must resolve to undefined. Typed Workspace errors thrown by an implementation remain typed; malformed returns do not get laundered into success. Snapshot metadata has structural, cycle, depth, and array-count checks, but no total byte cap; createdAt is checked only as a nonempty string.

05 / LEAST-PRIVILEGE VIEWS

The run snapshots all methods; each tool receives only one.

bindWorkspace() reads all seven methods synchronously, receiver-binds them, and freezes the resulting facade. Replacing a method on the caller-owned object after the run begins cannot redirect the captured capability. The implementation's own receiver and internal state remain shared, which is necessary for a real adapter and why binding must not be described as sandboxing.

Binding once and granting one operationtypescript · source
const runWorkspace = bindWorkspace(callerWorkspace)
// all seven methods captured and receiver-bound synchronously

const readOnlyView = restrictWorkspace(runWorkspace, 'readFile')

await readOnlyView.readFile('README.md')  // delegates
await readOnlyView.listFiles('.')         // typed unsupported error
await readOnlyView.writeFile('x', 'y')    // typed unsupported error
await readOnlyView.execute({ argv: ['sh'] }) // typed unsupported error

// the frozen facade prevents method replacement through this reference;
// the adapter's own internal state remains shared and caller-owned

restrictWorkspace() builds another frozen facade. Its selected method delegates through the strict dispatcher; every sibling method rejects with WORKSPACE_OPERATION_UNSUPPORTED without consulting the underlying adapter. This is object-capability least privilege inside the process, not operating-system isolation from code that already has ambient authority.

06 / RUNTIME INJECTION

Capability presence changes what the model is allowed to see.

MinimalAgentRuntime accepts an optional operational Workspace on RunInput and binds it while snapshotting the run. Its model context includes a workspace-bound tool definition only when that capability exists. Pure tools continue to appear without one and receive no Workspace in their execution context.

No adapter means no usable workspace toolM8 makes capability availability explicit in both kernel paths. MinimalAgentRuntime advertises a workspace tool only when a Workspace was injected; legacy runAgent keeps string identity and operational authority separate; the M3 Agent Server rejects workspace tools until M9 wires an adapter.
Capability-aware model-tool admissiontext · source
MinimalAgentRuntime
├── RunInput.workspace?: Workspace
├── snapshotRunInput() binds the capability before the run begins
├── no Workspace → omit workspace tools from model definitions
├── Workspace + reviewed tool + PermissionController
│   └── durable intent/policy → one-operation view → execution
└── forced workspace call without capability or permission
    └── typed denial · no workspace invocation

pure tool
└── receives no Workspace object, even when the run has one

Absence is defended twice. Filtering prevents normal advertisement; if a model adapter nevertheless emits a call for a registered workspace tool, the runtime records its intent and returns a typed required-capability denial without invoking Workspace. A capability without a permission controller also fails closed—M8 does not inherit the pure-tool auto-allow rule for filesystem or process authority.

07 / REVIEWED TOOL BOUNDARIES

M8 routes one legacy read seam; it does not quietly ship the M11 tool surface.

Tool execution boundaries are normalized into frozen WeakMap-held records. The workspace variant accepts only reviewed read operations—readFile, listFiles, diff, or snapshot—plus nonempty root metadata. Write and execute exist on the generic Workspace contract but cannot be named by this M8 model-facing boundary.

Reviewed boundaries and the actual M8 tool surfacetypescript · source
type ToolExecutionBoundary =
  | { kind: 'pure' }
  | {
      kind: 'workspace'
      access: 'read'
      capability: 'readFile' | 'listFiles' | 'diff' | 'snapshot'
      root: string
    }
  | { kind: 'sandbox'; root: string }

M8 model-facing surface
├── no new canonical tool names
├── existing read_file → injected Workspace.readFile
├── result remains capped at 128 KiB after the adapter returns it
└── fs.read / fs.list / fs.write / process.exec / git.diff remain M11

The pre-existing read_file tool now calls injected Workspace.readFile rather than importing the host filesystem. It retains itsfs.read authorization intent and checks that returned UTF-8 content is no more than 128 KiB after the adapter has already returned it. That check does not bound the underlying read. readFile also has no signal parameter, and this tool does not forward its execution-context signal, so a non-cooperative adapter may continue after the runtime is canceled. The planned canonical dotted tools do not arrive until M11, when adapter containment and bounded process semantics exist beneath them.

08 / IDENTITY AND CAPABILITY

The legacy loop keeps a workspace name without confusing it for authority.

Earlier event and service contracts use a string workspace identity. Removing or repurposing that field would rewrite their meaning. M8 therefore gives legacy runAgent a separate workspaceCapability option while the newer runtime's RunInput.workspace becomes the operational object.

Workspace identity and capability across both kernel pathstext · source
streaming MinimalAgentRuntime
└── RunInput.workspace = operational Workspace capability

legacy runAgent
├── RunOptions.workspace = stable string identity for events/transports
├── RunOptions.workspaceCapability = separate operational Workspace
└── registered definitions remain visible; missing capability/policy denies use

both paths
├── snapshot bound methods before model work
├── require an injected capability for workspace tools
├── require explicit permission for workspace effects
├── pass only a restricted view into the tool
└── leave disposal to the caller

Both paths apply the same substantive boundary: capture the capability, require a reviewed tool marker, derive authorization only after valid arguments, persist policy before the effect, and pass a one-operation view. The caller that created the Workspace remains responsible for disposal; a completed run does not silently destroy a capability it may not own exclusively. Their advertisement differs: MinimalAgentRuntime filters out workspace definitions when capability is absent, while legacy runAgent keeps registered definitions visible and denies the requested call at execution admission.

09 / FAIL-CLOSED SERVICE ADMISSION

The Agent Server refuses a workspace tool it cannot honestly execute.

The M3 WebSocket service still receives a string workspace identity and has no operational adapter lifecycle. Before M8, it could review a workspace-root marker for the legacy host reader. After M8 removed direct host I/O from that tool, admitting it would advertise a function whose required capability was absent.

Agent Server admission between M8 and M9text · source
M3 Agent Server admission at M8

session request
├── workspace = string identity
└── requested host tools
    ├── pure boundary       → may be admitted
    ├── sandbox boundary    → existing reviewed checks apply
    └── workspace boundary → reject session
                              "operational Workspace adapter is required"

reason
└── the service cannot advertise a workspace tool it cannot execute

M9
└── constructs and lifecycle-manages an explicit LocalWorkspace adapter

10 / HOST-IMPORT GUARD

A direct filesystem shortcut now leaves a failing architectural trace.

An offline fixture parses JavaScript-family production sources under kernel and tools with the TypeScript AST. It recognizes static imports, re-exports, import types, literal dynamic imports, and literal require calls for the Node filesystem and child-process module names. The test also refuses symlinked source entries while walking those trees.

The source tree makes host access visible at the architectural edgeAn offline TypeScript-AST fixture scans production sources in packages/kernel and packages/tools for direct filesystem and child-process imports. Those layers must cross @harness/workspace; packages/workspace and explicit trusted outer adapters remain the places where host APIs may later be adapted.
Covered source roots, module names, and syntax formstext · source
production roots scanned
├── packages/kernel/src
└── packages/tools/src

forbidden direct modules
├── node:fs             · fs
├── node:fs/promises    · fs/promises
└── node:child_process  · child_process

AST forms recognized
├── static import and re-export
├── import type
├── literal dynamic import(...)
└── literal require(...)

allowed authority edges
├── packages/workspace adapter layer
└── explicit trusted CLI/service infrastructure

The real M8 tree passes with no covered imports in either production root. That is useful architectural enforcement, but its scope matters: it is a test, not a runtime interceptor; it does not analyze transitive packages or prove that computed loading, globals, eval, or malicious code lack ambient process authority. Docker isolation remains later work.

11 / ABORTSIGNAL HARDENING

The first green-looking design failed on the project's actual Node 22 boundary.

Initial CI on b6ce977 stopped at 657 of 658 tests. A structurally plausible or revoked signal could cross normalization until platform code touched its native internal slots. That made the boundary depend on duck typing precisely where cancellation identity must be trustworthy.

From failing Node 22 signal case to final headtext · source
initial implementation  b6ce977
initial CI              33645319615
result                  657 / 658 tests passed

failure
└── a duck-typed or revoked AbortSignal could cross the dispatcher
    · structural checks were not a native-platform brand check

correction               fa8da7f
├── call the intrinsic AbortSignal.prototype.aborted getter
├── compose the accepted signal with AbortSignal.any
├── preserve future cancellation
└── reject impostors before Workspace.execute is invoked

final PR-head CI         658 / 658 passed

The follow-up invokes the intrinsic AbortSignal.prototype.aborted getter as a brand check, then uses AbortSignal.any to create an unshadowed platform signal that still tracks future aborts. Duck-typed and revoked candidates now fail with a typed malformed-operation error before Workspace.execute is called. Final-head CI passed all 658 tests.

12 / MACHINE-READABLE SCOPE

The task could change the capability seam, but it could not use the network or push.

The M8 manifest permits workspace, tool, and kernel packages; the narrow Agent Server admission files; the lockfile; three architecture/status documents; and the task itself. Its command allowlist includes offline installation, targeted package tests, the full suite, types, evals, validation, and the Harness exit gate. Network and Git push remain denied to the development agent.

M8 task manifest (abridged)yaml · source
id: m8-workspace-capability-boundary
goal: enforce the operational filesystem and process capability boundary

allowed_paths:
  - packages/workspace/**
  - packages/tools/**
  - packages/kernel/**
  - services/agent-server/src/connection.ts
  - services/agent-server/test/agent-server.test.ts
  - pnpm-lock.yaml
  - ARCHITECTURE.md
  - ROADMAP.md
  - README.md
  - tasks/m8-workspace-capability-boundary.yaml

permissions:
  network: deny
  git.push: deny

delivery:
  type: pull_request

The retained report checks all 19 changed paths both before and after tests and records zero violations. Its 100,000-model-token and 200-tool-call limits govern the task-authoring run; they are not runtime defaults or evidence about Workspace throughput.

13 / DELIVERY CHRONOLOGY

Final-head automation was green before merge; repository rules did not require it.

Pull request #9 opened at 14:56:25 UTC on September 2, 2026 from initial implementation b6ce977. The failing signal case produced a second commit fa8da7f. Final-head CI and CodeQL completed successfully before the PR merged at 15:02:58 UTC as d14fc13.

Authoritative M8 release boundarytext · source
base                 9e535b696a742a8aea4b6f1e15a377f3d19a6672
implementation       b6ce9773f8ee228f360e74aef5506ca8096f8689
Node 22 correction   fa8da7f95d4d25e121ff709349c420b7206ec626
merge                d14fc13e299a6718d9e8a98ba9e028b320cd5f53

pull request #9
├── 19 changed files
├── 2,642 insertions
├── 446 deletions
├── 2 feature-branch commits
└── final-head CI and CodeQL green before merge

Main was unprotected and no human approval is visible. The accurate statement is that green final-head checks preceded this merge and separate workflows reverified the exact merge—not that branch protection or peer review compelled the sequence. The lockfile changes connect existing workspace packages; the M8 diff adds no external dependency.

14 / VERIFIED RESULT

Public head and merge workflows agree on the offline result.

Final-head CI passed strict TypeScript, 658 tests across 42 files, and one golden kernel scenario. The exit gate checked the 19-path diff before and after the suite with zero violations. Both CodeQL jobs passed, and its differential result reported no new alert in changed code. Exact-merge CI and CodeQL then passed independently.

Public and independently reproduced M8 verificationtext · source
public final-head evidence · fa8da7f
├── strict TypeScript                         passed
├── test files                                42 / 42
├── repository tests                         658 / 658
├── golden scenarios                           1 / 1
├── changed paths checked before / after      19 / 19
├── path-policy violations                     0
├── CI run                            33645737911
└── CodeQL workflow                   33645731987

exact-merge evidence · d14fc13
├── CI                                 33646021258 · passed
├── CodeQL                            33646020469 · passed
└── clean local audit · Node 24.18.0
    ├── tests                                  658 / 658
    ├── strict TypeScript                     passed
    └── golden scenarios                        1 / 1

A clean local publication checkout of the exact merge reproduced 658 of 658 tests, strict types, and one of one eval under Node 24.18.0 with the project install reporting pnpm 11.15.1. That local run is unretained supporting evidence. None of these lanes constructed a host adapter or measured filesystem safety, capacity, latency, or throughput.

15 / EVIDENCE ARTIFACT

The retained gate artifact belongs to the feature head and expires.

Final-head workflow 33645737911 uploaded gate-evidence-33645737911. Its run-report/v2 pins fa8da7f against base 9e535b6, records the 658-test result, both path checks, zero violations, and a passed outcome. It is not the exact-merge workflow and is retention-bound until December 1, 2026.

Retained PR-head artifact recordtext · source
gate-evidence-33645737911
├── artifact ID     9852721940
├── feature head    fa8da7f95d4d25e121ff709349c420b7206ec626
├── base            9e535b696a742a8aea4b6f1e15a377f3d19a6672
├── digest          sha256:353da9de5303c8196a217785652f411ad778c4aa4350796a5db03bbe2dd8f431
├── expires         2026-12-01
├── report          run-report/v2 · passed
├── tests           658 passed · 18,609 ms recorded whole-suite time
├── paths           19 before + 19 after · zero violations
├── JSON events     7 · includes delivered + run.recorded
└── SQLite events   5 · session remains active

The JSON report contains seven serialized events, including delivery and run.recorded. Its SQLite companion contains five events and still marks the session active, so this note does not call that database a closed terminal log. The report field 18,609 ms is whole-suite timing produced by one CI run—not a benchmark.

16 / CURRENT TRUTH

M8 narrows authority in code while leaving the dangerous implementation work visible.

SurfaceWhat the evidence supportsWhat remains open
ContractOne canonical interface defines seven operational filesystem, process, review, snapshot, and lifecycle methods.M8 supplies no LocalWorkspace, DockerWorkspace, host implementation selector, or automatic fallback.
Lexical scopeThe existing openWorkspace helper retains an absolute lexical root and rejects a resolved path outside it.It is not an operational implementation and does not by itself prove symlink, hard-link, descriptor-race, or adapter safety.
BindingThe runtime captures and receiver-binds all seven methods, then freezes the facade before model work.The adapter object and its internal state remain trusted and shared; binding is not isolation from malicious implementation code.
DispatchKnown operations receive copied, strictly shaped requests and checked results; invalid input fails before invocation.Not every string or adapter output has a byte limit at this generic layer; arbitrary adapter errors become generic kernel tool failures.
Tool authorityA workspace tool receives a frozen view delegating only its declared operation after durable policy and permission.M8 adds no five-tool development surface, and the restricted JavaScript object is not an operating-system sandbox.
read_fileThe legacy read_file seam delegates I/O through injected Workspace.readFile and checks its returned text size.Its 128-KiB check happens after the read; it forwards no cancellation signal, forwards path shapes to the adapter, and strips extra keys despite a stricter schema.
Service pathAgent Server rejects workspace-bound tools while it has only a string workspace identity.The M3 service cannot execute M8 workspace tools until M9 creates and manages a real adapter.
Import guardAn offline AST fixture found no covered direct filesystem or child-process import in kernel/tools production sources.It is not runtime interception, dependency analysis, or proof against computed loading, eval, globals, or malicious packages.
VerificationFinal-head and exact-merge automation passed 658 tests, strict types, one golden scenario, and CodeQL.No live host adapter, model provider, load, latency, throughput, capacity, penetration, or production-isolation test ran.
Review and deliveryGreen final-head checks preceded merge and exact-merge CI plus CodeQL passed afterward.Main was unprotected, no human approval is visible, and Copilot changes-recommended findings remain unresolved in the merge.

The precise claim is an injected and least-privilege Workspace protocol, enforced in the kernel/tool source architecture and proven offline with deterministic fake adapters. It is not a safe local executor, disposable sandbox, complete development-tool surface, live service capability, or production security certification.

17 / FILE GUIDE

The contract, dispatch, runtime wiring, source guard, and open review are inspectable.

18 / WHAT IS NEXT

The next three milestones turn a safe-shaped port into usable, bounded authority.

M8 deliberately lands before implementation because every later adapter and tool can now target one contract. M9 must build trusted local behavior with path, link, race, I/O, cancellation, diff, snapshot, and lifecycle gates. M10 must make Docker the default native selector without an automatic local fallback. M11 can then expose exactly five development tools through the same durable policy loop.

Post-M8 self-hosting sequence (condensed)text · source
M8   canonical Workspace capability boundary                complete
M9   trusted developer LocalWorkspace                        planned
M10  disposable DockerWorkspace                              planned
M11  fs.read · fs.list · fs.write · process.exec · git.diff  planned
M12  steering and follow-up turns                            planned
M13  context accounting and compaction                       planned
M14  durable replay and checkpoints                          planned
M15  restart-safe continuation                               planned
M16  offline kernel-backed self-host runner                  planned
M17  authorship and evidence attestation                     planned
M18  live self-host doctor                                   planned
  1. 01

    M9 — make trusted local access explicit

    Implement LocalWorkspace with escape and link defenses, bounded I/O, argv-only execution, cancellation, snapshots, and an explicit developer-only selector.

  2. 02

    M10 — put the default authority in a disposable container

    Adapt the existing sandbox runner, deny network and host credentials by default, bound resources and outputs, export only reviewed artifacts, and destroy the run.

  3. 03

    M11 — expose exactly five development verbs

    Add fs.read, fs.list, fs.write, process.exec, and git.diff; migrate away the legacy seams instead of accumulating aliases.

  4. 04

    M12–M18 — earn continuity and self-hosting

    Steering, compaction, replay, restart safety, offline integration, authorship attestation, and one live doctor each retain a separate evidence gate.

19 / EVIDENCE LEDGER

Implementation, delivery, review, and reproduction claims resolve separately.

CONTINUE EXPLORING

Inspect the workspace authority boundary—and the adapters it still refuses to invent.

The pinned public merge contains the canonical injected Workspace contract, strict operation dispatcher, least-privilege tool views, runtime and service admission rules, host-import guard, and explicit adapter limits described here.