From a22bb84e3c9e8ed82ef30240192a9a69f8e318e3 Mon Sep 17 00:00:00 2001 From: snapsynapse <57973674+snapsynapse@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:08:22 -0600 Subject: [PATCH] Add parallel write conflict preflight --- CHANGELOG.md | 3 + IMPLEMENTATION_PLAN.md | 7 +- NEXT.md | 111 +++++++++--------------- ROADMAP.md | 10 +-- audits/handoff-relevance-2026-07-21.md | 86 +++++++++++++++++++ docs/GUIDE.md | 3 + docs/guide.html | 3 +- evals/operability.yaml | 7 ++ evals/stewardship.yaml | 9 +- harness/evals.py | 18 ++++ harness/ownership.py | 45 +++++++++- harness/runner.py | 46 +++++++++- harness/tools/builtin.py | 4 +- harness/write_safety.py | 114 +++++++++++++++++++++++++ tests/test_ownership.py | 18 ++++ tests/test_runner.py | 90 +++++++++++++++++++ tests/test_write_safety.py | 64 ++++++++++++++ 17 files changed, 548 insertions(+), 90 deletions(-) create mode 100644 audits/handoff-relevance-2026-07-21.md create mode 100644 harness/write_safety.py create mode 100644 tests/test_write_safety.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 138964f..dd70241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ All notable changes to Harnessie are recorded here. Format loosely follows Keep ### Added +- The first 0.8 write-safety slice: parallel phases may declare exact files and directory subtrees with `writes`. Once a group opts in, every member must declare its writes, including `writes: []` for read-only work; malformed, partial, case/Unicode-aliased, or overlapping declarations refuse the whole group before workspace creation or model dispatch and emit a structured event. Declared ownership lanes now remain enforced inside isolated parallel workspaces without treating physically separate phase-local files as one first-writer claim. Proven by parser adversarial tests, runner dispatch-spy tests, ownership tests, and a new operability eval. +- AIDR-0008 has been executed in the separate Apache-2.0 [harnessie-engine-wrappers](https://github.com/snapsynapse/harnessie-engine-wrappers) repository. Its fresh-authored v0.1.0 macOS Seatbelt reference wrapper admits the backend only after a deny/allow/symlink probe, fails closed on unsupported or unavailable engines, and was release-gated by a live macOS-14 CI probe. - `harnessie verify` now ships as a GitHub Action, published to the Marketplace as [Harnessie Verify](https://github.com/marketplace/actions/harnessie-verify) (repo: [harnessie-verify-action](https://github.com/snapsynapse/harnessie-verify-action), v0.1.0, adopted via `decisions/AIDR-0007`). This repo dogfoods it: `.github/workflows/verify-pr-claims.yml` verifies every PR's claims once the verifier endpoint variables and secret are configured, and skips politely until then. +- A current handoff-relevance audit classifies every private rotation packet, retires the completed position sweep, reconciles the Homebrew tap and verify-action release channels, incorporates the arbitrated AIDR-0008 separate-repository work, and scopes the 0.8 work order. `NEXT.md` now carries only current state and executable next work; the stewardship eval checks that contract instead of requiring shipped 0.6 headings. ## 0.7.1 (2026-07-09) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a8174e0..c1ad723 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -2,7 +2,7 @@ Ordered build steps. Each step has a done test: a check an operator can run that either passes or fails, in the spirit of "define evaluation before scale". Steps 1 through 10 plus the injection-defense layer, OS sandbox (step 15), and the first deterministic eval scorecard (step 12 foundation) are implemented in this repo; their done tests are encoded in tests/. Remaining steps are the hardening path. -The injection-defense layer (harness/quarantine.py; SECURITY.md) is implemented: ingress filter on quarantine=True tools, loop tripwire, per-phase deny_tools, scrubbed child-process env, shell-output secret redaction, and write-time credential refusal. Done test: tests/test_quarantine.py (8 tests) proves poisoned file content is fenced not obeyed, the tripwire re-asserts the boundary, denied tools are hidden and blocked, child env carries no secrets, and credential-shaped strings are redacted from output and refused on write. The unclosed gap is an OS sandbox (step 15) for full containment of allowlisted interpreters. +The injection-defense layer (harness/quarantine.py; SECURITY.md) is implemented: ingress filter on quarantine=True tools, loop tripwire, per-phase deny_tools, scrubbed child-process env, shell-output secret redaction, and write-time credential refusal. Done test: tests/test_quarantine.py proves poisoned file content is fenced not obeyed, the tripwire re-asserts the boundary, denied tools are hidden and blocked, child env carries no secrets, and credential-shaped strings are redacted from output and refused on write. OS sandbox enforcement for allowlisted interpreters is implemented in step 15. ## Phase 1, minimum safe harness (implemented) @@ -63,12 +63,13 @@ The injection-defense layer (harness/quarantine.py; SECURITY.md) is implemented: 14. Parallel workers (implemented) - Independent consecutive phases declared with the same `parallel:` label fan out across workers, with per-phase workspaces under `workspace/.phases/` to prevent write conflicts. -- Done test: two independent phases run concurrently with disjoint workspaces and both gate independently; total wall-clock beats sequential on a mock brain with latency. Covered by `tests/test_runner.py` and `evals/operability.yaml`. +- The first 0.8 write-safety slice adds opt-in `writes:` preflight: exact files and directory subtrees are parsed in a deliberately decidable language, every member must declare after any member opts in, and invalid or overlapping declarations refuse before workspace creation or dispatch. Declared ownership lanes remain enforced in each isolated workspace; phase-local first-writer claims remain independent. +- Done test: two independent phases run concurrently with disjoint workspaces and both gate independently; total wall-clock beats sequential on a mock brain with latency. Dispatch spies prove conflicts start no phase, adversarial parser cases prove portable alias handling, and operator-owned paths remain denied in parallel phases. Covered by `tests/test_runner.py`, `tests/test_write_safety.py`, `tests/test_ownership.py`, and `evals/operability.yaml`. 15. OS sandbox for shell execution (implemented) - run_shell and gate checks run inside an OS confinement (harness/sandbox.py; macOS Seatbelt via sandbox-exec) that limits writes to the workspace and denies network by default, closing the interpreter escape that per-role allowlists and the argument jail only narrow. Policy: fail closed everywhere (no backend means shell/checks are blocked, not run unconfined); network is per-phase opt-in via allow_network. - Done test: a worker's `python3 -c "open('~/x','w')"` is blocked by the sandbox and the file is never created; the same write into the workspace succeeds; network is denied by default; run_shell and gate checks fail closed when the backend is monkeypatched absent. (tests/test_sandbox.py, 7 tests.) -- Follow-up: a Linux backend (bubblewrap / firejail / docker); until one is wired, Linux fails closed by design. Scoped as a 0.4.0 milestone (displaced twice from 0.2.0) in [ROADMAP.md](ROADMAP.md) under Platform support. +- Linux parity shipped in 0.4.0 through bubblewrap, firejail, and docker backends with admission probes; the no-backend path still fails closed. See [ROADMAP.md](ROADMAP.md) under Platform support. ## Phase 3, extensibility (later, only when earned) diff --git a/NEXT.md b/NEXT.md index 153ce9d..e4ab332 100644 --- a/NEXT.md +++ b/NEXT.md @@ -2,97 +2,64 @@ ## Current state -Harnessie is at v0.7.1 (the verifier leaves the harness), SHIPPED 2026-07-09: `harnessie verify` is a standalone claim-by-claim verification surface (workspace + criteria in, fail-closed exit 0/1/2 out, no project scaffold), adopted via `decisions/AIDR-0006` (four-provider sweep, human-arbitrated) and proven in the field the day it shipped: all four open outside PRs on the Ringer repo verified into a handoff package, after the tool first refuted a claim in its own author's PR. Public surface gained docs/ringer.md (composition story, verify-as-Ringer-check wiring, PR verification recipe). Post-release surfaces (2026-07-10): harnessie-verify-action v0.1.0 live on the GitHub Marketplace (Harnessie Verify), brew tap brought current to 0.7.1 with a permanent tap step added to RELEASE_CHECKLIST, dogfood PR-verification workflow armed in this repo, AIDR-0008 (operator-side engine-wrapper containment) open awaiting arbitration. Verification at ship: 269 passed / 1 skipped, 43/43 eval, manifest OK. GuideCheck Level 4 re-confirmed end-to-end 2026-07-10 (guidecheck-hosted 0.7.0: achieved level 4, level5_ready, 0 blocking) against the live 0.7.1 `.well-known/` pair and the updated DNS TXT anchor (both DoH resolvers serving the new hash); the two warnings remain the response headers GitHub Pages cannot set. - -The prior release, v0.7.0 (sovereignty cascade routing + containment boundary), SHIPPED 2026-07-09: tagged `v0.7.0`, GitHub release published with wheel + sdist attached, and `harnessie 0.7.0` live on PyPI (fresh-install verified from the live index). The whole milestone was adopted through the harness's own contested-decision process (five arbitrated AIDRs, three on six- and three-model Ollama Cloud panels). Routing (cascade policies, sideways fallback, escalation headroom, sovereign tier, reserved pre-gate, routing_trace) and the containment boundary (PII strip/rehydrate, secret egress halt, fail-closed strip-map lifecycle, per-tool rehydration grants, per-data-class coverage table) are both live and opt-in; a workflow that does not opt in routes byte-identically to 0.6. The boundary is vendored under the PAICE.work PBC Apache-2.0 release recorded in NOTICE (sole-director consent on file, July 9). Verification at ship: 255 passed / 1 skipped, 43/43 eval, manifest OK. GuideCheck DNS TXT anchor updated to the 0.7.0 guide hash and confirmed resolving on two resolvers; the hosted Level-4 re-verify against the live `.well-known/` pair is the one remaining operator confirmation (needs Pages to redeploy the served guide). The 0.6 Siteline live-page re-scan remains open from the prior line. - -v0.6.0 (first-harness-readiness) shipped 2026-07-07: repo and canonical page live, on PyPI, GuideCheck Level 4 confirmed. - -Engineering: -- v0.4.0 portability/proof remains in: Linux sandbox backends, opt-in live provider scorecards, and trust-bundle manifest integrity. -- v0.5.0 operability is in: headless approval policy files, optional TTY approval prompts, per-phase cost deltas, and parallel worker groups with per-phase workspaces. -- Approval policy shape is intentionally small: -```yaml -allow: - - tool: expire_fact - phase: triage -deny: - - tool: deploy -``` -Rules name a `tool` and may name a `phase`; explicit deny wins; no match denies closed. CLI flags: `--approval-policy ` and `--approve-interactive`. -- Parallel phases are consecutive workflow phases with the same `parallel:` label. They run concurrently, gate independently, and use `workspace/.phases/` as their workspace. Later phases receive each phase report by phase name. -- Event logging and budget charging are lock-guarded for concurrent phase execution. +Harnessie 0.7.1 is shipped on PyPI and GitHub. The standalone `harnessie verify` surface is also published as `snapsynapse/harnessie-verify-action@v0` and listed in the GitHub Marketplace. The Homebrew formula serves 0.7.1. -Public surface (LIVE): -- harnessie.com serves the landing page (v0.7.0), the seven generated doc pages (quickstart, getting-started, guide, brains, threat-model, compare, ringer), the GuideCheck `.well-known/` trust pair, and the crawler/discovery files. -- The HTML doc pages are generated from the markdown by `scripts/build_docs_html.py`; edit markdown, run the script, commit both. -- `docs/MANIFEST.yaml` pins 9 files; `tests/test_guide_artifacts.py` enforces guide-artifact sync. +The next public milestone is 0.8.0, write-safety and self-integrity. Its four roadmap mechanics are blast-radius ceilings, declared write-path conflict refusal for parallel groups, the maiden-voyage rule, and an inward manifest for role prompts and shipped configuration. -## Verification status +`decisions/AIDR-0008` was arbitrated on 2026-07-16 and executed on 2026-07-21 as [snapsynapse/harnessie-engine-wrappers](https://github.com/snapsynapse/harnessie-engine-wrappers). The fresh-authored Apache-2.0 v0.1.0 seed contains a macOS Seatbelt reference wrapper, shared credential deny policy, and a deny/allow/symlink admission probe. Its macOS-14 CI probe passed, unsupported and unavailable backends fail closed, and the consent boundary remains intact: other developers' work enters only through their own consenting contribution. -Current (after the 0.6 first-harness-readiness work): -- `python3 -m pytest -q`: 195 passed, 1 skipped. -- `python3 -m harness.cli eval`: all PASS (default, `evals/operability.yaml`, `evals/stewardship.yaml`, `evals/redteam.yaml`). -- `python3 -m harness.cli verify-manifest`: passed, 9 files. -- `python3 -m harness.cli eval --live`: keyless/no-endpoint skip path returns 0/0 with explicit `SKIP` rows unless live env vars are set. Confirmed. -- `git diff --check`: clean. -- Scrub check still needs to be run before any commit that stages public surface. +The first 0.8 slice is implemented on `agent/handoff-and-write-safety`: opt-in parallel `writes` declarations refuse invalid or overlapping groups before dispatch, and declared ownership lanes now remain enforced inside isolated parallel workspaces. Blast-radius ceilings, the inward manifest, and the maiden-voyage rule remain. -## Known limits +## Verified baseline -- Budget-safety hardening CLOSED 2026-07-07 (was: parallel phase budgets seeded with the full run ceiling, mid-group enforcement loose, up to ~(N-1)x overshoot). Now: `Budget.child()` headroom-scoped child budgets with live charge-through to the run budget and parent-aware `exhausted`; a group entered with the budget already exhausted refuses per-phase before dispatch; the post-group `add_spend` merge is removed. Residual (accepted): overshoot bounded to model turns already in flight when the ceiling crosses — a turn cannot be un-called mid-flight. Proven by `tests/test_routing_verify.py` (3 child-budget tests) and `tests/test_runner.py` (pre-dispatch refusal; no double count). This was the named 0.7 prerequisite; it no longer blocks routing work. +Verified locally on 2026-07-21: -## Operator-attended steps ready and waiting +- `python3 -m pytest -q`: 288 passed, 8 skipped. +- `python3 -m harness.cli eval`: 44/44 passed. +- `python3 -m harness.cli verify-manifest`: passed, 9 files. +- `git diff --check`: clean before this handoff refresh. -These remain outside Codex's headless authority: +Skip counts depend on the available local sandbox and live-provider configuration. Treat the commands and outcomes as the contract, not a permanently fixed test count. -1. Run live provider smokes when ready: -```bash -HARNESSIE_LIVE=1 \ -HARNESSIE_OPENAI_COMPAT_BASE_URL=http://localhost:11434/v1 \ -python3 -m harness.cli eval --live -``` -2. Run the live contested phase across two real providers on `workflows/contested-decision.yaml` if you want a real `independent-positions` record for the 0.4 proof trail. -3. Enable GitHub Pages, DNS, and public-repo settings only as deliberate operator acts. -4. Verify PostHog on the live page with a PostHog login. -5. Publish to PyPI only after the 0.6 launch gate closes. DONE 2026-07-07: harnessie 0.6.0 on PyPI, verified installable from the live index. -6. Run the live Siteline scan only after the site is live. +## Current cross-repo state + +- `snapsynapse/harnessie-verify-action`: the local checkout at `~/Git/harnessie-verify-action` is clean and synchronized with remote `main` at `3a2f1bb`. The published action remains v0.1.0 and pins Harnessie 0.7.1. +- `snapsynapse/homebrew-tap`: the live `Formula/harnessie.rb` serves Harnessie 0.7.1 and has the correct PyPI sdist hash. Draft PR [#1](https://github.com/snapsynapse/homebrew-tap/pull/1) adds Harnessie to the README formula list and install example; the formula itself is unchanged. +- `snapsynapse/harnessie-engine-wrappers`: v0.1.0 is released from `ad3d759`. CI passed its real macOS-14 containment probe and its Ubuntu unsupported-platform refusal; release archives, wheel, and `SHA256SUMS` are attached. +- This repo dogfoods `snapsynapse/harnessie-verify-action@v0` in `.github/workflows/verify-pr-claims.yml`. A live verdict still depends on the repository verifier endpoint/model variables and API-key secret. +- GitHub `main` is `eb07488`; the latest CI and Pages runs succeeded. There are no open pull requests or issues, and v0.7.1 remains the latest release. -## Next unblocked engineering work +## Handoff disposition -### 0.6.0 headless subset +The detailed inventory and relevance assessment is in `audits/handoff-relevance-2026-07-21.md`. In short: -- Grow `harnessie init` into guided first run: Python check, sandbox-backend detect, env-var API-key walk-through, ends on a green zero-dollar mock-brain run. DONE 2026-07-07: `harness/firstrun.py` + `init` wiring (readiness report, zero-dollar baseline run, named next commands; `--no-verify` to skip); proven by `tests/test_firstrun.py`; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- Plain-language operator surface: `harnessie report` and halt messages should self-explain with one named next action. DONE 2026-07-07: `harness/explain.py` + CLI wiring (`run`/`resume` summary, plain `report` with `--raw` fallback); each halt names one command; proven by `tests/test_explain.py`; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- Pre-run cost preview; refuse to start a live run when no ceiling is set. DONE 2026-07-07: `harness/preflight.py` + CLI wiring + `tests/test_preflight.py`; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- Non-developer quickstart + glossary; honest Windows/WSL2 page. DONE 2026-07-07: `docs/quickstart.md` (init→run→report flow, 19-term glossary, Windows/WSL2 section), linked from README + getting-started; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- Threat-model comparison artifact: SECURITY.md properties vs prevailing harness failure modes, each row citing enforcing code and tests. DONE 2026-07-07: `docs/threat-model.md` (11 rows, 25 cited test nodes all passing), linked from README + SECURITY.md; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- Default-deny posture audit extending `tests/test_repo_configs.py`. DONE 2026-07-07: 11 assertions over the shipped registry, OWNERSHIP.yaml, and CLI seams; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- GuideCheck content rewrite of `assistant-guide.txt` to Level 3+ and manifest sidecar prep; end-to-end `.well-known` verify waits for Pages. DONE 2026-07-07: conforming GuideCheck Level 3 profile (verifier: 0 findings), byte-identical `docs/.well-known/` pair + sidecar manifest, `docs/.nojekyll`, discovery wired (landing/README/llms.txt/pyproject), trust manifest re-pinned (9 files). Level 4 CONFIRMED end-to-end 2026-07-07 (guidecheck-hosted 0.7.0, achieved level 4, 0 blocking) against the live pair + sidecar + independent DNS TXT anchor at `_assistant-guide.harnessie.com`. Reminder: any future guide edit must move five sync points together — root file, `.well-known` copy, sidecar hash, trust-bundle pins (all four test-enforced by `tests/test_guide_artifacts.py`), and the DNS TXT value (manual). See CHANGELOG 0.6.0 and ROADMAP 0.6.0. -- Standing "break it" invitation. DONE 2026-07-07: `SECURITY.md` disclosure path (GitHub private vulnerability reporting) + "Break it" section publishing `evals/redteam.yaml` (3 canary-exfiltration scenarios, new `expect_events_absent` loop expectation); see CHANGELOG Unreleased and ROADMAP 0.6.0 Safety. End-to-end GHSA flow verifiable only once the repo is public. -- Graceful Boundaries conformance check and citation/gap list. DONE 2026-07-07: transport-adapted GB adoption (Level 1 grammar MET across all denial sites, Action Boundaries vocab aligned, SC-16 met, HTTP Levels 2-4 N/A); cited in GOVERNANCE.md §8 + INTENT.md §7, proven by `tests/test_graceful_boundaries.py`; see CHANGELOG Unreleased and ROADMAP 0.6.0. -- PyPI packaging prep only; publishing is an operator act. DONE 2026-07-07: published (operator-authorized in session); `pip install harnessie` is the documented entry. +- The 0.3 through 0.5 provider-rotation packets and the site-refresh packet are historical and already delivered. +- The old position sweep is retired because AIDR-0001 and AIDR-0002 are arbitrated and the tenets are ratified. +- `handoffs/HANDOFF-protocol-resistant-mechanisms.md` remains design input, not an executable handoff. +- `handoffs/skills-inventory-preliminary.md` remains a private standing research task and needs a fresh inventory before any adoption decision. +- `handoffs/scrub-list.txt` remains an active pre-commit control. -### 0.7.0 planned (post-launch, design gated) +## Recommended work order -ROADMAP.md now carries a full 0.7.0 section: sovereignty cascade routing (policy scoping over the existing reformulate/effort/tier gate ladder, containment-constrained ladders, sideways provider fallback distinct from upward escalation, sovereign tier slot, routing_trace) plus a containment boundary (deterministic PII strip/rehydrate adapted from PAICE.work PBC production code, a stricter secrets class with tool-output scrubbing, per-tool rehydration grants on the approval-policy grammar) and its eval-shaped proof (canary leak evals, gate-integrity canaries, bundle-identity proven-brain claims). Scope was re-cut 2026-07-07: the three write-safety bullets (blast-radius ceilings, declared-write-path conflict refusal, maiden-voyage rule) moved to a new 0.8.0 "Write-safety and self-integrity" section together with the inward manifest, because they bound write damage rather than data exposure and had no 0.7 acceptance coverage. The 0.6 budget-safety hardening prerequisite CLOSED 2026-07-07 (see Known limits). GATE SATISFIED 2026-07-07: the adoption decision ran twice through `workflows/contested-decision.yaml` on Ollama Cloud brains — `decisions/AIDR-0003` (six models, six providers, 3-3 split, arbitrated: redraft first), spec redrafted (coverage-table claim scoping, routing owns the unstructured residual, strip-map lifecycle designed, placeholder-impact deltas published), then `decisions/AIDR-0004` (same panel, unanimous recommend, arbitrated: implement; the clean-convergence run `20260707-105241-DUJM8J` completed the workflow end-to-end). Both records lint PASS with independent-positions, dissent-preserved, human-arbitrated. 0.7 implementation is OPEN. Note for the boundary work: vendoring `pii_service.py` -> `harness/boundary.py` requires the PBC written grant recorded in NOTICE before any public commit (operator act, per the standing NOTICE rule). Fuller planning context is in the operator's private planning note. +1. Review and merge Homebrew tap draft PR [#1](https://github.com/snapsynapse/homebrew-tap/pull/1) when ready. The local verify-action checkout was synchronized on 2026-07-21. +2. Continue 0.8 in eval-first slices. Declared write-path conflict refusal and parallel declared-lane enforcement are complete; next add blast-radius ceilings, then the inward manifest and maiden-voyage rule. +3. Invite wrapper-engine contributions only after the v0.1.0 original seed, and accept another developer's implementation only through their own consenting contribution. Keep backend claims probe-gated and platform-specific. +4. Take the smaller hardening backlog after the 0.8 write boundary is structurally defined: malformed provider-response handling, structured memory frontmatter, and macOS sandbox parity for writes outside the workspace. -## Non-goals standing +## Operator-attended or external checks -- No Pages/DNS/public-repo/PyPI promotion from a headless agent session. -- No unattended external live-provider calls. -- No external mention of Harnessie before operator launch. -- No agent-authored or edited Arbitration sections. -- No annotated tags or release-checklist ceremony until public promotion. -- Do not stage `.agents/`, `.codex/`, `handoffs/`, `runs/`, `workspace/`, or `ROADMAP-PRIVATE.md`. -- Private planning notes for this repo live in `ROADMAP-PRIVATE.md` at repo root (gitignored, not tracked); its contents are never referenced from any tracked file beyond this line. +- Confirm whether the live Siteline score has reached the roadmap bar of 90; the tracked roadmap still treats it as open. +- Configure the dogfood verifier repository variables and secret if live PR verdicts are desired. +- Live provider scorecards remain explicit opt-in operations via `HARNESSIE_LIVE=1`. -## First commands for the next agent +## Session start commands ```bash -git status --short --branch && git log --oneline -8 +git status --short --branch python3 -m pytest -q python3 -m harness.cli eval python3 -m harness.cli verify-manifest -python3 -m harness.cli eval --live +git diff --check ``` + +Private planning notes remain in `ROADMAP-PRIVATE.md`. Do not stage `.agents/`, `.codex/`, `handoffs/`, `runs/`, `workspace/`, or `ROADMAP-PRIVATE.md`. diff --git a/ROADMAP.md b/ROADMAP.md index 1817e64..5596807 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,10 +44,10 @@ Acceptance: a requires_approval tool blocks headless by default and proceeds onl ### 0.6.0: First-harness readiness (public launch gate) - SHIPPED -Theme: make "the safest and easiest first AI harness for people" true for someone who has never identified as a developer, and make the safety claim falsifiable for the developers who will audit it. This milestone gated the public launch; it does not displace 0.4 portability or 0.5 operability, both of which it depends on. Released 2026-07-07; the repo and canonical page are public. Two acceptance items close as follow-ups now that the site is live: the Siteline live-page bar (the hero CTAs were sharpened for it; a re-scan after Pages redeploys should clear 90) and the GuideCheck `.well-known` pair (content and sidecar land next, the end-to-end hash verify needs the served tree). +Theme: make "the safest and easiest first AI harness for people" true for someone who has never identified as a developer, and make the safety claim falsifiable for the developers who will audit it. This milestone gated the public launch; it does not displace 0.4 portability or 0.5 operability, both of which it depends on. Released 2026-07-07; the repo and canonical page are public. GuideCheck Level 4 is confirmed end to end. The Siteline live-page bar remains an explicit follow-up until a current score of 90 or above is recorded. Ease (the first-run path): -- PyPI packaging: `pip install harnessie` (or `pipx install harnessie`) replaces clone-and-editable-install as the documented entry; signed, tagged releases with `RELEASE_CHECKLIST.md` per the repo-standards promotion path. GREEN: harnessie 0.6.0 is on PyPI (wheel + sdist, twine check passed, artifacts swept for private files before upload, LICENSE + NOTICE included); a fresh `pip install harnessie` from the live index reaches the guided init's green zero-dollar run; `pip install harnessie` is now the documented entry across README, quickstart, getting-started, and the landing page, with source install kept for development. Remaining from this bullet: the tag/release ceremony (`RELEASE_CHECKLIST.md`) is still the operator's. +- PyPI packaging: `pip install harnessie` (or `pipx install harnessie`) replaces clone-and-editable-install as the documented entry; signed, tagged releases use `RELEASE_CHECKLIST.md` per the repo-standards promotion path. GREEN: Harnessie 0.6.0 shipped on PyPI and as a tagged GitHub release; subsequent 0.7.0 and 0.7.1 releases use the same ceremony. A fresh install from the live index reaches the guided init's green zero-dollar run, with source installation kept for development. - Guided first run: `harnessie init` grows an interactive setup that checks Python version, detects a sandbox backend, walks API-key setup via environment variable (never a file), and ends with a green mock-brain run so the first experience costs zero dollars. GREEN (`harness/firstrun.py`). - Plain-language operator surface: `harnessie report` and every halt message readable by a non-developer; each stop condition explains itself in one sentence and names the single next action (the README halt table becomes the in-tool text, not just docs). GREEN (`harness/explain.py`). - Pre-run cost preview: before a live run, show the configured ceilings and a worst-case dollar estimate; refuse to start when no ceiling is set. GREEN (`harness/preflight.py`). @@ -70,7 +70,7 @@ Acceptance: a non-developer given only the quickstart reaches a green first run Theme: route every task to the least-exposed environment that can complete it, and make containment a mechanical property of the run rather than an operator habit. Extends the existing gate ladder (reformulate, then effort up, then tier up) into declared, containment-aware routing policy. Opens only after the 0.6 launch gate closes, and only after the design passes a contested-decision run recorded as an AIDR: the harness's own governance decides its routing layer. Redrafted 2026-07-07 after `decisions/AIDR-0003` arbitrated "do not adopt as first specified": the containment claim is now a per-data-class coverage table rather than a blanket statement, contained routing explicitly owns the unstructured residual the filter cannot catch, the strip-map lifecycle across resume is designed rather than deferred, and placeholder impact on gate pass rates becomes a published per-brain number. -Prerequisite (carried from 0.6 known limits): the budget-safety hardening closes before routing work opens. Today each phase in a parallel group is seeded with the full run ceiling rather than remaining headroom, so a group entered near the ceiling can collectively overshoot before merge-back reconciles. Escalation headroom (below) builds directly on that fix; it lands first. +Prerequisite (carried from 0.6 known limits): GREEN. `Budget.child()` gives parallel phases headroom-scoped budgets with live charge-through to the run budget, and escalation headroom builds on that enforcement. Routing (policy over the existing ladder): @@ -110,7 +110,7 @@ Theme: extract the VerificationGate as a standalone surface (`harnessie verify`) Theme: bound what a run may change, the way 0.7 bounds what a run may expose. 0.7's containment boundary limits data leaving the harness; this milestone limits damage inside it, and extends the same integrity discipline to the harness's own configuration. These mechanisms are independent of the routing engine and the containment boundary, which is why they ship as their own claim rather than riding the sovereignty milestone. - Blast-radius ceilings, the artifact-volume sibling of the cost budget: per-phase caps on files touched, edits applied, and workspace bytes written, plus a per-run escalation cap. A cap hit fails the phase with the count, never best-effort-continues. Today a worker can write ten thousand files without denting the token budget; volume becomes a bounded resource like dollars and tokens. -- Declared-write-path conflict refusal for parallel groups: phases in one parallel group declare their write paths up front, and overlapping declarations refuse the run before any work starts — static conflict detection layered under the existing per-phase workspace isolation. +- Declared-write-path conflict refusal for parallel groups: GREEN. Phases may declare exact files or directory subtrees up front; partial opt-in, ambiguous declarations, and portable case/Unicode aliases fail closed, while overlapping declarations refuse before workspace creation or model dispatch. Declared operator and agent ownership lanes remain enforced inside isolated phase workspaces. Static conflict detection is layered under the existing workspace isolation, and legacy groups that do not opt in retain their 0.7 behavior. - Maiden-voyage rule: the first run of a workflow under a new phase type executes propose-only (artifacts staged, nothing applied), and write behavior unlocks only after the operator approves the maiden output. First contact with new automation is read-only by construction. - Inward manifest: the trust-bundle integrity check turned on the harness itself — role prompts (`agents/*.md`) and shipped configs are hash-pinned, and a run under a modified prompt or config either records the divergence or refuses, per policy. The outward manifest proves the public surface; this proves the machine that produced the run. @@ -140,7 +140,7 @@ Deliberately after 1.0, not before: macOS is fully supported: the OS sandbox uses native `sandbox-exec` (Seatbelt), confining child-command writes to the workspace and denying network by default. Linux backends (bubblewrap preferred, firejail alternate, docker fallback) are implemented as of the 0.4 line, each admitted only after a startup smoke test; CI proves the suite green under bubblewrap and proves fail-closed with every backend removed. On Windows, and on any host where no backend passes its smoke test, shell-using workflows fail closed. This is the fail-closed-everywhere policy working as designed, not a bug: a control that cannot be enforced is refused rather than skipped. -### Linux support (0.4.0 target) +### Linux backend design (shipped in 0.4.0) This is the headline portability need. The same security policy the macOS backend enforces (writes confined to the workspace, network denied by default, per-phase `allow_network` opt-in) must be expressed with Linux primitives. diff --git a/audits/handoff-relevance-2026-07-21.md b/audits/handoff-relevance-2026-07-21.md new file mode 100644 index 0000000..c63f7e9 --- /dev/null +++ b/audits/handoff-relevance-2026-07-21.md @@ -0,0 +1,86 @@ +# Handoff relevance audit, 2026-07-21 + +Scope: all files under `handoffs/`, the current tracked handoff in `NEXT.md`, the untracked 2026-07-07 code review, and release coupling with `harnessie-verify-action` and `snapsynapse/homebrew-tap`. + +## Outcome + +Most handoffs are historical evidence, not current instructions. GitHub `main` added one decisive forward item missing from the local checkout at the start of this audit: AIDR-0008 was arbitrated and authorized a separate probe-gated wrapper repository with an original minimal seed. That repository and the first Harnessie 0.8 write-safety slice now exist. The remaining 0.8 mechanics, smaller runtime hardening findings, and review of one Homebrew discovery PR remain current. + +## File-by-file disposition + +| Artifact | Disposition | Evidence and next action | +|---|---|---| +| `NEXT.md` | Active, refreshed | Rewritten to describe GitHub `main` at `eb07488`, the arbitrated AIDR-0008 work, 0.7.1, the 0.8 milestone, current cross-repo state, and current verification. | +| `handoffs/HANDOFF-CLAUDE.md` | Historical | Its v0.5 verification scope shipped. Current tests and evals supersede its expected counts. Retain only as rotation evidence. | +| `handoffs/HANDOFF-CODEX.md` | Historical | Its 0.4, 0.5, and 0.6 rungs shipped. Its operator-gate warnings remain useful history but are no longer the current work packet. | +| `handoffs/HANDOFF-GEMINI.md` | Historical | The 0.4 review lane shipped. No remaining execution item. | +| `handoffs/CHANGES.md` | Delivered design packet | The split hero, proof strip, video, animated pipeline, and generated-doc side rail are present in the live source. | +| `handoffs/Screenshot 2026-07-07 at 11.29.47 AM.png` | Historical source asset | Companion design evidence. No current execution item. | +| `handoffs/flowchart-ai.jpeg` | Historical source asset | Companion design evidence. No current execution item. | +| `handoffs/codex-session-notes.md` | Historical log | Retain append-only as rotation provenance. Do not use as current state. | +| `handoffs/v0.3.2-inventory.md` | Historical implementation packet | Its refusal and identifier work shipped. Retain as rationale for exclusions that later code may still depend on. | +| `handoffs/position-sweep/` | Retired | AIDR-0001 and AIDR-0002 now contain Arbitration, and the tenets are ratified. Do not resume the old sweep. | +| `handoffs/HANDOFF-protocol-resistant-mechanisms.md` | Relevant design input, not executable | The concession and real-stakes questions remain open, but the file depends on a source transcript and an unconfirmed Harnessie/Turnfile scope boundary. Route through a new decision or roadmap proposal before implementation. | +| `handoffs/skills-inventory-preliminary.md` | Relevant standing research, stale inventory | The assessment bar remains sound. Refresh sources and candidates before adopting anything; do not treat the 2026-07-06 shortlist as current. | +| `handoffs/scrub-list.txt` | Active control | Run its staged-diff check before every public commit. | +| `ROADMAP-PRIVATE.md` | Active private plan, pruned | The shipped 0.7 license and implementation block was obsolete and has been removed; AIDR-0008 now records the approved separate-repository execution boundary. | +| `audits/code-review-2026-07-07.md` | Mixed, untracked | Its P0 provenance, boundary wiring, and sovereign escalation findings were fixed before 0.7 shipped. Its parallel ownership, sandbox parity, provider-shape handling, memory-frontmatter, and release-claim drift findings remain relevant. | + +## Cross-repo findings + +### harnessie-verify-action + +The local checkout was fast-forwarded from `c101caf` to remote `main` at `3a2f1bb`. The extra commit adds repository guidance files and does not change the action runtime or release. The action remains v0.1.0, its default `harnessie-version` remains 0.7.1, and Harnessie consumes the stable major tag `@v0`. + +Completed this session: inspected the remote housekeeping diff and fast-forwarded the clean local checkout. No Harnessie source change was required. + +### snapsynapse/homebrew-tap + +The live formula is current at Harnessie 0.7.1 and uses the PyPI sdist with SHA-256 `a584cfbda10eeb4e6993077d5a766644a248204cde68caff23988db7382ba4c7`. Draft PR [#1](https://github.com/snapsynapse/homebrew-tap/pull/1) adds Harnessie to the README formula list and install example without changing the formula. + +Completed this session: the README change passed `ruby -c Formula/harnessie.rb` and `git diff --check`, then was committed and pushed for review. Merging the draft remains an operator action. + +### GitHub source of truth + +GitHub `main` is `eb07488`, two commits ahead of the checkout used for the first audit pass. Commit `228fb47` arbitrates AIDR-0008; commit `eb07488` adds repository guidance. The latest CI and Pages runs for `eb07488` succeeded. GitHub reports no open pull requests or issues, and v0.7.1 remains the latest release. + +The tracked GitHub `NEXT.md` still said AIDR-0008 was open even though the decision record was arbitrated. This audit treats the decision record as authoritative and corrects `NEXT.md` accordingly. + +The approved work now ships in [snapsynapse/harnessie-engine-wrappers](https://github.com/snapsynapse/harnessie-engine-wrappers) v0.1.0. Commit `ad3d759` passed a real macOS-14 deny/allow/symlink containment probe and an Ubuntu fail-closed unsupported-platform check. Release archives, wheel, and `SHA256SUMS` are attached. + +## Current implementation priorities + +### Separate repository delivered under AIDR-0008 + +The fresh-authored Apache-2.0 seed provides the minimal macOS Seatbelt reference wrapper, shared credential deny policy, and admission probe. The first adversarial run exposed a crucial false-positive shape: a denied read alone can look successful when `sandbox-exec` itself fails under an outer sandbox. The shipped admission contract therefore requires denied direct and symlink reads plus a successful allowed control. Contributor outreach may now follow; another developer's work still lands only through their own consenting contribution. + +### First slice: parallel write ownership and 0.8 conflict refusal + +Implemented on `agent/handoff-and-write-safety`. Opt-in `writes` declarations use exact files and directory subtrees; partial opt-in, ambiguous input, portable case/Unicode aliases, and overlaps refuse before workspace creation or dispatch. Parallel registries receive an isolated ownership view that enforces declared operator, agent, and collaborative lanes without sharing first-writer claims across physically separate phase workspaces. + +Acceptance evidence proves: + +- Overlapping declared write paths refuse the whole group before any phase starts. +- Operator-owned paths remain denied inside a parallel phase. +- Disjoint declared paths still execute concurrently. +- Refusal is recorded and downstream phases do not run. + +### Subsequent 0.8 slices + +1. Blast-radius ceilings with atomic stop semantics and per-phase/per-run counters. +2. Inward manifest for role prompts and shipped configs, with record-or-refuse policy on divergence. +3. Maiden-voyage propose-only execution with explicit operator approval before write behavior unlocks. + +### Smaller hardening backlog + +- Convert malformed JSON and unexpected response shapes from both provider adapters into sanitized `model_error` turns instead of exceptions. +- Serialize memory frontmatter structurally and validate dates/multiline scalar inputs. +- Align macOS temporary-path behavior with the documented workspace-only write claim, or narrow the public claim and test the actual boundary. +- Add a single release gate that composes pytest, eval, manifest verification, generated-doc checks, provenance consistency, and public-surface scrubbing. + +## Gates and non-actions + +- Do not copy or adapt third-party wrapper code while implementing AIDR-0008. Author the seed fresh, then invite consenting contributions. +- Do not resume the retired position sweep. +- Do not use historical expected test counts as release assertions. +- Do not stage private `handoffs/` or `ROADMAP-PRIVATE.md` content. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 270c21b..ebe20f6 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -147,9 +147,12 @@ Phase fields: - `inject_memory_status`: prepend a deterministic memory-and-prior-run digest to the task. - `approve_tools`: operator-recorded pre-approval for approval-gated tools, scoped to this phase. - `parallel`: phases with the same label and placed consecutively run concurrently in separate workspaces under `workspace/.phases/`. +- `writes`: optional static write declaration for a parallel phase. An exact path such as `reports/result.json` declares one file; a trailing slash such as `dist/` declares that directory and all descendants. Globs, absolute paths, traversal, backslashes, and ambiguous path segments are rejected. Once one phase in a parallel group uses `writes`, every phase in that group must declare it; `writes: []` is the explicit read-only form. Invalid or overlapping declarations refuse the entire group before workspace creation or model dispatch. Groups with no `writes` key retain the 0.7 behavior. Prior-phase reports are treated as untrusted model output: before substitution they pass through the same quarantine filter that scans tool results, so injection attempts inside a report are fenced as data rather than followed. The operator's `goal` is never fenced. +Parallel workspace isolation does not suspend declared ownership. Operator-owned paths remain unwritable, agent lanes remain exclusive to the named agent, and collaborative lanes remain shared. First-writer auto-claims are not shared between phase workspaces because two equal relative names identify physically separate artifacts; cross-phase collision prevention is the static `writes` preflight when a workflow opts into it. + Adversarial phases. A phase with `mode: adversarial` runs a panel instead of a single worker. Each `positions` entry is an agent on a task class (choose different task classes to get genuinely different brains, which is what earns the record its independent-positions claim). After `rebuttal_rounds` of objections, `arbitration: convergence` passes only on unanimous agreement with zero open objections; anything else halts as `needs_arbitration` with a decision record. See [Governance](#governance-consent-contests-and-arbitration). Approval policy. Approval-gated tools deny closed unless authorized. A workflow may use `approve_tools` for phase-local recorded pre-approval, or an operator can pass `--approval-policy approvals.yaml` with `allow` and `deny` lists. Each rule names a `tool` and may name a `phase`; explicit deny wins. `--approve-interactive` prompts on a TTY when no policy rule matches. diff --git a/docs/guide.html b/docs/guide.html index 2447836..320cb1d 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -190,8 +190,9 @@

Writing a workflow

The implement phase finished with: {implement} Write the final operator summary with evidence and follow-ups.

Phase fields:

-
  • name: the phase id, and the placeholder later phases use to read its report.
  • agent: the role to run. orchestrator, or a worker or verifier defined under agents/.
  • task_class: the routing key. Looked up in config/models.yaml to pick tier and effort.
  • task: the task template. {goal} is the operator's goal; {phase_name} is a prior phase's report.
  • max_steps: the loop's step ceiling for this phase.
  • verify: the gate (worker phases). checks are shell commands that must exit 0; verifier names an independent judge in agents/verifiers/; max_attempts bounds the reformulate-and-retry loop; criteria is what the verifier judges against.
  • deny_tools: tools removed from this phase, narrowing the blast radius of untrusted content.
  • allow_network: opt this phase's sandboxed shell into network access (off by default).
  • inject_memory_status: prepend a deterministic memory-and-prior-run digest to the task.
  • approve_tools: operator-recorded pre-approval for approval-gated tools, scoped to this phase.
  • parallel: phases with the same label and placed consecutively run concurrently in separate workspaces under workspace/.phases/<phase>.
+
  • name: the phase id, and the placeholder later phases use to read its report.
  • agent: the role to run. orchestrator, or a worker or verifier defined under agents/.
  • task_class: the routing key. Looked up in config/models.yaml to pick tier and effort.
  • task: the task template. {goal} is the operator's goal; {phase_name} is a prior phase's report.
  • max_steps: the loop's step ceiling for this phase.
  • verify: the gate (worker phases). checks are shell commands that must exit 0; verifier names an independent judge in agents/verifiers/; max_attempts bounds the reformulate-and-retry loop; criteria is what the verifier judges against.
  • deny_tools: tools removed from this phase, narrowing the blast radius of untrusted content.
  • allow_network: opt this phase's sandboxed shell into network access (off by default).
  • inject_memory_status: prepend a deterministic memory-and-prior-run digest to the task.
  • approve_tools: operator-recorded pre-approval for approval-gated tools, scoped to this phase.
  • parallel: phases with the same label and placed consecutively run concurrently in separate workspaces under workspace/.phases/<phase>.
  • writes: optional static write declaration for a parallel phase. An exact path such as reports/result.json declares one file; a trailing slash such as dist/ declares that directory and all descendants. Globs, absolute paths, traversal, backslashes, and ambiguous path segments are rejected. Once one phase in a parallel group uses writes, every phase in that group must declare it; writes: [] is the explicit read-only form. Invalid or overlapping declarations refuse the entire group before workspace creation or model dispatch. Groups with no writes key retain the 0.7 behavior.

Prior-phase reports are treated as untrusted model output: before substitution they pass through the same quarantine filter that scans tool results, so injection attempts inside a report are fenced as data rather than followed. The operator's goal is never fenced.

+

Parallel workspace isolation does not suspend declared ownership. Operator-owned paths remain unwritable, agent lanes remain exclusive to the named agent, and collaborative lanes remain shared. First-writer auto-claims are not shared between phase workspaces because two equal relative names identify physically separate artifacts; cross-phase collision prevention is the static writes preflight when a workflow opts into it.

Adversarial phases. A phase with mode: adversarial runs a panel instead of a single worker. Each positions entry is an agent on a task class (choose different task classes to get genuinely different brains, which is what earns the record its independent-positions claim). After rebuttal_rounds of objections, arbitration: convergence passes only on unanimous agreement with zero open objections; anything else halts as needs_arbitration with a decision record. See Governance.

Approval policy. Approval-gated tools deny closed unless authorized. A workflow may use approve_tools for phase-local recorded pre-approval, or an operator can pass --approval-policy approvals.yaml with allow and deny lists. Each rule names a tool and may name a phase; explicit deny wins. --approve-interactive prompts on a TTY when no policy rule matches.

Verification options. Start with the offline deterministic path: pytest, harnessie eval, and harnessie verify-manifest. When a local OpenAI-compatible endpoint such as Ollama is already running, HARNESSIE_LIVE=1 HARNESSIE_OPENAI_COMPAT_BASE_URL=http://localhost:11434/v1 harnessie eval --live gives a live-local scorecard without external provider calls. External provider scorecards are attended operations. CLI fan-out, local model review, or a separate model-family review can strengthen a change, but it is review evidence; the merge proof remains the repo's tests, evals, manifests, and audit records.

diff --git a/evals/operability.yaml b/evals/operability.yaml index 6c300e0..922ce56 100644 --- a/evals/operability.yaml +++ b/evals/operability.yaml @@ -70,3 +70,10 @@ scenarios: expect_files: left/out.txt: left right/out.txt: right + + - id: parallel_declared_write_conflict_refuses_before_dispatch + kind: parallel + declare_same_write: true + expect_statuses: [passed, needs_human, needs_human] + expect_parallel_workspace_absent: true + expect_event: parallel_write_conflict diff --git a/evals/stewardship.yaml b/evals/stewardship.yaml index caa737b..e2aeb7c 100644 --- a/evals/stewardship.yaml +++ b/evals/stewardship.yaml @@ -21,9 +21,10 @@ scenarios: - NEXT.md require_contains: - "Current state" - - "Verification status" - - "Operator-attended steps" - - "Non-goals standing" + - "Verified baseline" + - "Current cross-repo state" + - "Recommended work order" + - "Operator-attended or external checks" - "python3 -m pytest -q" - "python3 -m harness.cli eval" - - "0.6.0 headless subset" + - "0.8.0" diff --git a/harness/evals.py b/harness/evals.py index 2481f3c..61e09ce 100644 --- a/harness/evals.py +++ b/harness/evals.py @@ -348,6 +348,14 @@ def _run_parallel_scenario(scenario: dict[str, Any]) -> EvalCaseResult: with tempfile.TemporaryDirectory(prefix="harnessie-eval-") as d: root = Path(d) _scaffold_eval_project(root, max_attempts=1, parallel=True) + if scenario.get("declare_same_write"): + workflow_path = root / "workflows" / "parallel.yaml" + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + for phase in workflow["phases"]: + if phase.get("parallel"): + phase["writes"] = ["out.txt"] + workflow_path.write_text(yaml.safe_dump(workflow, sort_keys=False), + encoding="utf-8") def brain(messages: list[Message]) -> AssistantTurn: task = messages[1].content @@ -407,6 +415,16 @@ def brain(messages: list[Message]) -> AssistantTurn: ok = verify_chain(root / "runs" / "evalrun")["ok"] if ok != bool(scenario["expect_audit_ok"]): problems.append(f"audit chain ok={ok}") + if scenario.get("expect_parallel_workspace_absent") and \ + (root / "workspace" / ".phases").exists(): + problems.append("parallel workspace exists; expected pre-dispatch refusal") + expected_event = scenario.get("expect_event") + if expected_event: + events_path = root / "runs" / "evalrun" / "events.jsonl" + events = [json.loads(line) for line in + events_path.read_text(encoding="utf-8").splitlines()] + if not any(event.get("kind") == expected_event for event in events): + problems.append(f"expected event missing: {expected_event}") return EvalCaseResult( id=scenario["id"], passed=not problems, diff --git a/harness/ownership.py b/harness/ownership.py index 783beb4..dbbfa13 100644 --- a/harness/ownership.py +++ b/harness/ownership.py @@ -81,8 +81,14 @@ def owner_of(self, rel: str) -> str | None: return agent return self.files.get(rel) - def check_write(self, agent: str, rel: str) -> tuple[bool, str]: - """May `agent` write workspace-relative `rel`? (allowed, reason).""" + def declared_write(self, agent: str, rel: str) -> tuple[bool, str] | None: + """Evaluate operator/agent/collaborative lanes only. + + `None` means no declared lane matched and the caller may apply its own + auto-claim semantics. Isolated parallel workspaces use this seam so + declared authority remains enforced without conflating two physically + separate `out.txt` files into one first-writer claim. + """ if any(fnmatch(rel, g) for g in self.operator): return False, (f"{rel!r} is in an operator-owned lane; no agent may " "write it. This is not negotiable at agent level.") @@ -95,6 +101,13 @@ def check_write(self, agent: str, rel: str) -> tuple[bool, str]: "request_change to record what you need changed.") if any(fnmatch(rel, g) for g in self.collaborative): return True, "collaborative lane" + return None + + def check_write(self, agent: str, rel: str) -> tuple[bool, str]: + """May `agent` write workspace-relative `rel`? (allowed, reason).""" + declared = self.declared_write(agent, rel) + if declared is not None: + return declared claimed = self.files.get(rel) if claimed and claimed != agent: return False, (f"{rel!r} is owned by agent {claimed!r} (first " @@ -114,3 +127,31 @@ def claim(self, agent: str, rel: str) -> bool: self.files[rel] = agent self.save() return True + + def isolated_view(self) -> "IsolatedOwnershipView": + return IsolatedOwnershipView(self) + + +@dataclass(frozen=True) +class IsolatedOwnershipView: + """Declared-lane enforcement for a physically isolated phase workspace. + + Auto-claims are intentionally absent: two phase-local files with the same + relative name are different artifacts. Static `writes` preflight owns + cross-phase collision prevention when a workflow opts into that 0.8 seam. + """ + ledger: OwnershipLedger + + def owner_of(self, rel: str) -> str | None: + for agent, globs in self.ledger.agent_lanes.items(): + if any(fnmatch(rel, glob) for glob in globs): + return agent + return None + + def check_write(self, agent: str, rel: str) -> tuple[bool, str]: + declared = self.ledger.declared_write(agent, rel) + return declared if declared is not None else \ + (True, "isolated phase workspace") + + def claim(self, agent: str, rel: str) -> bool: + return False diff --git a/harness/runner.py b/harness/runner.py index ab7c4d1..55cf2cd 100644 --- a/harness/runner.py +++ b/harness/runner.py @@ -31,6 +31,7 @@ from .models import build_model from .models.base import EFFORT_LEVELS, ModelSpec from .ownership import OwnershipLedger +from .write_safety import WriteDeclarationError, parallel_write_conflicts from .quarantine import guard_result from .roles import RoleLibrary from .routing import Budget, Route, Router, VALID_TIERS @@ -436,6 +437,49 @@ def _run_parallel_group( phases: list[dict], reports: dict[str, str], ) -> list[PhaseOutcome]: + try: + conflicts = parallel_write_conflicts(phases) + except WriteDeclarationError as exc: + detail = str(exc) + self.events.emit( + "parallel_write_declaration_invalid", + group=phases[0].get("parallel"), + phases=[phase["name"] for phase in phases], + detail=detail, + ) + report = ( + "invalid parallel write declaration: " + detail + "; " + "declare a writes list for every phase using exact files or " + "directory roots ending in '/'" + ) + return [PhaseOutcome(phase["name"], "needs_human", report) + for phase in phases] + if conflicts: + first = conflicts[0] + involved = sorted({conflict.left_phase for conflict in conflicts} | + {conflict.right_phase for conflict in conflicts}) + paths = sorted({conflict.left.raw for conflict in conflicts} | + {conflict.right.raw for conflict in conflicts}) + self.events.emit( + "parallel_write_conflict", + group=phases[0].get("parallel"), + phases=involved, + paths=paths, + conflicts=[{ + "left_phase": conflict.left_phase, + "left": conflict.left.raw, + "right_phase": conflict.right_phase, + "right": conflict.right.raw, + } for conflict in conflicts], + ) + report = ( + "declared write-path conflict: " + f"phases {first.left_phase!r} ({first.left.raw!r}) and " + f"{first.right_phase!r} ({first.right.raw!r}) overlap; " + "make every parallel phase's writes declaration disjoint and resume" + ) + return [PhaseOutcome(phase["name"], "needs_human", report) + for phase in phases] snapshot = dict(reports) self.events.emit("parallel_group_start", group=phases[0].get("parallel"), @@ -477,7 +521,7 @@ def _run_parallel_phase( workspace.mkdir(parents=True, exist_ok=True) registry = ToolRegistry() register_builtin(registry, workspace=workspace, - ledger=None, events=self.events, + ledger=self.ledger.isolated_view(), events=self.events, memory=self.memory, provenance=f"run {self.run_id}, phase {name}") task = self._render_task(phase, reports) diff --git a/harness/tools/builtin.py b/harness/tools/builtin.py index c3efb7c..a2f0978 100644 --- a/harness/tools/builtin.py +++ b/harness/tools/builtin.py @@ -33,7 +33,7 @@ from ..events import EventLog from ..memory import FACT_TYPES, ProjectMemory -from ..ownership import OwnershipLedger +from ..ownership import IsolatedOwnershipView, OwnershipLedger from ..quarantine import find_secrets, redact_secrets from ..sandbox import SandboxUnavailable, wrap as sandbox_wrap from .registry import Refusal, ToolRefusal, ToolRegistry, ToolSpec @@ -68,7 +68,7 @@ def _jail(root: Path, rel: str) -> Path: def register_builtin(reg: ToolRegistry, workspace: Path, shell_allowlists: dict[str, tuple[str, ...]] | None = None, - ledger: OwnershipLedger | None = None, + ledger: OwnershipLedger | IsolatedOwnershipView | None = None, events: EventLog | None = None, memory: ProjectMemory | None = None, provenance: str = "") -> None: diff --git a/harness/write_safety.py b/harness/write_safety.py new file mode 100644 index 0000000..de1f069 --- /dev/null +++ b/harness/write_safety.py @@ -0,0 +1,114 @@ +"""Static write declarations for parallel phase preflight. + +The 0.8 contract deliberately accepts a decidable path language instead of +pretending arbitrary glob intersection is safe to infer: + +- `path/to/file.txt` declares one exact file. +- `path/to/directory/` declares that directory and every descendant. +- absolute paths, traversal, backslashes, and glob metacharacters are invalid. + +Legacy parallel groups with no `writes` key retain their 0.7 behavior. Once +any phase in a group opts in, every phase must declare `writes` (an empty list +is the explicit read-only declaration). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import PurePosixPath +import unicodedata + + +class WriteDeclarationError(ValueError): + """A group partially opted in or used an ambiguous path declaration.""" + + +@dataclass(frozen=True) +class WritePath: + value: PurePosixPath + directory: bool + raw: str + + +@dataclass(frozen=True) +class WriteConflict: + left_phase: str + left: WritePath + right_phase: str + right: WritePath + + +def parse_write_path(raw: object) -> WritePath: + if not isinstance(raw, str): + raise WriteDeclarationError("write paths must be strings") + if not raw or raw != raw.strip() or "\n" in raw or "\r" in raw: + raise WriteDeclarationError("write paths must be non-empty single lines without surrounding whitespace") + if "\\" in raw: + raise WriteDeclarationError(f"write path {raw!r} must use POSIX '/' separators") + if any(ord(char) < 32 or ord(char) == 127 for char in raw): + raise WriteDeclarationError(f"write path {raw!r} contains control characters") + if any(char in raw for char in "*?[]"): + raise WriteDeclarationError( + f"write path {raw!r} uses glob syntax; declare an exact file or a directory ending in '/'") + directory = raw.endswith("/") + value_text = raw.rstrip("/") if directory else raw + segments = value_text.split("/") + if any(segment in ("", ".", "..") for segment in segments): + raise WriteDeclarationError( + f"write path {raw!r} contains an empty, current, or parent segment") + value = PurePosixPath(value_text) + if value.is_absolute() or value_text in ("", ".") or \ + any(part in ("", ".", "..") for part in value.parts): + raise WriteDeclarationError( + f"write path {raw!r} must stay beneath the phase workspace") + return WritePath(value=value, directory=directory, raw=raw) + + +def _comparison_parts(path: WritePath) -> tuple[str, ...]: + # Cross-platform safety: macOS commonly compares names case-insensitively + # and normalizes Unicode. Treat those aliases as conflicts everywhere so a + # workflow admitted on Linux cannot collide when moved to a Mac. + return tuple(unicodedata.normalize("NFC", part).casefold() + for part in path.value.parts) + + +def _contains(parent: WritePath, child: WritePath) -> bool: + parent_parts = _comparison_parts(parent) + child_parts = _comparison_parts(child) + if not parent.directory: + return parent_parts == child_parts + return child_parts[:len(parent_parts)] == parent_parts + + +def overlap(left: WritePath, right: WritePath) -> bool: + return _contains(left, right) or _contains(right, left) + + +def parallel_write_conflicts(phases: list[dict]) -> list[WriteConflict]: + opted_in = ["writes" in phase for phase in phases] + if not any(opted_in): + return [] + if not all(opted_in): + missing = [phase.get("name", "(unnamed)") for phase in phases + if "writes" not in phase] + raise WriteDeclarationError( + "every phase in an opted-in parallel group must declare writes; " + f"missing: {missing}") + + declared: list[tuple[str, WritePath]] = [] + for phase in phases: + values = phase.get("writes") + if not isinstance(values, list): + raise WriteDeclarationError( + f"phase {phase.get('name')!r} writes must be a list") + for raw in values: + declared.append((phase["name"], parse_write_path(raw))) + + conflicts: list[WriteConflict] = [] + for index, (left_phase, left) in enumerate(declared): + for right_phase, right in declared[index + 1:]: + if left_phase != right_phase and overlap(left, right): + conflicts.append(WriteConflict( + left_phase=left_phase, left=left, + right_phase=right_phase, right=right)) + return conflicts diff --git a/tests/test_ownership.py b/tests/test_ownership.py index 8691a52..f6704d6 100644 --- a/tests/test_ownership.py +++ b/tests/test_ownership.py @@ -84,6 +84,24 @@ def test_ledger_persists_round_trip(tmp_path): assert led2.owner_of("a.txt") == "alice" +def test_isolated_view_enforces_declared_lanes_without_auto_claims(tmp_path): + (tmp_path / "OWNERSHIP.yaml").write_text( + "lanes:\n" + " agent:\n" + " alice: ['src/*']\n" + " collaborative: ['shared/*']\n" + " operator: ['frozen/*']\n" + "files:\n" + " ordinary.txt: bob\n") + view = OwnershipLedger.load(tmp_path / "OWNERSHIP.yaml").isolated_view() + assert view.check_write("alice", "src/a.py")[0] + assert not view.check_write("bob", "src/a.py")[0] + assert not view.check_write("alice", "frozen/config.txt")[0] + assert view.check_write("alice", "shared/note.md")[0] + assert view.check_write("alice", "ordinary.txt")[0] + assert view.claim("alice", "ordinary.txt") is False + + # -- tool-layer enforcement ---------------------------------------------------- def make_agent_loop(tmp_path, agent, script): diff --git a/tests/test_runner.py b/tests/test_runner.py index 372b3bc..2b6752a 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -346,6 +346,96 @@ def test_parallel_phase_refuses_when_run_budget_already_exhausted(tmp_path, monk assert not (tmp_path / "workspace" / ".phases").exists() +def test_parallel_declared_write_conflict_refuses_before_dispatch(tmp_path, monkeypatch): + """0.8 red case: static overlap must refuse the whole group before any + phase workspace or model dispatch exists.""" + monkeypatch.setattr(sandbox, "wrap", + lambda argv, workspace, allow_network=False: argv) + scaffold_project(tmp_path) + (tmp_path / "workflows" / "conflict.yaml").write_text(textwrap.dedent(""" + name: conflict + phases: + - name: left + parallel: workers + agent: implementer + task: "Write left" + writes: [shared.txt] + - name: right + parallel: workers + agent: implementer + task: "Write right" + writes: [shared.txt] + """)) + runner = WorkflowRunner(project_root=tmp_path, run_id="conflict", echo=False) + brain = MockModel(ModelSpec(name="mid", provider="mock", model_id="mock")) + runner._models["mid"] = brain + + outcomes = runner.run_workflow(tmp_path / "workflows" / "conflict.yaml") + + assert [o.status for o in outcomes] == ["needs_human", "needs_human"] + assert all("declared write-path conflict" in o.report for o in outcomes) + assert brain.calls == [] + assert not (tmp_path / "workspace" / ".phases").exists() + events = [json.loads(line) for line in + (tmp_path / "runs" / "conflict" / "events.jsonl").read_text().splitlines()] + conflicts = [event for event in events + if event["kind"] == "parallel_write_conflict"] + assert len(conflicts) == 1 + assert conflicts[0]["phases"] == ["left", "right"] + + +def test_parallel_phase_enforces_operator_ownership_lane(tmp_path, monkeypatch): + """Parallel isolation must not erase operator-owned lane enforcement.""" + monkeypatch.setattr(sandbox, "wrap", + lambda argv, workspace, allow_network=False: argv) + scaffold_project(tmp_path) + (tmp_path / "OWNERSHIP.yaml").write_text(textwrap.dedent(""" + lanes: + agent: {} + collaborative: [] + operator: ['frozen/*'] + files: {} + """)) + (tmp_path / "workflows" / "ownership-parallel.yaml").write_text(textwrap.dedent(""" + name: ownership-parallel + phases: + - name: blocked + parallel: workers + agent: implementer + task: "Write blocked" + - name: allowed + parallel: workers + agent: implementer + task: "Write allowed" + """)) + + def brain(messages): + task = messages[1].content + last = messages[-1].name + if last == "accept_task" and "blocked" in task: + return turn_tool("write_file", {"path": "frozen/config.txt", "content": "no"}) + if last == "accept_task" and "allowed" in task: + return turn_tool("write_file", {"path": "result.txt", "content": "yes"}) + if last == "write_file": + return turn_tool("task_complete", {"report": "attempted"}) + return turn_tool("accept_task", {}) + + runner = WorkflowRunner(project_root=tmp_path, run_id="ownership-parallel", echo=False) + runner._models["mid"] = MockModel( + ModelSpec(name="mid", provider="mock", model_id="mock"), fn=brain) + outcomes = runner.run_workflow( + tmp_path / "workflows" / "ownership-parallel.yaml") + + assert [o.status for o in outcomes] == ["passed", "passed"] + phases = tmp_path / "workspace" / ".phases" + assert not (phases / "blocked" / "frozen" / "config.txt").exists() + assert (phases / "allowed" / "result.txt").read_text() == "yes" + events = [json.loads(line) for line in + (tmp_path / "runs" / "ownership-parallel" / "events.jsonl").read_text().splitlines()] + assert any(event["kind"] == "ownership_denied" and + event["path"] == "frozen/config.txt" for event in events) + + def test_parallel_spend_flows_to_run_budget_without_double_count(tmp_path, monkeypatch): monkeypatch.setattr(sandbox, "wrap", lambda argv, workspace, allow_network=False: argv) diff --git a/tests/test_write_safety.py b/tests/test_write_safety.py new file mode 100644 index 0000000..07119df --- /dev/null +++ b/tests/test_write_safety.py @@ -0,0 +1,64 @@ +import pytest + +from harness.write_safety import ( + WriteDeclarationError, + overlap, + parallel_write_conflicts, + parse_write_path, +) + + +def test_exact_files_are_disjoint_but_equal_files_conflict(): + assert not overlap(parse_write_path("left.txt"), parse_write_path("right.txt")) + assert overlap(parse_write_path("same.txt"), parse_write_path("same.txt")) + + +def test_directory_roots_conflict_with_descendants(): + assert overlap(parse_write_path("dist/"), parse_write_path("dist/app.js")) + assert overlap(parse_write_path("dist/"), parse_write_path("dist/assets/")) + assert not overlap(parse_write_path("dist/"), parse_write_path("docs/index.html")) + + +@pytest.mark.parametrize("value", [ + "", ".", "/tmp/x", "../x", "a/../b", "a/./b", "a//b", + "*.txt", "a\\b", " x", "a\x00b", "a\tb", +]) +def test_ambiguous_or_escaping_declarations_fail_closed(value): + with pytest.raises(WriteDeclarationError): + parse_write_path(value) + + +def test_partial_group_opt_in_fails_closed(): + phases = [ + {"name": "left", "writes": []}, + {"name": "right"}, + ] + with pytest.raises(WriteDeclarationError, match="every phase"): + parallel_write_conflicts(phases) + + +def test_conflict_result_names_both_phases_and_paths(): + conflicts = parallel_write_conflicts([ + {"name": "left", "writes": ["dist/"]}, + {"name": "right", "writes": ["dist/app.js"]}, + ]) + assert len(conflicts) == 1 + assert conflicts[0].left_phase == "left" + assert conflicts[0].right_phase == "right" + + +def test_portable_comparison_catches_case_and_unicode_aliases(): + assert overlap(parse_write_path("Dist/"), parse_write_path("dist/app.js")) + assert overlap(parse_write_path("café.txt"), parse_write_path("cafe\u0301.txt")) + + +def test_legitimate_similar_prefixes_do_not_false_positive(): + assert not overlap(parse_write_path("dist/"), parse_write_path("distribution/app.js")) + assert not overlap(parse_write_path("src/a.py"), parse_write_path("src/a.py.bak")) + + +def test_legacy_group_without_declarations_is_unchanged(): + assert parallel_write_conflicts([ + {"name": "left"}, + {"name": "right"}, + ]) == []