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.
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 proof02 / 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.
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 toolThe 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.
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.
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_UNSUPPORTEDResults 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.
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-ownedrestrictWorkspace() 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.
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 oneAbsence 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.
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 M11The 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.
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 callerBoth 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.
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 adapter10 / 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.
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 infrastructureThe 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.
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 passedThe 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.
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_requestThe 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.
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 mergeMain 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 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 / 1A 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.
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 activeThe 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.
| Surface | What the evidence supports | What remains open |
|---|---|---|
| Contract | One canonical interface defines seven operational filesystem, process, review, snapshot, and lifecycle methods. | M8 supplies no LocalWorkspace, DockerWorkspace, host implementation selector, or automatic fallback. |
| Lexical scope | The 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. |
| Binding | The 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. |
| Dispatch | Known 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 authority | A 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_file | The 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 path | Agent 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 guard | An 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. |
| Verification | Final-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 delivery | Green 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.
Capability contract
- packages/workspace/src/index.ts — lexical scope, canonical interface, typed errors, binding, restriction, dispatch, result normalization, and signal branding.
- packages/workspace/test/workspace.test.ts — capability capture, hostile shapes, operation dispatch, restricted views, snapshots, results, and cancellation cases.
Kernel integration
- packages/kernel/src/runtime.ts — streaming-runtime snapshot, conditional advertisement, permission, restricted execution, and typed failure wiring.
- packages/kernel/src/run.ts — separate legacy identity/capability inputs with the same effect fence.
Tool and service boundary
- packages/tools/src/tool.ts — normalized pure, read-only workspace, and sandbox execution markers.
- packages/tools/src/fs-tools.ts — injected
read_file, returned-text limit, and the path/schema review findings described above. - services/agent-server/src/connection.ts — fail-closed admission until a service adapter exists.
Architecture gate and delivery
- packages/workspace/test/import-boundary.test.ts — AST fixtures and real-tree scan for covered direct host imports.
- tasks/m8-workspace-capability-boundary.yaml — acceptance criteria, scope, offline commands, authoring budget, and PR delivery.
- ARCHITECTURE.md — delivered boundary, ownership, server state, and M9 handoff.
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.
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 planned19 / EVIDENCE LEDGER
Implementation, delivery, review, and reproduction claims resolve separately.
- M8 merge
d14fc13— exact public implementation pin for this article. - Pull request #9 — enforce workspace capability boundary — 19-file diff, two feature commits, timestamps, checks, and review state.
- M8 task contract and delivered roadmap entry — acceptance, scope, permissions, gate, explicit deferrals, and M9 handoff.
- Initial-head CI — the 657/658 Node 22 failure that prompted the native AbortSignal follow-up
fa8da7f. - Final-head CI and gate artifact and final-head CodeQL — successful pre-merge automation on
fa8da7f. - Exact-merge CI and exact-merge CodeQL — successful post-merge workflows on
d14fc13. - Copilot path-validation finding and the review's suppressed non-strict-Zod finding — unresolved automated review notes in the exact merge; no human approval is visible.
- Clean local publication audit — an unretained exact-merge checkout reproduced 658/658 tests, strict TypeScript, and 1/1 golden scenario under Node 24.18.0. The shared build conversation informed chronology and intent but is not published as implementation evidence.