mirror of
https://github.com/bdbrown4/peltier.io.git
synced 2026-11-07 10:58:28 +00:00
agent: OS-level isolation for the agent tool boundary (SPEC §10)
harnessd gains --socket mode (Unix socket, trusted uid, mutating ops serialized); the harness client speaks socket when HOTPATH_HARNESS_SOCKET is set. scripts/agent-isolated.sh launches an attempt with the agent tree confined — mountns mode (repo bind-mounted read-only, CAP_SYS_ADMIN/CAP_SYS_PTRACE dropped; no credential copying) or user mode (unprivileged hpagent, bench-metal shape). scripts/isolation-check.sh + just isolation-check verify the boundary from the agent's side: 19 checks, both modes, all passing — repo writes, forged ledger INSERTs, trust-binary replacement, rw remounts, and userns re-entry all refused; the harnessd socket door works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcKPWQ3MQD4NdEHwar96u2
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
"""Client for harnessd — the agent's only door to the trust layer.
|
||||
|
||||
Spawns the Rust daemon and speaks one JSON object per line over its
|
||||
stdio. The client is deliberately dumb: no retries that could double-
|
||||
apply a patch, no local fallbacks that could fabricate evidence.
|
||||
Two transports:
|
||||
- Unix socket (production/SPEC §10): if HOTPATH_HARNESS_SOCKET is set,
|
||||
connect to a daemon owned by the trusted uid. The agent process runs
|
||||
unprivileged and cannot write the repo except through this door.
|
||||
- Spawned stdio subprocess (dev fallback): same uid, defense-in-depth
|
||||
only — the filesystem boundary does not hold in this mode.
|
||||
|
||||
The client is deliberately dumb: no retries that could double-apply a
|
||||
patch, no local fallbacks that could fabricate evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -18,27 +26,45 @@ class HarnessError(RuntimeError):
|
||||
|
||||
class HarnessClient:
|
||||
def __init__(self, repo_root: str, binary: str = "target/debug/harnessd"):
|
||||
self._proc = subprocess.Popen(
|
||||
[binary],
|
||||
cwd=repo_root,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
sock_path = os.environ.get("HOTPATH_HARNESS_SOCKET")
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._sock: socket.socket | None = None
|
||||
if sock_path:
|
||||
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._sock.connect(sock_path)
|
||||
self._io = self._sock.makefile("rw", encoding="utf-8", newline="\n")
|
||||
else:
|
||||
self._proc = subprocess.Popen(
|
||||
[binary],
|
||||
cwd=repo_root,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def call(self, op: str, **kwargs: Any) -> dict[str, Any]:
|
||||
assert self._proc.stdin and self._proc.stdout
|
||||
self._proc.stdin.write(json.dumps({"op": op, **kwargs}) + "\n")
|
||||
self._proc.stdin.flush()
|
||||
line = self._proc.stdout.readline()
|
||||
payload = json.dumps({"op": op, **kwargs}) + "\n"
|
||||
if self._sock is not None:
|
||||
self._io.write(payload)
|
||||
self._io.flush()
|
||||
line = self._io.readline()
|
||||
else:
|
||||
assert self._proc and self._proc.stdin and self._proc.stdout
|
||||
self._proc.stdin.write(payload)
|
||||
self._proc.stdin.flush()
|
||||
line = self._proc.stdout.readline()
|
||||
if not line:
|
||||
raise HarnessError("harnessd exited")
|
||||
raise HarnessError("harnessd connection closed")
|
||||
resp = json.loads(line)
|
||||
if not resp.get("ok"):
|
||||
raise HarnessError(resp.get("error", "unknown harness error"))
|
||||
return resp["result"]
|
||||
|
||||
def close(self) -> None:
|
||||
if self._proc.stdin:
|
||||
self._proc.stdin.close()
|
||||
self._proc.wait(timeout=10)
|
||||
if self._sock is not None:
|
||||
self._io.close()
|
||||
self._sock.close()
|
||||
if self._proc is not None:
|
||||
if self._proc.stdin:
|
||||
self._proc.stdin.close()
|
||||
self._proc.wait(timeout=10)
|
||||
|
||||
@@ -36,15 +36,14 @@ HARNESS_TOOLS = [
|
||||
]
|
||||
|
||||
# Built-in tools we deny at the SDK layer. This is DEFENSE IN DEPTH, not the
|
||||
# trust boundary. SPEC §10's real boundary is OS-level process/user isolation:
|
||||
# harnessd runs as a separate process the agent can't bypass, target code runs
|
||||
# in a no-net container. In THIS dev container the nested agent runs as root
|
||||
# under a parent Claude Code session, so neither --dangerously-skip-permissions
|
||||
# nor a can_use_tool callback reliably gates built-ins (settings inherited from
|
||||
# the parent session shadow them). We enumerate what we can; production runs the
|
||||
# agent process without host-shell capability at all. What holds regardless:
|
||||
# propose_patch is the only harness-mediated write path (path-allowlist + git
|
||||
# apply), and the ledger is append-only (mutation-refusing triggers).
|
||||
# trust boundary. SPEC §10's real boundary is OS-level isolation, shipped in
|
||||
# scripts/agent-isolated.sh: this whole process tree runs either inside a
|
||||
# mount namespace with the repo read-only and CAP_SYS_ADMIN dropped, or as
|
||||
# the unprivileged hpagent user — verified by `just isolation-check`. Even
|
||||
# if the nested CLI reaches a shell despite this list (settings inherited
|
||||
# from a parent session can shadow SDK gating), repo writes fail at the OS.
|
||||
# What holds regardless: propose_patch is the only harness-mediated write
|
||||
# path (path-allowlist + git apply), and the ledger is append-only.
|
||||
BUILTIN_TOOLS_DENIED = [
|
||||
"Bash", "Read", "Write", "Edit", "Glob", "Grep",
|
||||
"WebSearch", "WebFetch", "Task", "TodoWrite",
|
||||
|
||||
Reference in New Issue
Block a user