Skip to main content
Workspace — a tracked, path-confined handle to the repo (§8, §15). Tools touch the filesystem and run commands only through this handle, so a tool physically cannot reach outside the workspace root. It also owns the diff baseline: at construction it pins the current git HEAD, and diff() compares the working tree against that pinned baseline (not the git index), so the task’s delta is well-defined and the harness never needs to commit (§15).

Classes

AmbiguousMatchError

The old anchor matched more than once and replace_all was not set (ADR-0015). Args: path: The workspace-relative file the anchor matched in. count: How many times the anchor matched (>1); exposed as .count for the tool’s message.

CommandOutput

The captured result of one command run through the workspace.

DirtyWorkspaceError

The workspace has uncommitted changes at open and they were not acknowledged (§15).

EmptyAnchorError

old was empty — it would match between every character and rewrite the file (ADR-0015). Rejected at the Workspace.replace chokepoint so a direct SDK caller can’t corrupt a file, not only the str_replace tool layer.

MatchNotFoundError

The old anchor was absent from the file — a stale or mistyped anchor (§10).

PatchError

A patch failed to apply cleanly (stale context) — model-correctable (§10). Application is all-or-nothing: when this is raised, nothing was written.

PathOutsideWorkspaceError

Raised when a requested path resolves outside the workspace root.

ReplaceError

A string-anchored replace could not be applied cleanly (ADR-0015) — model-correctable. All-or-nothing: when raised, the file is byte-for-byte unchanged.

SensitivePathError

Raised when a resolved path matches the sensitive-path denylist (§11, Phase 2.5). Enforced at the workspace chokepoint (not just the permission gate), so the check sees the symlink-resolved target — an innocuously-named symlink cannot launder a secret — and a non-gated caller still cannot read/patch it.

Workspace

A tracked, path-confined handle to the repo; tools reach the FS only here (§8, §15). Args: root: The workspace root all paths are confined to. allow_dirty: When True, skip the clean-tree check at open (§15). sensitive_path_globs: The denylist refused on read/patch (resolved-path check, §11). Defaults to the built-in set (secure by default); the runner threads HarnessConfig.sensitive_path_globs through to match the permission gate. log_path: The harness’s own event-journal path (HarnessConfig.log_path), hidden from the agent’s file tools so it can’t list/read/search the harness’s plumbing. sandbox: The execution strategy applied at the command seam (ADR-0042). Defaults to NoSandbox (the full inherited environment — today’s behavior), so a bare Workspace(root) and every read-only inspection stay unchanged; the entry points that actually run model/verifier commands inject make_sandbox(config.sandbox_mode).

Workspace.apply_patch(self, diff: str) -> list[str]

Apply a (possibly multi-file) unified diff atomically; return changed paths. An internal/SDK primitive — no longer a model-facing tool (the agent edits via replace/str_replace, ADR-0015), but retained for programmatic callers and for multi-file/creation diffs replace does not cover. Confinement first: every target path must resolve inside the root, else PathOutsideWorkspaceError (nothing written). Then a git apply --check dry run gates the real apply, so a stale diff raises PatchError and the workspace is left byte-for-byte unchanged — all-or-nothing (§10). Args: diff: The unified diff to apply. Returns: Sorted workspace-relative paths the diff changed. Raises: PatchError: When the diff names no targets or fails to apply cleanly.

Workspace.baseline_paths(self) -> list[str]

Workspace-relative paths tracked at the pinned baseline commit (empty when none). Lets the verifier tell a genuinely test-less repo (a legitimate no-tests skip) from one whose tests were suppressed after the baseline (ADR-0042, Threat A): if the baseline HAD tests but a frozen kind="test" check now collects none, that exit-5 is laundering, not absence. Reads the baseline tree via git ls-tree — not the working tree — so a just-deleted/emptied test is still seen as having existed. Empty when the workspace is not a git repo (no baseline was pinned). Returns: The sorted workspace-relative POSIX paths tracked at the pinned baseline.

Workspace.contains(self, rel_path: str) -> bool

Whether rel_path resolves inside the root, without raising (for the gate). Args: rel_path: The path to test. Returns: True if rel_path resolves inside the root, else False.

Workspace.diff(self) -> str

Working-tree delta vs. the pinned baseline (empty when no baseline). Returns: The unified diff against the pinned baseline, empty when none.

Workspace.is_ignored(self, relpath: str) -> bool

Whether relpath (workspace-relative POSIX) is hidden harness plumbing. Args: relpath: A workspace-relative POSIX path. Returns: True for the harness’s own journal directory (any file under it) or, when the journal sits directly in the root, its file and latest.jsonl pointer.

Workspace.list_files(self, glob: str) -> list[str]

Return workspace-relative paths of files matching glob, sorted. A glob that matches a directory expands to the files under it (recursively), so list_files("pkg") lists pkg/’s contents rather than silently returning nothing — the dogfood gap where rich* matched a dir and was dropped. Hidden (dot-prefixed) entries are skipped by wildcards, mirroring ripgrep’s default — pathlib’s glob matches them, so a venv or .git inside the workspace otherwise turns */**/* into thousands of junk paths. A pattern that names a dot-prefixed segment (e.g. .github/**/*) opts into hidden, the same way an explicit path does for rg; read_file on an explicit hidden path always works. Args: glob: The glob pattern to match against the root. Returns: Sorted workspace-relative paths of the matching files (dir matches expanded; hidden entries skipped unless the pattern names a dot-prefixed segment).

Workspace.read(self, path: str, line_range: tuple[int, int] | None = None) -> str

Read a workspace file, optionally a 1-indexed inclusive line range. Args: path: The file to read. line_range: A 1-indexed inclusive (start, end) range, or None for all. Returns: The file text, sliced to line_range when given. Raises: FileNotFoundError: When the path is the harness’s own (hidden) journal.

Workspace.remove(self, path: str) -> str

Delete a workspace file and stage the removal so diff() reflects it (ADR-0015). The deletion counterpart to write_file/replace — the capability the removed apply_patch /dev/null hunk used to carry. Staging the removal makes a baseline-tracked file show as deleted in git diff <baseline> and lets a transiently-created file (staged by write_file) net back to zero. Confinement and the sensitive-path denylist apply at this chokepoint like every other write. Args: path: The workspace-relative file to delete. Returns: The workspace-relative path deleted. Raises: FileNotFoundError: When the target does not exist (nothing to delete).

Workspace.replace(self, path: str, old: str, new: str, *, replace_all: bool = False) -> str

Swap an exact string in a file; the anchor proves non-staleness (§10, ADR-0015). The string-anchored modification primitive that supersedes apply_patch’s diff costume: old must match the current file text exactly — read-before-edit (§10) enforced by the anchor, not by line arithmetic. It must match once, unless replace_all. All-or-nothing: a rejected match leaves the file byte-for-byte unchanged. Confinement, the sensitive-path denylist, and staging apply at this chokepoint like every other write; the resulting diff is derived by diff(), never authored by the model (§5). Args: path: The workspace-relative file to edit. old: The exact existing text to find (the anchor); must be non-empty. new: The replacement text; an empty string deletes the matched span. replace_all: Replace every occurrence instead of requiring a unique match. Returns: The workspace-relative path edited. Raises: FileNotFoundError: When the target file does not exist (create with write_file). EmptyAnchorError: When old is empty (would rewrite the whole file). MatchNotFoundError: When old is absent (stale or mistyped anchor). AmbiguousMatchError: When old matches more than once and replace_all is unset. Note: The input contract lives HERE, at the chokepoint, not only in the str_replace tool — a direct SDK caller gets the same guarantees. Confinement and the sensitive-path denylist apply via _resolve/_assert_not_sensitive. An empty new is deliberately allowed (span deletion); an empty old is rejected.

Workspace.run(self, command: str, timeout: int | None = None) -> CommandOutput

Run command confined to the root, capturing output; bounded by timeout. Never raises into the loop (ADR-0007 robustness floor): a missing binary, an empty command, or an unparseable command line all come back as a failed CommandOutput (shell convention: exit 127 = command not found) so the verifier and tools see a legible failure, not a FileNotFoundError. Args: command: The shell-style command to run. timeout: Seconds before the command is killed, or None for no bound. Returns: The captured stdout, stderr, exit code, and timeout flag.

Workspace.stage(self, paths: list[str]) -> None

git add the given paths so they enter the pinned-HEAD diff (§15). Mirrors apply_patch’s git apply --index: a command-created (untracked) file is invisible to git diff <baseline> until staged, so staging is what makes codegen/migration output show up in the diff, artifact, and verifier. No-op when not a git repo or given no paths. Args: paths: Workspace-relative paths to stage.

Workspace.status_paths(self) -> set[str]

Workspace-relative paths git currently sees as changed or untracked. Used to attribute a command’s side effects: snapshot before and after a run and the delta is what the command touched. Includes untracked files (unlike diff, which is git diff <baseline>), so command-created files are visible. Empty when not a git repo. Returns: The set of paths from git status --porcelain (rename → its new path).

Workspace.write_file(self, path: str, content: str, *, overwrite: bool = False) -> str

Create a file with content and stage it; refuse an existing target by default. The plain-content twin of apply_patch for the no-anchor case (ADR-0003 B): creation needs no diff costume, while modification stays diff-anchored — without overwrite, an existing target raises FileExistsError so the clean-apply staleness invariant can’t be bypassed casually. Confinement and the sensitive-path denylist apply at this chokepoint like every other access, and the new file is staged so it appears in diff() (matching apply_patch --index and the run_command mutation capture). Args: path: The workspace-relative file to create. content: The full file content to write. overwrite: True to deliberately replace an existing file. Returns: The workspace-relative path written. Raises: FileExistsError: When the target exists and overwrite is False.

Functions

path_is_sensitive(rel_path: str, globs: Sequence[str]) -> bool

Whether rel_path matches any denylist glob (§11, Phase 2.5). A pattern without a slash matches any single path component (gitignore-style “match anywhere” — so .env hits a/b/.env and .ssh hits .ssh/id_rsa). A pattern with a slash is matched against the whole relative path. Matching is case-insensitive (ADR-0021): a case-insensitive filesystem (macOS APFS, Windows) resolves CREDENTIALS to the same file as credentials, so a case-sensitive denylist is bypassable by varying case. We case-fold both sides via fnmatchcase on lowered strings — deterministic across platforms (unlike fnmatch, whose case behavior rides os.path.normcase, a no-op on macOS) and conservative (over-matching a denylist is the safe direction). Args: rel_path: The workspace-relative path to test. globs: The denylist patterns. Returns: True if any pattern matches, else False.