diff --git a/CLAUDE.md b/CLAUDE.md index 216fab2..2bc04e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ just gates # corpus pin + upstream tests + golden replay just pin-check # verify corpus MANIFEST.sha256 just calibrate # automated A/A + regression-injection calibration just verdict ... # gates + bench vs pristine-rebuilt baseline + ledger row +cargo run -p harnessd # agent IPC daemon: six-tool JSON surface (SPEC §3.5) ``` ## Definition of done — one optimization attempt diff --git a/Cargo.lock b/Cargo.lock index 4daf4fd..2b820fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "harnessd" +version = "0.1.0" +dependencies = [ + "anyhow", + "diff-test", + "ledger", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "hashbrown" version = "0.14.5" diff --git a/Cargo.toml b/Cargo.toml index f52d276..ed3d792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ledger", "crates/report", "crates/verdict", + "crates/harnessd", ] [workspace.package] diff --git a/agent/hotpath_agent/harness.py b/agent/hotpath_agent/harness.py new file mode 100644 index 0000000..3364af5 --- /dev/null +++ b/agent/hotpath_agent/harness.py @@ -0,0 +1,44 @@ +"""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. +""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any + + +class HarnessError(RuntimeError): + """The harness refused or failed an operation.""" + + +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, + ) + + 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() + if not line: + raise HarnessError("harnessd exited") + 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) diff --git a/agent/hotpath_agent/tools.py b/agent/hotpath_agent/tools.py index 572f9a1..e03e20a 100644 --- a/agent/hotpath_agent/tools.py +++ b/agent/hotpath_agent/tools.py @@ -1,59 +1,71 @@ """Tool surface exposed to the model (SPEC §3.5). -Exactly six tools, all mediated by the trust-layer harness over IPC — +Exactly six tools, all mediated by harnessd over line-delimited JSON — the agent process has no direct filesystem or shell capability beyond what these return. Deliberately NOT exposed: shell on the host, writes outside targets//, any access to crates/, config/, corpora/. -Phase 2 wiring: each function below becomes a Claude Agent SDK tool -definition whose implementation calls the harness. The signatures and -docstrings are the contract; the bodies are placeholders until the -harness IPC exists (Phase 1 exit criteria). +Phase 2 wiring: each function becomes a Claude Agent SDK tool +definition. The bodies talk to the live harness; construct a client +with `connect(repo_root)` first. """ from __future__ import annotations from typing import Any -class HarnessUnavailable(RuntimeError): - """Raised until the Phase 1 trust-layer harness IPC is wired up.""" +from .harness import HarnessClient + +_client: HarnessClient | None = None + + +def connect(repo_root: str, binary: str = "target/debug/harnessd") -> None: + global _client + _client = HarnessClient(repo_root, binary) + + +def _c() -> HarnessClient: + if _client is None: + raise RuntimeError("call connect(repo_root) first") + return _client def read_profile(target: str) -> dict[str, Any]: - """Latest profile for a target: ranked hotspots (symbol, exclusive %, - source mapping) plus perf stat counters. Read-only.""" - raise HarnessUnavailable("profiler adapter not wired (Phase 1)") + """Latest profile for a target: ranked hotspots. Read-only.""" + return _c().call("read_profile", target=target) -def read_ledger(target: str) -> list[dict[str, Any]]: - """All prior attempts against this target — the anti-double-attempt - memory. The agent must not re-grind a (hotspot, playbook_class, - hypothesis) combination that already has a verdict.""" - raise HarnessUnavailable("ledger IPC not wired (Phase 1)") +def read_ledger(target: str) -> dict[str, Any]: + """Prior attempts against this target — the anti-double-attempt + memory. Never re-grind a class that already has a verdict.""" + return _c().call("read_ledger", target=target) def read_playbook(class_number: int) -> str: - """Playbook class markdown (1-7): preconditions, procedure, - verification notes, known failure modes.""" - raise HarnessUnavailable("playbook IPC not wired (Phase 1)") + """Playbook class markdown (1-7).""" + return _c().call("read_playbook", **{"class": class_number})["markdown"] def read_target_source(target: str, path: str) -> str: - """Read-only view of the target's source tree at the pinned commit.""" - raise HarnessUnavailable("source IPC not wired (Phase 1)") + """Read-only view of the target workspace at the pinned commit.""" + return _c().call("read_target_source", target=target, path=path)["content"] def propose_patch(target: str, diff: str, hypothesis: str) -> str: - """Submit a unified diff plus the hypothesis it tests. The harness - applies it via `git apply` after a path-allowlist check (SPEC §10); - a diff touching anything outside targets// is auto-rejected - before any gate runs. Returns a patch id.""" - raise HarnessUnavailable("patch IPC not wired (Phase 1)") + """Submit a unified diff plus its hypothesis. The harness applies it + via `git apply` after a path-allowlist check (SPEC §10); anything + outside targets//workspace is rejected before any gate + runs. Returns a patch id.""" + return _c().call("propose_patch", target=target, diff=diff, hypothesis=hypothesis)["patch_id"] -def run_verdict(patch_id: str) -> dict[str, Any]: - """Run the full gate + bench sequence on a proposed patch and return - the ledger row (gates, bench CIs, verdict). This is the only way the - agent ever learns whether a change 'worked' — there is no other - number to optimize.""" - raise HarnessUnavailable("verdict IPC not wired (Phase 1)") +def run_verdict(patch_id: str, run_id: str, playbook_class: int, hotspot: str) -> dict[str, Any]: + """Gates + bench + ledger row for a proposed patch. This is the only + way the agent learns whether a change 'worked'.""" + return _c().call( + "run_verdict", + patch_id=patch_id, + run_id=run_id, + playbook_class=str(playbook_class), + hotspot=hotspot, + ) diff --git a/crates/harnessd/Cargo.toml b/crates/harnessd/Cargo.toml new file mode 100644 index 0000000..dce4568 --- /dev/null +++ b/crates/harnessd/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "harnessd" +description = "Agent-facing IPC daemon: the six-tool surface (SPEC §3.5) over line-delimited JSON on stdio; patches applied only after path-allowlist checks" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow.workspace = true +diff-test = { path = "../diff-test" } +ledger = { path = "../ledger" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true diff --git a/crates/harnessd/src/main.rs b/crates/harnessd/src/main.rs new file mode 100644 index 0000000..514bb72 --- /dev/null +++ b/crates/harnessd/src/main.rs @@ -0,0 +1,181 @@ +//! harnessd — the only door between the agent and the trust layer +//! (SPEC §3.5, §10). One JSON request per stdin line, one JSON response +//! per stdout line. The agent gets exactly six operations; everything +//! else (shell, trust-layer writes, patches outside the target +//! workspace) simply has no code path here. Phase 1 caveat, recorded: +//! same-uid filesystem read-only enforcement still requires the +//! separate-user/container setup (Phase 1 infra gap). + +use anyhow::{anyhow, ensure, Result}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::io::{BufRead, Write}; +use std::path::{Component, Path}; +use std::process::Command; + +fn main() -> Result<()> { + let root = std::env::current_dir()?.canonicalize()?; + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let resp = match handle(&root, &line) { + Ok(v) => json!({"ok": true, "result": v}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + }; + writeln!(stdout, "{resp}")?; + stdout.flush()?; + } + Ok(()) +} + +fn field<'a>(req: &'a Value, key: &str) -> Result<&'a str> { + req.get(key) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("missing string field: {key}")) +} + +/// A target name must be a plain directory name — no separators, no dots. +fn check_target(name: &str) -> Result<()> { + ensure!( + !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "invalid target name" + ); + Ok(()) +} + +/// Relative paths only; no parent components, no .git, no absolutes. +fn check_rel_path(p: &str) -> Result<()> { + let path = Path::new(p); + ensure!(!path.is_absolute(), "absolute paths forbidden"); + for c in path.components() { + match c { + Component::Normal(seg) => ensure!(seg != ".git", ".git access forbidden"), + Component::CurDir => {} + _ => anyhow::bail!("path escapes workspace: {p}"), + } + } + Ok(()) +} + +fn handle(root: &Path, line: &str) -> Result { + let req: Value = serde_json::from_str(line)?; + match field(&req, "op")? { + "read_profile" => { + let t = field(&req, "target")?; + check_target(t)?; + let text = std::fs::read_to_string(root.join(format!("results/{t}/hotspots.txt")))?; + Ok(json!({"hotspots_text": text})) + } + "read_ledger" => { + let t = field(&req, "target")?; + check_target(t)?; + // Read-only surface over the append-only DB. + let ledger = ledger::Ledger::open(&root.join("results/ledger.sqlite"))?; + let classes = ledger.attempted_classes(t)?; + Ok(json!({"attempted_playbook_classes": classes, "total_attempts": ledger.count()?})) + } + "read_playbook" => { + let class = match req.get("class") { + Some(Value::Number(n)) => n.as_u64().ok_or_else(|| anyhow!("bad class"))? as u8, + Some(Value::String(s)) => s.parse()?, + _ => anyhow::bail!("missing class"), + }; + ensure!((1..=7).contains(&class), "playbook class must be 1-7"); + let dir = root.join("playbook"); + let entry = std::fs::read_dir(&dir)? + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with(&format!("{class:02}-"))) + .ok_or_else(|| anyhow!("no playbook file for class {class}"))?; + Ok(json!({"markdown": std::fs::read_to_string(entry.path())?})) + } + "read_target_source" => { + let t = field(&req, "target")?; + let p = field(&req, "path")?; + check_target(t)?; + check_rel_path(p)?; + let full = root.join(format!("targets/{t}/workspace")).join(p); + Ok(json!({"content": std::fs::read_to_string(full)?})) + } + "propose_patch" => { + let t = field(&req, "target")?; + let diff = field(&req, "diff")?; + let hypothesis = field(&req, "hypothesis")?; + check_target(t)?; + // Allowlist: every path named by the diff must be a safe + // relative path (the git -C below roots them in the target + // workspace; nothing outside it is reachable). + for l in diff.lines() { + if let Some(p) = l.strip_prefix("--- a/").or_else(|| l.strip_prefix("+++ b/")) { + check_rel_path(p.trim())?; + } + ensure!(!l.starts_with("--- /") && !l.starts_with("+++ /"), "absolute diff paths forbidden"); + } + let ws = root.join(format!("targets/{t}/workspace")); + let mut check = Command::new("git") + .args(["-C", ws.to_str().unwrap(), "apply", "--check", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + check.stdin.take().unwrap().write_all(diff.as_bytes())?; + ensure!(check.wait()?.success(), "diff does not apply cleanly"); + let mut apply = Command::new("git") + .args(["-C", ws.to_str().unwrap(), "apply", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + apply.stdin.take().unwrap().write_all(diff.as_bytes())?; + ensure!(apply.wait()?.success(), "git apply failed"); + let patch_id = format!("{:x}", Sha256::digest(diff.as_bytes()))[..12].to_string(); + let pending = root.join("results/pending"); + std::fs::create_dir_all(&pending)?; + std::fs::write( + pending.join(format!("{patch_id}.json")), + serde_json::to_string_pretty(&json!({"target": t, "hypothesis": hypothesis, "diff": diff}))?, + )?; + Ok(json!({"patch_id": patch_id})) + } + "run_verdict" => { + let patch_id = field(&req, "patch_id")?; + ensure!(patch_id.chars().all(|c| c.is_ascii_hexdigit()) && patch_id.len() == 12, "bad patch id"); + let run_id = field(&req, "run_id")?; + let class = field(&req, "playbook_class")?; + let hotspot = field(&req, "hotspot")?; + let pending: Value = serde_json::from_str(&std::fs::read_to_string( + root.join(format!("results/pending/{patch_id}.json")), + )?)?; + let t = pending["target"].as_str().unwrap(); + let spec = diff_test::target::TargetSpec::load(root, t)?; + // Build the candidate from the patched workspace into an + // isolated dir, then hand off to the verdict pipeline. + let cand_dir = root.join(format!("targets/{t}/candidate-{patch_id}")); + let build = Command::new("sh") + .arg("-c").arg(&spec.build.baseline) + .current_dir(root) + .env("CARGO_TARGET_DIR", &cand_dir) + .status()?; + ensure!(build.success(), "candidate build failed"); + let bin = Path::new(&spec.build.binary).file_name().unwrap().to_str().unwrap(); + let diff_path = root.join(format!("results/pending/{patch_id}.json.diff")); + std::fs::write(&diff_path, pending["diff"].as_str().unwrap())?; + let out = Command::new("cargo") + .args([ + "run", "-q", "-p", "verdict", "--", t, + "--rebuild-baseline", + "--candidate-bin", &format!("targets/{t}/candidate-{patch_id}/release/{bin}"), + "--run-id", run_id, + "--playbook-class", class, + "--hypothesis", pending["hypothesis"].as_str().unwrap(), + "--hotspot", hotspot, + "--patch-file", diff_path.to_str().unwrap(), + ]) + .current_dir(root) + .output()?; + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + Ok(json!({"exit_ok": out.status.success(), "report": stdout, + "stderr_tail": String::from_utf8_lossy(&out.stderr).chars().rev().take(500).collect::().chars().rev().collect::()})) + } + other => Err(anyhow!("unknown op: {other} (six ops only, SPEC §3.5)")), + } +} diff --git a/results/ledger.sqlite b/results/ledger.sqlite index 90050fb..a22ac7b 100644 Binary files a/results/ledger.sqlite and b/results/ledger.sqlite differ diff --git a/results/pending/ff886433fec9.json.diff b/results/pending/ff886433fec9.json.diff new file mode 100644 index 0000000..1fef822 --- /dev/null +++ b/results/pending/ff886433fec9.json.diff @@ -0,0 +1,9 @@ +diff --git a/README.md b/README.md +index 5ab2632..dadf761 100644 +--- a/README.md ++++ b/README.md +@@ -586,3 +586,4 @@ See [the graph](https://github.com/XAMPPRocky/tokei/graphs/contributors) for a f + Tokei is distributed under the terms of both the MIT license and the Apache License (Version 2.0). + + See [LICENCE-APACHE](./LICENCE-APACHE), [LICENCE-MIT](./LICENCE-MIT) for more information. ++