From 53b5e3889b6bf3bfef09817b829068d3c922ed84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=B1=D0=BE=D1=82?= Date: Mon, 12 Oct 2026 18:20:11 +0200 Subject: [PATCH 1/2] =?UTF-8?q?0.27.1=20=E2=80=94=20the=20temp-tree=20chec?= =?UTF-8?q?k=20reads=20the=20whole=20repository;=20the=20old=20leak=20is?= =?UTF-8?q?=20reapable=20by=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B-158: TEMP_MAKERS is make-skill 0.31.0's pattern (any single receiver, one-line renaming from-import refused); alias and renamed-import negative controls watched failing on the old pattern. - AG-06a: the check reads every *.py the repository ships or runs, with exact-path, exact-count, reasoned exemptions (residue.py 1, audit_agent.py 2). Its first run refused a real bypass in test/audit_regressions/instruction_budget.py:21, now residue.workspace(). New CI negative self-test for a plugin script. - AG-06b: `python3 test/residue.py --reap-own` (dry run by default, --apply) claims only mkdtemp-named, non-symlink trees that are byte for byte one of the old fixture's end states, unchanged for 24 h. test/reap_own_test.py runs in a sandbox temp root (6 cases, in npm test). Real $TMPDIR, dry: 0 trees match; nothing deleted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude-plugin/marketplace.json | 2 +- .github/workflows/validate.yml | 21 ++ CHANGELOG.md | 35 ++++ SKILL-CARD.md | 2 +- docs/evidence/backlog.md | 6 +- docs/evidence/verification.md | 19 ++ package.json | 4 +- .../agent-stack/.claude-plugin/plugin.json | 2 +- test/audit_regressions/instruction_budget.py | 7 +- test/reap_own.py | 179 ++++++++++++++++ test/reap_own_test.py | 191 ++++++++++++++++++ test/residue.py | 13 ++ test/validate.py | 122 ++++++++--- 13 files changed, 564 insertions(+), 39 deletions(-) create mode 100644 test/reap_own.py create mode 100644 test/reap_own_test.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9278a4b..f696772 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "displayName": "Agent Stack", "source": "./plugins/agent-stack", "description": "Four skills: agent-orchestrator — tool-calling loops, pipelines with checkpoints, provider routing with fallback, memory architecture, plus the wallet side of reselling LLM access; agent-evals — run/trace/thread evals, LLM judges, and fixtures grown from production; agent-interop — MCP servers and clients, A2A agent cards, the MCP Registry, and gateways; agent-harness — system prompts, tool shaping, workflow-vs-agent, and auditing an agent system.", - "version": "0.27.0", + "version": "0.27.1", "author": { "name": "ssheleg", "url": "https://x.com/sshlg93" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1c23f99..5cd66c9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -695,6 +695,27 @@ jobs: echo "OK: every temp tree goes through the residue ledger" fi + - name: Negative self-test (a shipped script's temp tree must not escape the check) + run: | + set -eu + rm -rf /tmp/residue-ship-copy && cp -R . /tmp/residue-ship-copy + python3 test/plant_guard.py snap /tmp/residue-ship-copy + # AG-06a and B-158 together: a maker outside `test/`, called through an alias. The + # check used to read `test/*.py` only and `tempfile.` as the only receiver, so this + # plant passed it twice over. + python3 - <<'PY' + import pathlib + p = pathlib.Path("/tmp/residue-ship-copy/plugins/agent-stack/skills/agent-harness/scripts/zz_plant.py") + p.write_text("import tempfile as tf\nd = tf." + "mkdtemp" + "()\n", encoding="utf-8") + PY + python3 test/plant_guard.py verify /tmp/residue-ship-copy "Negative self-test (a shipped script's temp tree must not escape the check)" + if out=$(python3 /tmp/residue-ship-copy/test/validate.py 2>&1); then + echo "ERROR: validator accepted an aliased temp-dir call in a shipped script" + exit 1 + fi + echo "$out" | grep -F "zz_plant.py: creates a temp tree outside the residue ledger — 'tf.mkdtemp(' at line 2" + echo "OK: a shipped script's temp tree is refused, by file and line" + - name: Negative self-test (a description must stay inside the working limit) run: | set -eu diff --git a/CHANGELOG.md b/CHANGELOG.md index a884c36..8764974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## 0.27.1 — the temp-tree check reads the whole repository, and the old leak can be reaped by content + +Closes board rows B-158, AG-06a and AG-06b (sshlg-skills plan R28). + +- **Any receiver, and a renamed from-import (B-158).** `check_temp_trees_go_through_the_residue_ledger` + matched `tempfile.` as the only receiver, so `import tempfile as tf; tf.mkdtemp()` and + `from tempfile import mkdtemp as m` passed. The pattern is now make-skill 0.31.0's: a direct + call through one receiver or none, and a one-line renaming from-import, are refused; two + negative controls (alias, renamed import) were watched failing on the old pattern. Only + whole-line `#` comments and `"""` blocks are skipped; chained receivers, function references, + `getattr` and parenthesised multi-line imports are not seen. Line numbers are now the file's + own, not the stripped source's. +- **Recursive, and beyond `test/` (AG-06a).** The check read `test/*.py`; it now reads every + `*.py` the repository ships or runs (`test/` recursively, `plugins/`, `bin/`), skipping only + `.git`, `node_modules`, `__pycache__` and `fixtures`. Its first run found a real bypass, + `test/audit_regressions/instruction_budget.py:21` (`tempfile.TemporaryDirectory(`), now a + `residue.workspace()` (the audit run reports `14 temp tree(s) created, 14 removed`). + Exemptions are exact paths with a reason and a pinned count of makers: `test/residue.py` + (1, the ledger itself) and `agent-harness/scripts/audit_agent.py` (2, a shipped tool's + context-managed self-test that cannot import the suite's ledger). A third maker in an + exempt file is refused, and an exemption for a file that no longer exists fails. A shipped + script is told its own remedy. New CI negative self-test: an aliased maker in a plugin + script, refused by file and line. +- **`python3 test/residue.py --reap-own` (AG-06b).** The trees the old `plant_guard_test.py` + leaked are identifiable by content, so they can be reaped without claiming anybody else's + directory: `test/reap_own.py` claims a direct child of the temp root only when it is named as + `mkdtemp()` names, is not a symlink, holds exactly `copy/` (one of the fixture's end states, + byte for byte, regular files only) and optionally the fixture's own manifest, and has not + changed for 24 hours (`--min-age-hours`). Dry run by default; `--apply` re-checks each tree + before removing it. `test/reap_own_test.py` (6 cases, in `npm test`) runs in a sandbox + temp root and plants near-misses by one byte, entry, name, symlink and manifest. Run + against this machine's real `$TMPDIR` on 2026-10-12, dry: `0 tree(s) match` — the 2592 + trees counted on 2026-08-20 are already gone (`find … -path '*/copy/sub/b.sh' | wc -l` → 0), + and both suites that leaked them now use `residue.workspace()`. + ## 0.27.0 — trust by machine, Fabric jobs, and room in every description Integration wave 4 of the family's waves run (sshlg-skills plan R23–R26). The first cut diff --git a/SKILL-CARD.md b/SKILL-CARD.md index 063962e..5d47ad5 100644 --- a/SKILL-CARD.md +++ b/SKILL-CARD.md @@ -5,7 +5,7 @@ | Field | Value | |---|---| | Pack | `agent-stack` | -| Version | `0.27.0` | +| Version | `0.27.1` | | Skills | `agent-orchestrator`, `agent-evals`, `agent-interop`, `agent-harness` | | License | MIT | | Source | https://github.com/ssheleg/agent-stack | diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md index 7e86516..b82d5e1 100644 --- a/docs/evidence/backlog.md +++ b/docs/evidence/backlog.md @@ -43,12 +43,12 @@ row per line, appended, so two runs closing two rows do not land on the same lin | B-155 | **`agent-interop/references/gateway.md` names per-caller authorisation and per-hop policy (`:49`, `:108`) but no on-behalf-of model.** Nothing says how authority travels down a chain of agents. Fabric's delegation model (ADR-0144, CO-258) asks the doctrine to describe it: one issuer mints a short delegation JWT carrying an RFC 8693 `act` chain, one audience, capabilities, an effect ceiling and a grant; the callee verifies it offline against published JWKS; calls go direct, with no proxy in the path; `approval/1` and the common refusal codes apply. Doctrine only, no product change. Write it once the plan lands on Fabric main, citing that commit instead of the branch. | peer request fabric-workspace-91, 2026-10-11; plan `passioncode-ai/fabric@32046d14:docs/evidence/plans/2026-10-11-delegation-model/README.md` (branch agent/release-040-wave1) | 3 | 0 | 2 | **1.5** | **closed 2026-10-11** — 0.26.0, `gateway.md` § Delegation; pinned to Fabric main `812876cf` and the contract's `8e49fbd` | | B-156 | **The per-reply `degraded` / `human_steps` envelope is taught nowhere, because it has no contract home.** Several of the estate's MCP tools (Copylot, Observatory, Research Agent) answer every call with `degraded` and `human_steps` beside the result. The contract defines `degraded` only on the service's well-known document (fabric-agent-contract `docs/specification/service.md` *Well-known document* at `bcada4a`), and `agent-interop/references/mcp.md` teaches that one. The per-reply form is an estate convention; teaching it as a contract would put a second, unsourced home beside the contract's. Teach it once the contract (or Fabric) gives it a rule to cite, and pin that commit. | sshlg-skills plan R24 (`docs/evidence/briefs/2026-10-11-integration-waves.md:117`, branch `waves/2026-10-11-integration`); wave 4 fix round, 2026-10-12 | 3 | 0 | 1 | **3** | **open** — deferred: waits on a contract rule for the per-reply envelope | | B-157 | **The back-links to the Fabric adapter run one way.** agent-interop now points to `fabric-agent-adapter`'s `creating-fabric-agents`, `adapting-projects-to-fabric` and `building-fabric-services`; of those, only `adapting-projects-to-fabric/SKILL.md:25-26` points back (wire questions). The adapter belongs to the Fabric owner: ask there for a pointer from the other two to agent-interop's tier doctrine. Nothing contradicts today — the adapter carries its own copy of the tier rule (`delegation.md:47-55`, 0.20.0). | wave 4 seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — an ask to the Fabric adapter's owner | -| B-158 | **The residue-ledger check misses an aliased temp-dir call.** `TEMP_MAKERS` (`test/validate.py`) excludes every dotted receiver but `tempfile.`, so `import tempfile as tf; tf.mkdtemp()` and `from tempfile import mkdtemp as m` pass. make-skill 0.31.0 ported this check and closed the gap there (any receiver, renamed from-imports refused, two negative controls); port that back. No current file here uses an alias. | make-skill W4-B seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** | +| B-158 | **The residue-ledger check misses an aliased temp-dir call.** `TEMP_MAKERS` (`test/validate.py`) excludes every dotted receiver but `tempfile.`, so `import tempfile as tf; tf.mkdtemp()` and `from tempfile import mkdtemp as m` pass. make-skill 0.31.0 ported this check and closed the gap there (any receiver, renamed from-imports refused, two negative controls); port that back. No current file here uses an alias. | make-skill W4-B seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **closed in v0.27.1 (2026-10-12)** — `TEMP_MAKERS` is make-skill 0.31.0's pattern (any single receiver, one-line renaming from-import refused); the alias and renamed-import negative controls in `test/validate.py` were watched failing on the old pattern (`B-158 self-test: a temp-dir call through a module alias (\`tf.\`) was not found`, `… a renamed from-import of a temp-dir maker was not found`), then passed. Evidence: verification.md, v0.27.1 | +| AG-06a | **A temp tree created outside `test/` is still invisible.** `check_temp_trees_go_through_the_residue_ledger` reads this repository's own suite. A tree made by anything else — a script under `plugins/`, a future helper outside `test/` — is not accounted for, and `residue.report()` counts only what `workspace()` handed out. The mechanism catches a *reviewed* bypass; it does not observe `$TMPDIR`. Observing it would mean a run claiming ownership of directories it cannot prove it made, which is the reaping mistake `agent-sync` refuses by design. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 1 | 0 | 1 | **1** | **closed in v0.27.1 (2026-10-12)** for the static half: the check now reads every `*.py` the repository ships or runs (`test/` recursively, `plugins/`, `bin/`) with exact-path, exact-count, reasoned exemptions (`test/residue.py` 1, `agent-harness/scripts/audit_agent.py` 2). Its first run refused a real bypass, `test/audit_regressions/instruction_budget.py` line 21, now `residue.workspace()`. Plants: an aliased maker in a plugin script, a renamed from-import under `bin/`, a third maker in `audit_agent.py` — each refused by file and line. What stays true: the check reads source, it does not observe `$TMPDIR`; that half stays with AG-06b's content fingerprint. Evidence: verification.md, v0.27.1 | +| AG-06b | **The 2592 existing trees are reported and left alone.** `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \\| wc -l` → **2592** on 2026-08-20, of which **16 were added by this run's own verification** — one run of the leaking fixture before the port, and one of the v0.12.0 archive's copy of it. They are plain `tmpXXXXXXXX` and indistinguishable from every other program's, so they are named rather than swept. The identical fixture still leaks in **`seo-aeo-audit`**, which another agent holds; referred, not edited. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 2 | 0 | 1 | **2** | **closed in v0.27.1 (2026-10-12)** — the trees ARE identifiable by content, so they are reapable without claiming another program's directory: `python3 test/residue.py --reap-own` (dry run by default, `--apply` to remove) claims only an `mkdtemp()`-named, non-symlink child of the temp root holding exactly one of the fixture's end states byte for byte, plus optionally its own manifest, unchanged for 24 h. `test/reap_own_test.py` 6/6 in a sandbox temp root; a planted permissive reaper (any tree holding `sub/b.sh`) was refused: `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']`. Real `$TMPDIR`, dry, 2026-10-12: `0 tree(s) match`; the `find` count is 0 — the 2592 trees are already gone, and both leaking suites (`agent-stack`, `seo-aeo-audit`) now use `residue.workspace()`. Nothing in the real `$TMPDIR` was deleted | ## Open, and why | id | What | Why it is still open | |---|---|---| -| AG-06a | **A temp tree created outside `test/` is still invisible.** `check_temp_trees_go_through_the_residue_ledger` reads this repository's own suite. A tree made by anything else — a script under `plugins/`, a future helper outside `test/` — is not accounted for, and `residue.report()` counts only what `workspace()` handed out. The mechanism catches a *reviewed* bypass; it does not observe `$TMPDIR`. Observing it would mean a run claiming ownership of directories it cannot prove it made, which is the reaping mistake `agent-sync` refuses by design. | Open: the ledger reads this repository's own suite, so a tree made anywhere else is still unattributable. Plan R28 of the hub's 2026-10-11 integration waves; deferred from wave 4 W4-A, 2026-10-12. | -| AG-06b | **The 2592 existing trees are reported and left alone.** `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \\| wc -l` → **2592** on 2026-08-20, of which **16 were added by this run's own verification** — one run of the leaking fixture before the port, and one of the v0.12.0 archive's copy of it. They are plain `tmpXXXXXXXX` and indistinguishable from every other program's, so they are named rather than swept. The identical fixture still leaks in **`seo-aeo-audit`**, which another agent holds; referred, not edited. | Open by decision: state this run cannot prove is its own is reported, never deleted. Plan R28 of the hub's 2026-10-11 integration waves; deferred from wave 4 W4-A, 2026-10-12. | | AG-04a | **This board's own priority is a scalar.** The header computes `P = blast × (1 + age_runs) / effort` while `audit.md` now argues that publishing axes beats multiplying them. The two are not the same claim — the audit is doctrine this pack ships to users about somebody else's system, the board is one repository's own triage — but the tension is real and it is not resolved by saying so. The method's home is the umbrella board (`sshlg-skills/docs/evidence/backlog.md`), which another agent holds; the check added here deliberately scopes to documents under `plugins/`, so it neither polices nor blesses this file. Referred to the umbrella. | Open: the board's formula and `audit.md` disagree, and neither has been chosen. | diff --git a/docs/evidence/verification.md b/docs/evidence/verification.md index 0d81153..2d21016 100644 --- a/docs/evidence/verification.md +++ b/docs/evidence/verification.md @@ -10,6 +10,25 @@ This file exists because its absence read as zero exposure. `sshlg-skills` board --- +## Release candidate — v0.27.1 (2026-10-12) + +Board rows B-158, AG-06a, AG-06b (sshlg-skills plan R28). Measured on the branch `fix/ag06` +before any tag; nothing below is a published-artifact claim. + +**Handoff.** Done: the three rows below, closed on the board. Open: GitHub has not run the new +negative self-test step; the merge, the `v0.27.1` tag, the npm publish and the hub re-pin belong +to the orchestrator. Next task, exactly: after the merge, tag `v0.27.1`, read the release run and +`npm view @ssheleg/agent-stack version`, and re-head this section `Released`. + +| REQ | What ships | How it was confirmed | Confirmed | +|---|---|---|---| +| B-158 | `TEMP_MAKERS` refuses a direct temp-dir call through one receiver or none and a one-line renaming from-import; `temp_maker_hits()` reports the file's own line numbers | Two negative controls written first and run against the old pattern: `python3 test/validate.py` → `B-158 self-test: a temp-dir call through a module alias (`tf.`) was not found` and `B-158 self-test: a renamed from-import of a temp-dir maker was not found`; each control asserts on the parsed `(match, line)`. After the port → `OK` | **watched failing**, then passed | +| AG-06a scan | the check reads every `*.py` under the repository (skipping `.git`, `node_modules`, `__pycache__`, `fixtures`) with exact-path, exact-count exemptions | First run of the recursive scan on the unmodified suite → `test/audit_regressions/instruction_budget.py: creates a temp tree outside the residue ledger — 'tempfile.TemporaryDirectory(' at line 21`; migrated to `residue.workspace()`, its run prints `residue: this run left nothing — 14 temp tree(s) created, 14 removed` | **watched failing** on a real bypass, then passed | +| AG-06a plants | shipped scripts are read | `plugins/…/scripts/zz_plant.py` with `import tempfile as tf` / `tf.mkdtemp()` → `zz_plant.py: creates a temp tree outside the residue ledger — 'tf.mkdtemp(' at line 2. A shipped script cannot reach the suite's ledger: …`; `bin/zz_plant.py` with a renamed `TemporaryDirectory` import → refused at line 1; a third maker appended to `audit_agent.py` → `'tempfile.mkdtemp(' at line 408 (3 makers, 2 allowed: …)`; `plugins` added to the skip set → `AG-06a self-test: …audit_agent.py is not in the temp-tree scan`. Each plant removed, gate back to `OK`. The new CI step's logic run locally on a copy: refused with the exact file-and-line string it greps for | **planted**, then passed; CI step not yet run by GitHub | +| AG-06b | `python3 test/residue.py --reap-own [--apply] [--tmpdir DIR] [--min-age-hours N]` via `test/reap_own.py` | `test/reap_own_test.py` first run → `ModuleNotFoundError: No module named 'reap_own'`; after the module, 6/6 in a sandbox temp root made by `residue.workspace()`. Its near-miss case asserts each plant carries an `mkdtemp()`-shaped name, so a plant excluded by name rather than content fails as `PLANT DID NOT LAND` (it did, on the first draft's 7-character names). Planted a permissive reaper (accept any tree holding `sub/b.sh`) → `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']` and `--apply removed a tree that is not the fixture's`; restored → `PASS: reap-own — 6 cases` | **planted**, then passed | +| AG-06b, this machine | the 2592 trees | `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \| wc -l` → `0` on 2026-10-12 (also 0 under `/tmp`); `python3 test/residue.py --reap-own` (dry) → `reap-own: 0 tree(s) match the leaking fixture's fingerprint under /var/folders/…/T — DRY RUN, nothing removed.` Nothing in the real `$TMPDIR` was removed; `seo-aeo-audit`'s fixture now uses `residue.workspace("plant-guard")` (its `origin/main`, line 52) | **observed**, local | +| Gate | `npm test` (now also runs `test/reap_own_test.py`) | EXIT=0 on 2026-10-12: `OK: agent-stack structurally valid (16 checks = 12 named + 4 per-skill, 4 skill(s), v0.27.1)`, `PASS: plant_guard — 9 cases`, `PASS: reap-own — 6 cases`, `PASS: installer — 11 case(s)`, all 25 `test/audit_regressions/*.py` exit 0; every residue line `left nothing` | **observed**, local | + ## Release candidate — v0.27.0 (2026-10-12) The first cut (`4f88a0e`) failed three blind readings (unit, seam, product). These rows diff --git a/package.json b/package.json index bb3417d..da91e7f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@ssheleg/agent-stack", - "version": "0.27.0", + "version": "0.27.1", "scripts": { - "test": "python3 test/validate.py && python3 test/plant_guard_test.py && node test/installer_test.js && npm run test:audit", + "test": "python3 test/validate.py && python3 test/plant_guard_test.py && python3 test/reap_own_test.py && node test/installer_test.js && npm run test:audit", "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done" }, "description": "Production patterns for AI agent orchestrators \u2014 tool-calling loops, multi-stage pipelines with checkpoints, LLM provider routing with fallback, four-layer memory with confidence decay \u2014 plus the wallet side of reselling LLM access. This package is the installer CLI.", diff --git a/plugins/agent-stack/.claude-plugin/plugin.json b/plugins/agent-stack/.claude-plugin/plugin.json index c098862..dfb1c39 100644 --- a/plugins/agent-stack/.claude-plugin/plugin.json +++ b/plugins/agent-stack/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "agent-stack", "displayName": "Agent Stack", "description": "Four skills: agent-orchestrator — tool-calling loops, pipelines with checkpoints, provider routing with fallback, memory architecture, plus the wallet side of reselling LLM access; agent-evals — run/trace/thread evals, LLM judges, and fixtures grown from production; agent-interop — MCP servers and clients, A2A agent cards, the MCP Registry, and gateways; agent-harness — system prompts, tool shaping, workflow-vs-agent, and auditing an agent system.", - "version": "0.27.0", + "version": "0.27.1", "author": { "name": "ssheleg", "url": "https://x.com/sshlg93" diff --git a/test/audit_regressions/instruction_budget.py b/test/audit_regressions/instruction_budget.py index f34f525..06c896a 100644 --- a/test/audit_regressions/instruction_budget.py +++ b/test/audit_regressions/instruction_budget.py @@ -5,10 +5,11 @@ from pathlib import Path import subprocess import sys -import tempfile import unittest sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import residue # noqa: E402 every temp tree goes through the ledger (B-158) SCRIPT = Path(__file__).resolve().parents[2] / "plugins/agent-stack/skills/agent-harness/scripts/instruction_budget.py" spec = importlib.util.spec_from_file_location("instruction_budget", SCRIPT) @@ -18,9 +19,7 @@ class InstructionBudget(unittest.TestCase): def setUp(self): - self.temp = tempfile.TemporaryDirectory(prefix="instruction-budget-") - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name).resolve() + self.root = Path(residue.workspace("instruction-budget")).resolve() def put(self, name, text): path = self.root / name diff --git a/test/reap_own.py b/test/reap_own.py new file mode 100644 index 0000000..2fb528e --- /dev/null +++ b/test/reap_own.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""`residue.py --reap-own`: remove only the trees the old leaking fixture left (AG-06b). + +Before the port to `residue.workspace()`, `test/plant_guard_test.py` made eight nameless +`tempfile.mkdtemp()` trees per run and removed none; the identical fixture (md5 +`623a086d10a04940573c31cbebb93e31`) did the same in `seo-aeo-audit`. Those trees are named +`tmpXXXXXXXX` like every other program's, so a prefix cannot attribute them, and the operator's +rule is that a run frees disk only through residue it can prove is its own. + +Content can prove it. The fixture wrote fixed bytes into a fixed shape, and each of its cases +left one of seven end states. A directory is claimed only when ALL of these hold: + +- a direct child of the temp root, named as `mkdtemp()` names (`tmp` + 8 of `[a-z0-9_]`), + and not a symlink; +- its entries are exactly `copy/` and, optionally, `copy.plant-manifest.json`; +- `copy/` holds, as regular files with no symlinks, exactly one of the seven file sets the + fixture's cases end in, byte for byte (`sub/b.sh` is in every one); +- the manifest, when present, is the fixture's own snapshot: `a.txt` and `sub/b.sh` with the + fixture's two SHA-256 digests and nothing else; +- nothing in it changed in the last `--min-age-hours` (default 24), so a run still in + progress is never reaped from under itself. + +Anything that differs by one byte, one entry, one name or one link is somebody else's and is +left alone. A dry run is the default; `--apply` removes, re-checking each tree immediately +before it goes. The fingerprint identifies the FIXTURE, not the repository: a tree from +`seo-aeo-audit`'s byte-identical copy matches too, and both are this family's own suites. +""" +import hashlib +import json +import os +import re +import shutil +import stat +import sys +import tempfile +import time + +A = b"hello\n" +A_PLANTED = b"hello\nplanted\n" +B = b"#!/bin/sh\necho hi\n" +ORPHAN = b"# orphan\n" +MANIFEST = "copy.plant-manifest.json" +NAME = re.compile(r"^tmp[a-z0-9_]{8}$") +MAX_BYTES = 4096 + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +# The seven end states, as {relative path: bytes}. `nochange` is also where the +# manifest-outside, missing-snapshot and churn-before-verify cases stop. +STATES = ( + {"a.txt": A, "sub/b.sh": B}, # no change, mode, manifest + {"a.txt": A_PLANTED, "sub/b.sh": B}, # content edit + {"a.txt": A, "sub/b.sh": B, "orphan.md": ORPHAN}, # new file + {"sub/b.sh": B}, # deletion + {"a.txt": A, "sub/b.sh": B, ".git/index": b"before\n"}, # git churn, before + {"a.txt": A, "sub/b.sh": B, ".git/index": b"after\n"}, # git churn, after +) +FIXTURE_SNAPSHOT = sorted([("a.txt", sha(A)), ("sub/b.sh", sha(B))]) + + +def _read_tree(copy): + """{rel: bytes} for every entry under `copy`, or None if anything is not plain.""" + files, total = {}, 0 + for dirpath, dirnames, filenames in os.walk(copy, followlinks=False): + for d in dirnames: + if os.path.islink(os.path.join(dirpath, d)): + return None + for f in filenames: + full = os.path.join(dirpath, f) + st = os.lstat(full) + if not stat.S_ISREG(st.st_mode): + return None + total += st.st_size + if total > MAX_BYTES: + return None + with open(full, "rb") as fh: + files[os.path.relpath(full, copy).replace(os.sep, "/")] = fh.read() + return files + + +def _newest_mtime(top): + newest = os.lstat(top).st_mtime + for dirpath, dirnames, filenames in os.walk(top, followlinks=False): + for n in dirnames + filenames: + newest = max(newest, os.lstat(os.path.join(dirpath, n)).st_mtime) + return newest + + +def is_fixture_tree(top, min_age_s=24 * 3600, now=None): + """True only when `top` is, byte for byte, a tree the leaking fixture left.""" + try: + if not NAME.match(os.path.basename(top)): + return False + st = os.lstat(top) + if not stat.S_ISDIR(st.st_mode): + return False # a symlink or a file is never ours + entries = set(os.listdir(top)) + if entries not in ({"copy"}, {"copy", MANIFEST}): + return False + copy = os.path.join(top, "copy") + if not stat.S_ISDIR(os.lstat(copy).st_mode): + return False + files = _read_tree(copy) + if files is None or files not in STATES: + return False + if MANIFEST in entries: + mpath = os.path.join(top, MANIFEST) + if not stat.S_ISREG(os.lstat(mpath).st_mode) or os.lstat(mpath).st_size > MAX_BYTES: + return False + with open(mpath, encoding="utf-8") as fh: + snap = json.load(fh) + if not isinstance(snap, list) or not all( + isinstance(e, list) and len(e) == 3 and all(isinstance(x, str) for x in e) + for e in snap): + return False + if sorted((e[0], e[2]) for e in snap) != FIXTURE_SNAPSHOT: + return False + now = time.time() if now is None else now + return now - _newest_mtime(top) >= min_age_s + except (OSError, ValueError, UnicodeDecodeError): + return False # unreadable is not provably ours + + +def find_own(tmpdir, min_age_s=24 * 3600): + out = [] + for name in sorted(os.listdir(tmpdir)): + if NAME.match(name): + path = os.path.join(tmpdir, name) + if is_fixture_tree(path, min_age_s): + out.append(path) + return out + + +def main(argv): + apply = "--apply" in argv + tmpdir = tempfile.gettempdir() + min_age_h = 24.0 + it = iter(argv) + for a in it: + if a == "--tmpdir": + tmpdir = next(it, "") + elif a == "--min-age-hours": + min_age_h = float(next(it, "24")) + elif a not in ("--reap-own", "--apply"): + print(f"reap-own CANNOT RUN: unknown argument {a!r}", file=sys.stderr) + return 2 + if not os.path.isdir(tmpdir): + print(f"reap-own CANNOT RUN: {tmpdir!r} is not a directory", file=sys.stderr) + return 2 + min_age_s = min_age_h * 3600 + own = find_own(tmpdir, min_age_s) + if not apply: + print(f"reap-own: {len(own)} tree(s) match the leaking fixture's fingerprint under " + f"{tmpdir} — DRY RUN, nothing removed. Re-run with --apply to remove them.") + for p in own[:20]: + print(f" {p}") + if len(own) > 20: + print(f" … and {len(own) - 20} more") + return 0 + removed, skipped = 0, 0 + for p in own: + if not is_fixture_tree(p, min_age_s): # re-checked right before it goes + skipped += 1 + continue + shutil.rmtree(p, ignore_errors=True) + if os.path.exists(p): + skipped += 1 + else: + removed += 1 + print(f"reap-own: {len(own)} tree(s) matched under {tmpdir}; removed {removed}, " + f"left {skipped} (changed since the scan or would not go)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/test/reap_own_test.py b/test/reap_own_test.py new file mode 100644 index 0000000..7d5f4d5 --- /dev/null +++ b/test/reap_own_test.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Fixtures for `residue.py --reap-own` (AG-06b), run in a sandbox, never the real $TMPDIR. + +Every case builds its own fake temp root inside a `residue.workspace()` and passes it with +`--tmpdir`, so nothing here can read or remove a directory this run did not make. The trees +are the eight terminal states of the leaking fixture (`plant_guard_test.py` before the port) +plus near-misses that differ by one byte, one entry, one name or one symlink: a reaper that +removes a near-miss is deleting somebody else's directory. +""" +import json +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import residue # noqa: E402 +import reap_own # noqa: E402 + +failures = [] +OLD = time.time() - 3 * 86400 + + +def case(name, fn): + residue.open_case(name) + try: + fn() + except AssertionError as e: + failures.append(f"{name}: {e}") + print(f"FAIL {name}: {e}") + else: + print(f" ok {name}") + residue.close_case(name) + + +def put(path, data, mode=0o644): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(data) + os.chmod(path, mode) + + +def age(top, when=OLD): + for dirpath, dirnames, filenames in os.walk(top): + for n in dirnames + filenames: + os.utime(os.path.join(dirpath, n), (when, when), follow_symlinks=False) + os.utime(top, (when, when)) + + +def fixture_tree(tmp, name, state="nochange", manifest=True): + """One leaked tree exactly as the old fixture left it, in one of its terminal states.""" + top = os.path.join(tmp, name) + copy = os.path.join(top, "copy") + if state != "deleted": + put(os.path.join(copy, "a.txt"), + reap_own.A_PLANTED if state == "content" else reap_own.A) + put(os.path.join(copy, "sub", "b.sh"), reap_own.B, 0o644 if state == "mode" else 0o755) + if state == "newfile": + put(os.path.join(copy, "orphan.md"), reap_own.ORPHAN) + if state == "gitchurn": + put(os.path.join(copy, ".git", "index"), b"after\n") + if manifest: + snap = [["a.txt", "0o644", reap_own.sha(reap_own.A)], + ["sub/b.sh", "0o755", reap_own.sha(reap_own.B)]] + with open(os.path.join(top, "copy.plant-manifest.json"), "w") as fh: + json.dump(snap, fh) + age(top) + return top + + +def sandbox(): + tmp = residue.workspace("reap-own") + return tmp + + +def cli(tmp, *extra): + r = subprocess.run([sys.executable, os.path.join(HERE, "residue.py"), "--reap-own", + "--tmpdir", tmp, *extra], capture_output=True, text=True) + return r + + +STATES = ["content", "mode", "nochange", "newfile", "deleted", "gitchurn"] + + +def every_terminal_state_is_recognised(): + tmp = sandbox() + names = [] + for i, st in enumerate(STATES): + names.append(os.path.basename(fixture_tree(tmp, "tmp%08d" % i, st))) + names.append(os.path.basename(fixture_tree(tmp, "tmpnosnap01", "nochange", manifest=False))) + found = sorted(os.path.basename(p) for p in reap_own.find_own(tmp)) + assert found == sorted(names), f"expected {sorted(names)}, found {found}" + + +def near_misses_are_left_alone(): + tmp = sandbox() + # one extra byte + t = fixture_tree(tmp, "tmpextrab01") + put(os.path.join(t, "copy", "a.txt"), reap_own.A + b"x") + age(t) + # one extra entry + t = fixture_tree(tmp, "tmpextraf01") + put(os.path.join(t, "copy", "notes.txt"), b"mine\n") + age(t) + # an extra entry at the top + t = fixture_tree(tmp, "tmpextrat01") + put(os.path.join(t, "keep.db"), b"\0") + age(t) + # a name that mkdtemp() would not produce + fixture_tree(tmp, "agent-stack-test-1-x") + fixture_tree(tmp, "tmp_short") + # a symlink where a file was + t = fixture_tree(tmp, "tmpsymlnk01") + os.remove(os.path.join(t, "copy", "a.txt")) + os.symlink("/etc/hosts", os.path.join(t, "copy", "a.txt")) + age(t) + # the top itself a symlink to a fixture-shaped tree elsewhere + real = fixture_tree(tmp, "realtree-not-tmp") + os.symlink(real, os.path.join(tmp, "tmplinked01")) + # a manifest that is not the fixture's + t = fixture_tree(tmp, "tmpbadman01") + with open(os.path.join(t, "copy.plant-manifest.json"), "w") as fh: + json.dump([["a.txt", "0o644", "0" * 64]], fh) + age(t) + # b.sh missing: the fixture never deletes it + t = fixture_tree(tmp, "tmpnobsh001") + os.remove(os.path.join(t, "copy", "sub", "b.sh")) + age(t) + # The plants must differ by the property they name, not by an accident of naming: every + # near-miss but the two deliberate name-misses carries a name mkdtemp() could produce. + named = [n for n in os.listdir(tmp) if n not in ("agent-stack-test-1-x", "tmp_short", + "realtree-not-tmp")] + bad = [n for n in named if not reap_own.NAME.match(n)] + assert not bad, f"PLANT DID NOT LAND: {bad} are excluded by name, not by content" + found = sorted(os.path.basename(p) for p in reap_own.find_own(tmp)) + assert found == [], f"near-misses were claimed as own: {found}" + + +def a_young_tree_is_left_alone(): + tmp = sandbox() + t = fixture_tree(tmp, "tmpyoung001") + age(t, time.time()) + assert reap_own.find_own(tmp) == [], "a tree younger than the age floor was claimed" + assert reap_own.find_own(tmp, min_age_s=0) == [t], "the age floor is not the only gate" + + +def dry_run_is_the_default_and_removes_nothing(): + tmp = sandbox() + t = fixture_tree(tmp, "tmpdryrun01") + r = cli(tmp) + assert r.returncode == 0, f"exit {r.returncode}: {r.stderr}" + assert os.path.isdir(t), "the dry run removed a tree" + line = [ln for ln in r.stdout.splitlines() if ln.startswith("reap-own:")] + assert line and "1 tree(s) match" in line[0] and "DRY RUN" in line[0], r.stdout + + +def apply_removes_exactly_the_matches(): + tmp = sandbox() + own = [fixture_tree(tmp, "tmpapply0%02d" % i, st) for i, st in enumerate(STATES)] + other = fixture_tree(tmp, "tmpother001") + put(os.path.join(other, "copy", "notes.txt"), b"mine\n") + age(other) + r = cli(tmp, "--apply") + assert r.returncode == 0, f"exit {r.returncode}: {r.stderr}" + gone = [p for p in own if not os.path.exists(p)] + assert len(gone) == len(own), f"{len(own) - len(gone)} matching tree(s) survived --apply" + assert os.path.isdir(other), "--apply removed a tree that is not the fixture's" + assert f"removed {len(own)}" in r.stdout, r.stdout + + +def a_missing_tmpdir_refuses(): + r = cli("/nonexistent/reap-own-root") + assert r.returncode == 2, f"expected 2, got {r.returncode}" + assert "CANNOT RUN" in r.stderr, r.stderr + + +for n, f in [ + ("the seven distinct end states of the leaking fixture (eight cases) are recognised", every_terminal_state_is_recognised), + ("near-misses by one byte, entry, name, symlink or manifest are left alone", near_misses_are_left_alone), + ("a tree younger than the age floor is left alone", a_young_tree_is_left_alone), + ("dry run is the default and removes nothing", dry_run_is_the_default_and_removes_nothing), + ("--apply removes exactly the matches", apply_removes_exactly_the_matches), + ("a missing temp root refuses rather than reports clean", a_missing_tmpdir_refuses), +]: + case(n, f) + +if failures: + print(f"\nFAIL: {len(failures)} of 6") + sys.exit(1) +print("\nPASS: reap-own — 6 cases") diff --git a/test/residue.py b/test/residue.py index d077801..fe864df 100755 --- a/test/residue.py +++ b/test/residue.py @@ -159,3 +159,16 @@ def report(stream=None): atexit.register(report) + + +if __name__ == "__main__": + # `python3 test/residue.py --reap-own [--apply]` — remove only what the old leaking + # fixture provably left (AG-06b). The logic lives in `reap_own.py` so this shared + # mechanism stays one file across its copies. + if "--reap-own" in sys.argv[1:]: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import reap_own + sys.exit(reap_own.main(sys.argv[1:])) + print("usage: residue.py --reap-own [--apply] [--tmpdir DIR] [--min-age-hours N]", + file=sys.stderr) + sys.exit(2) diff --git a/test/validate.py b/test/validate.py index f1d48d9..ede39ee 100644 --- a/test/validate.py +++ b/test/validate.py @@ -907,38 +907,106 @@ def check_priority_axes_are_the_manifesto_s_and_carry_no_scalar(): # looks for, and this file is inside the set it scans — it passed only because a `)` # happened to follow each name. A guard that is green by luck is a guard nobody has # watched, so the names never appear here as callable text. -TEMP_MAKERS = re.compile(r"(? allowed: + first = hits[allowed] + fail(f"{rel}: creates a temp tree outside the residue ledger — {first[0]!r} at " + f"line {first[1]}" + (f" ({len(hits)} makers, {allowed} allowed: {why})" + if allowed else "") + + (". Use `residue.workspace(tag)`: a tree the ledger never saw is a tree the " + "gate's residue line reports as nothing, which is how 2576 nameless " + "`tmpXXXXXXXX` directories accumulated with every run printing clean" + if rel.startswith("test" + os.sep) else + ". A shipped script cannot reach the suite's ledger: make the tree with a " + "context manager that removes it on every path and give the file an " + "exact-count entry, with its reason, in `TEMP_TREE_ALLOWED` — or move the " + "work into the suite")) + for rel in sorted(TEMP_TREE_ALLOWED): + if rel not in rels: + fail(f"{rel}: exempt from the temp-tree check but no longer exists — an exemption " + "for a file that is gone is a hole waiting for the next file of that name") + + +# Negative controls, computed on every run: a guard nobody has watched refuse is not one. +# Each asserts on the parsed (match, line), not on a pass/fail bit. +_M = "mkdtemp" +if (temp_maker_hit("import tempfile\nd = tempfile." + _M + "()\n") or (None, 0))[1] != 2: + fail("B-158 self-test: a planted tempfile." + _M + "() was not found at line 2") +if (temp_maker_hit("import tempfile as tf\nd = tf." + _M + "()\n") or ("",))[0] != "tf." + _M + "(": + fail("B-158 self-test: a temp-dir call through a module alias (`tf.`) was not found") +if not (temp_maker_hit("from tempfile import " + _M + " as m\nd = m()\n") or ("",))[0] \ + .startswith("from tempfile import " + _M + " as"): + fail("B-158 self-test: a renamed from-import of a temp-dir maker was not found") +if temp_maker_hit('"""why ' + _M + '() is banned"""\n# or ' + _M + "()\n"): + fail("B-158 self-test: an explanation in a docstring/comment was read as a violation") +# AG-06a, asserted as a capability: the scan reads the shipped scripts, not only the suite. +_scanned = {os.path.relpath(p, ROOT) for p in temp_scan_files(ROOT)} +for _rel in TEMP_TREE_ALLOWED: + if _rel not in _scanned: + fail(f"AG-06a self-test: {_rel} is not in the temp-tree scan — the check no longer " + "reads the directory that file lives in") check_temp_trees_go_through_the_residue_ledger() From 30f87d995873d2728dbce0823a78d308d66bb7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=B1=D0=BE=D1=82?= Date: Mon, 12 Oct 2026 19:55:19 +0200 Subject: [PATCH 2/2] fix(0.27.1): reaper refuses /, $HOME and young trees, quarantines before delete, sees empty dirs; exempt files pinned by shape Blind reading of 53b5e38: the temp root was never checked against / or $HOME; the age floor accepted 0 and negatives and crashed on non-numbers; a write between the final check and rmtree was deleted; empty directories were invisible; the state count was misstated; the audit_agent.py exemption pinned a count, not the with-form. Each is a case now (13 reap-own cases). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 21 ++++- docs/evidence/backlog.md | 4 +- docs/evidence/verification.md | 7 ++ test/reap_own.py | 148 +++++++++++++++++++++++++--------- test/reap_own_test.py | 103 +++++++++++++++++++++-- test/validate.py | 36 +++++++-- 6 files changed, 265 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8764974..77cb651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ Closes board rows B-158, AG-06a and AG-06b (sshlg-skills plan R28). - **`python3 test/residue.py --reap-own` (AG-06b).** The trees the old `plant_guard_test.py` leaked are identifiable by content, so they can be reaped without claiming anybody else's directory: `test/reap_own.py` claims a direct child of the temp root only when it is named as - `mkdtemp()` names, is not a symlink, holds exactly `copy/` (one of the fixture's end states, + `mkdtemp()` names, is not a symlink, holds exactly `copy/` (one of the fixture's five end-state file sets, byte for byte, regular files only) and optionally the fixture's own manifest, and has not changed for 24 hours (`--min-age-hours`). Dry run by default; `--apply` re-checks each tree before removing it. `test/reap_own_test.py` (6 cases, in `npm test`) runs in a sandbox @@ -32,6 +32,25 @@ Closes board rows B-158, AG-06a and AG-06b (sshlg-skills plan R28). against this machine's real `$TMPDIR` on 2026-10-12, dry: `0 tree(s) match` — the 2592 trees counted on 2026-08-20 are already gone (`find … -path '*/copy/sub/b.sh' | wc -l` → 0), and both suites that leaked them now use `residue.workspace()`. +- **Review round on the first cut (blind reading of PR #45).** Seven fixes, each case written + first and watched failing: + - the reaper refuses a temp root that is empty, `/`, `$HOME` or an ancestor of `$HOME`, + compared after `realpath` (exit 2, `CANNOT RUN`); `--tmpdir /` and `--tmpdir $HOME` were + accepted; + - `--min-age-hours` refuses non-finite values and anything under 1 h, and a non-numeric + value is `CANNOT RUN` instead of a traceback; + - `--apply` renames each tree atomically to a quarantine name in the same root, re-verifies + the renamed tree, and only then removes it; a tree changed in that window goes back to + its own name; + - the fingerprint includes the directory set (`copy/sub`, plus `copy/.git` for the churn + state), so an extra empty directory makes a tree somebody else's; + - the counts are the fixture's: eight cases end in five distinct file sets. The first cut + said seven and carried a sixth set (`.git/index` = `before`) the fixture never ends in; + - an exempt shipped file is held to the `with … TemporaryDirectory(` form line by line: a + bare `mkdtemp()` swapped in for one of `audit_agent.py`'s two context managers kept the + count at 2 and passed; + - stated as a limit, not fixed: a string literal containing `"""` pairs with the next one in + the file and hides every maker between them from the check. ## 0.27.0 — trust by machine, Fabric jobs, and room in every description diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md index b82d5e1..b003bfa 100644 --- a/docs/evidence/backlog.md +++ b/docs/evidence/backlog.md @@ -45,7 +45,9 @@ row per line, appended, so two runs closing two rows do not land on the same lin | B-157 | **The back-links to the Fabric adapter run one way.** agent-interop now points to `fabric-agent-adapter`'s `creating-fabric-agents`, `adapting-projects-to-fabric` and `building-fabric-services`; of those, only `adapting-projects-to-fabric/SKILL.md:25-26` points back (wire questions). The adapter belongs to the Fabric owner: ask there for a pointer from the other two to agent-interop's tier doctrine. Nothing contradicts today — the adapter carries its own copy of the tier rule (`delegation.md:47-55`, 0.20.0). | wave 4 seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — an ask to the Fabric adapter's owner | | B-158 | **The residue-ledger check misses an aliased temp-dir call.** `TEMP_MAKERS` (`test/validate.py`) excludes every dotted receiver but `tempfile.`, so `import tempfile as tf; tf.mkdtemp()` and `from tempfile import mkdtemp as m` pass. make-skill 0.31.0 ported this check and closed the gap there (any receiver, renamed from-imports refused, two negative controls); port that back. No current file here uses an alias. | make-skill W4-B seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **closed in v0.27.1 (2026-10-12)** — `TEMP_MAKERS` is make-skill 0.31.0's pattern (any single receiver, one-line renaming from-import refused); the alias and renamed-import negative controls in `test/validate.py` were watched failing on the old pattern (`B-158 self-test: a temp-dir call through a module alias (\`tf.\`) was not found`, `… a renamed from-import of a temp-dir maker was not found`), then passed. Evidence: verification.md, v0.27.1 | | AG-06a | **A temp tree created outside `test/` is still invisible.** `check_temp_trees_go_through_the_residue_ledger` reads this repository's own suite. A tree made by anything else — a script under `plugins/`, a future helper outside `test/` — is not accounted for, and `residue.report()` counts only what `workspace()` handed out. The mechanism catches a *reviewed* bypass; it does not observe `$TMPDIR`. Observing it would mean a run claiming ownership of directories it cannot prove it made, which is the reaping mistake `agent-sync` refuses by design. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 1 | 0 | 1 | **1** | **closed in v0.27.1 (2026-10-12)** for the static half: the check now reads every `*.py` the repository ships or runs (`test/` recursively, `plugins/`, `bin/`) with exact-path, exact-count, reasoned exemptions (`test/residue.py` 1, `agent-harness/scripts/audit_agent.py` 2). Its first run refused a real bypass, `test/audit_regressions/instruction_budget.py` line 21, now `residue.workspace()`. Plants: an aliased maker in a plugin script, a renamed from-import under `bin/`, a third maker in `audit_agent.py` — each refused by file and line. What stays true: the check reads source, it does not observe `$TMPDIR`; that half stays with AG-06b's content fingerprint. Evidence: verification.md, v0.27.1 | -| AG-06b | **The 2592 existing trees are reported and left alone.** `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \\| wc -l` → **2592** on 2026-08-20, of which **16 were added by this run's own verification** — one run of the leaking fixture before the port, and one of the v0.12.0 archive's copy of it. They are plain `tmpXXXXXXXX` and indistinguishable from every other program's, so they are named rather than swept. The identical fixture still leaks in **`seo-aeo-audit`**, which another agent holds; referred, not edited. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 2 | 0 | 1 | **2** | **closed in v0.27.1 (2026-10-12)** — the trees ARE identifiable by content, so they are reapable without claiming another program's directory: `python3 test/residue.py --reap-own` (dry run by default, `--apply` to remove) claims only an `mkdtemp()`-named, non-symlink child of the temp root holding exactly one of the fixture's end states byte for byte, plus optionally its own manifest, unchanged for 24 h. `test/reap_own_test.py` 6/6 in a sandbox temp root; a planted permissive reaper (any tree holding `sub/b.sh`) was refused: `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']`. Real `$TMPDIR`, dry, 2026-10-12: `0 tree(s) match`; the `find` count is 0 — the 2592 trees are already gone, and both leaking suites (`agent-stack`, `seo-aeo-audit`) now use `residue.workspace()`. Nothing in the real `$TMPDIR` was deleted | +| AG-06b | **The 2592 existing trees are reported and left alone.** `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \\| wc -l` → **2592** on 2026-08-20, of which **16 were added by this run's own verification** — one run of the leaking fixture before the port, and one of the v0.12.0 archive's copy of it. They are plain `tmpXXXXXXXX` and indistinguishable from every other program's, so they are named rather than swept. The identical fixture still leaks in **`seo-aeo-audit`**, which another agent holds; referred, not edited. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 2 | 0 | 1 | **2** | **closed in v0.27.1 (2026-10-12)** — the trees ARE identifiable by content, so they are reapable without claiming another program's directory: `python3 test/residue.py --reap-own` (dry run by default, `--apply` to remove) claims only an `mkdtemp()`-named, non-symlink child of the temp root holding exactly one of the fixture's five end-state file sets and its directory set byte for byte, plus optionally its own manifest, unchanged for 24 h; root, age floor and the removal window hardened after the PR #45 reading (verification.md, v0.27.1). `test/reap_own_test.py` 6/6 in a sandbox temp root; a planted permissive reaper (any tree holding `sub/b.sh`) was refused: `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']`. Real `$TMPDIR`, dry, 2026-10-12: `0 tree(s) match`; the `find` count is 0 — the 2592 trees are already gone, and both leaking suites (`agent-stack`, `seo-aeo-audit`) now use `residue.workspace()`. Nothing in the real `$TMPDIR` was deleted | +| B-159 | **`seo-aeo-audit` still has the AG-06a/B-158 hole.** Its temp-tree check reads less than the whole repository and misses aliased calls, and `test/negatives.py:195` makes a bare `tempfile.mkdtemp(prefix="tp-negatives-snap-")` outside any residue ledger (read on its `origin/main`, 2026-10-12). Port agent-stack 0.27.1's whole-repository scan, any-receiver pattern and exact-shape exemptions there, and route that snapshot through the ledger. | agent-stack PR #45 blind reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — another repository; referred, not edited | +| B-160 | **make-skill's temp-tree check reads `test/` only.** make-skill 0.31.0 has the any-receiver pattern but walks `test/` alone, so a maker in a shipped script is invisible there exactly as AG-06a described here. Port agent-stack 0.27.1's whole-repository scan with exact-path, exact-count, exact-shape exemptions back to make-skill. | agent-stack PR #45 blind reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — another repository; referred, not edited | ## Open, and why diff --git a/docs/evidence/verification.md b/docs/evidence/verification.md index 2d21016..ca19663 100644 --- a/docs/evidence/verification.md +++ b/docs/evidence/verification.md @@ -27,6 +27,13 @@ to the orchestrator. Next task, exactly: after the merge, tag `v0.27.1`, read th | AG-06a plants | shipped scripts are read | `plugins/…/scripts/zz_plant.py` with `import tempfile as tf` / `tf.mkdtemp()` → `zz_plant.py: creates a temp tree outside the residue ledger — 'tf.mkdtemp(' at line 2. A shipped script cannot reach the suite's ledger: …`; `bin/zz_plant.py` with a renamed `TemporaryDirectory` import → refused at line 1; a third maker appended to `audit_agent.py` → `'tempfile.mkdtemp(' at line 408 (3 makers, 2 allowed: …)`; `plugins` added to the skip set → `AG-06a self-test: …audit_agent.py is not in the temp-tree scan`. Each plant removed, gate back to `OK`. The new CI step's logic run locally on a copy: refused with the exact file-and-line string it greps for | **planted**, then passed; CI step not yet run by GitHub | | AG-06b | `python3 test/residue.py --reap-own [--apply] [--tmpdir DIR] [--min-age-hours N]` via `test/reap_own.py` | `test/reap_own_test.py` first run → `ModuleNotFoundError: No module named 'reap_own'`; after the module, 6/6 in a sandbox temp root made by `residue.workspace()`. Its near-miss case asserts each plant carries an `mkdtemp()`-shaped name, so a plant excluded by name rather than content fails as `PLANT DID NOT LAND` (it did, on the first draft's 7-character names). Planted a permissive reaper (accept any tree holding `sub/b.sh`) → `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']` and `--apply removed a tree that is not the fixture's`; restored → `PASS: reap-own — 6 cases` | **planted**, then passed | | AG-06b, this machine | the 2592 trees | `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \| wc -l` → `0` on 2026-10-12 (also 0 under `/tmp`); `python3 test/residue.py --reap-own` (dry) → `reap-own: 0 tree(s) match the leaking fixture's fingerprint under /var/folders/…/T — DRY RUN, nothing removed.` Nothing in the real `$TMPDIR` was removed; `seo-aeo-audit`'s fixture now uses `residue.workspace("plant-guard")` (its `origin/main`, line 52) | **observed**, local | +| PR #45 review — root | `--tmpdir` refused when empty, `/`, `$HOME` or an ancestor of `$HOME`, after `realpath` | Cases written first, run with `HOME` in a sandbox and never with `--apply`: `--tmpdir /: expected exit 2, got 0: reap-own: 0 tree(s) match … under /`, the same for `$HOME` and its parent; after the fix → refused, `CANNOT RUN` | **watched failing**, then passed | +| PR #45 review — age floor | `--min-age-hours` refuses `nan`, `inf`, `-inf`, `0`, `0.5`, `-3`, `soon`, empty; `1` accepted | First run → `--min-age-hours 'nan': expected exit 2, got 0`; after the fix every value → exit 2, `CANNOT RUN`, no `Traceback` | **watched failing**, then passed | +| PR #45 review — window | `reap()` renames to `.reap-own-quarantine--` in the same root, re-verifies, then removes; a changed tree is renamed back | The case injects a write into the quarantined tree through the `_window` seam. Against a `reap()` that renamed but did not re-verify → `a tree changed in the window was removed`; with the re-check → the changed tree is back under its own name with its write, the untouched one removed, `(1, 1)`, no quarantine residue | **watched failing** on behaviour, then passed | +| PR #45 review — directories | the fingerprint compares the directory set too | Trees with an extra empty `copy/mine` and a bare `copy/.git` → first run `claimed: ['tmpemptyd01', 'tmpemptyd02']`; after the fix → none claimed | **watched failing**, then passed | +| PR #45 review — counts | five end-state file sets, the pre-snapshot `.git/index` = `before` state dropped | `the_counts_are_the_fixture_s` → first run `6 file sets, the fixture has 5`; after → passes. The docstring and case name now say eight cases, five distinct file sets | **watched failing**, then passed | +| PR #45 review — exempt shape | an exempt shipped file must make every tree in the `with … TemporaryDirectory(` form | Plant on the old check: one of `audit_agent.py`'s two `with` blocks swapped for `d = tempfile.mkdtemp()` → `OK` (the count was still 2). In-gate control written against a stub → `PR #45 self-test: a bare mkdtemp() beside a `with` form … was not reported at line 4: []`; implemented → the same file plant → `audit_agent.py: 'tempfile.mkdtemp(' at line 347 is not a `with … TemporaryDirectory(` block` | **watched failing**, then passed | +| PR #45 review — stated limit | a string literal containing `"""` hides the makers up to the next `"""` | `temp_maker_hits()` on `q = '"""'` / `tempfile.mkdtemp()` / `r = """doc"""` → `[]`; written into the check's comment as a limit, not fixed. Board rows B-159 (seo-aeo-audit port) and B-160 (make-skill whole-repo port) filed | **observed**, documented | | Gate | `npm test` (now also runs `test/reap_own_test.py`) | EXIT=0 on 2026-10-12: `OK: agent-stack structurally valid (16 checks = 12 named + 4 per-skill, 4 skill(s), v0.27.1)`, `PASS: plant_guard — 9 cases`, `PASS: reap-own — 6 cases`, `PASS: installer — 11 case(s)`, all 25 `test/audit_regressions/*.py` exit 0; every residue line `left nothing` | **observed**, local | ## Release candidate — v0.27.0 (2026-10-12) diff --git a/test/reap_own.py b/test/reap_own.py index 2fb528e..8a5e3fb 100644 --- a/test/reap_own.py +++ b/test/reap_own.py @@ -7,26 +7,32 @@ `tmpXXXXXXXX` like every other program's, so a prefix cannot attribute them, and the operator's rule is that a run frees disk only through residue it can prove is its own. -Content can prove it. The fixture wrote fixed bytes into a fixed shape, and each of its cases -left one of seven end states. A directory is claimed only when ALL of these hold: +Content can prove it. The fixture wrote fixed bytes into a fixed shape, and its eight cases +end in five distinct file sets (no-change, mode-only, manifest-outside and missing-snapshot +share one). A directory is claimed only when ALL of these hold: - a direct child of the temp root, named as `mkdtemp()` names (`tmp` + 8 of `[a-z0-9_]`), and not a symlink; - its entries are exactly `copy/` and, optionally, `copy.plant-manifest.json`; -- `copy/` holds, as regular files with no symlinks, exactly one of the seven file sets the - fixture's cases end in, byte for byte (`sub/b.sh` is in every one); +- `copy/` holds, as regular files with no symlinks, exactly one of those five file sets, byte + for byte (`sub/b.sh` is in every one), and exactly the directories that set needs (`sub`, + plus `.git` for the churn state) — an extra directory, empty or not, is somebody else's; - the manifest, when present, is the fixture's own snapshot: `a.txt` and `sub/b.sh` with the fixture's two SHA-256 digests and nothing else; - nothing in it changed in the last `--min-age-hours` (default 24), so a run still in progress is never reaped from under itself. Anything that differs by one byte, one entry, one name or one link is somebody else's and is -left alone. A dry run is the default; `--apply` removes, re-checking each tree immediately -before it goes. The fingerprint identifies the FIXTURE, not the repository: a tree from +left alone. A dry run is the default. `--apply` first renames each tree, atomically, to a +quarantine name in the same root, re-verifies the renamed tree, and only then removes it; a +tree that changed in that window is renamed back. The root itself is refused when it is empty, +`/`, `$HOME` or an ancestor of `$HOME` (after `realpath`), and the age floor when it is not a +finite number of at least one hour. The fingerprint identifies the FIXTURE, not the repository: a tree from `seo-aeo-audit`'s byte-identical copy matches too, and both are this family's own suites. """ import hashlib import json +import math import os import re import shutil @@ -48,26 +54,28 @@ def sha(data): return hashlib.sha256(data).hexdigest() -# The seven end states, as {relative path: bytes}. `nochange` is also where the -# manifest-outside, missing-snapshot and churn-before-verify cases stop. +# The five end states, as ({relative path: bytes}, {relative directories}). The churn case +# writes `.git/index` as "before" and the snapshot then runs; a tree left at "before" means +# the snapshot never ran, which is not a state the fixture's cases end in. STATES = ( - {"a.txt": A, "sub/b.sh": B}, # no change, mode, manifest - {"a.txt": A_PLANTED, "sub/b.sh": B}, # content edit - {"a.txt": A, "sub/b.sh": B, "orphan.md": ORPHAN}, # new file - {"sub/b.sh": B}, # deletion - {"a.txt": A, "sub/b.sh": B, ".git/index": b"before\n"}, # git churn, before - {"a.txt": A, "sub/b.sh": B, ".git/index": b"after\n"}, # git churn, after + ({"a.txt": A, "sub/b.sh": B}, {"sub"}), # no change, mode, manifest, no snap + ({"a.txt": A_PLANTED, "sub/b.sh": B}, {"sub"}), # content edit + ({"a.txt": A, "sub/b.sh": B, "orphan.md": ORPHAN}, {"sub"}), # new file + ({"sub/b.sh": B}, {"sub"}), # deletion + ({"a.txt": A, "sub/b.sh": B, ".git/index": b"after\n"}, {"sub", ".git"}), # git churn ) FIXTURE_SNAPSHOT = sorted([("a.txt", sha(A)), ("sub/b.sh", sha(B))]) def _read_tree(copy): - """{rel: bytes} for every entry under `copy`, or None if anything is not plain.""" - files, total = {}, 0 + """({rel: bytes}, {rel dirs}) for everything under `copy`, or None if anything is not plain.""" + files, dirs, total = {}, set(), 0 for dirpath, dirnames, filenames in os.walk(copy, followlinks=False): for d in dirnames: - if os.path.islink(os.path.join(dirpath, d)): + full = os.path.join(dirpath, d) + if os.path.islink(full): return None + dirs.add(os.path.relpath(full, copy).replace(os.sep, "/")) for f in filenames: full = os.path.join(dirpath, f) st = os.lstat(full) @@ -78,7 +86,7 @@ def _read_tree(copy): return None with open(full, "rb") as fh: files[os.path.relpath(full, copy).replace(os.sep, "/")] = fh.read() - return files + return files, dirs def _newest_mtime(top): @@ -91,9 +99,13 @@ def _newest_mtime(top): def is_fixture_tree(top, min_age_s=24 * 3600, now=None): """True only when `top` is, byte for byte, a tree the leaking fixture left.""" + return bool(NAME.match(os.path.basename(top))) and _has_fixture_shape(top, min_age_s, now) + + +def _has_fixture_shape(top, min_age_s=24 * 3600, now=None): + """The content half of the fingerprint, independent of the name — so a tree renamed to + its quarantine name can be re-verified by exactly the same rule.""" try: - if not NAME.match(os.path.basename(top)): - return False st = os.lstat(top) if not stat.S_ISDIR(st.st_mode): return False # a symlink or a file is never ours @@ -103,8 +115,8 @@ def is_fixture_tree(top, min_age_s=24 * 3600, now=None): copy = os.path.join(top, "copy") if not stat.S_ISDIR(os.lstat(copy).st_mode): return False - files = _read_tree(copy) - if files is None or files not in STATES: + tree = _read_tree(copy) + if tree is None or tree not in STATES: return False if MANIFEST in entries: mpath = os.path.join(top, MANIFEST) @@ -134,22 +146,89 @@ def find_own(tmpdir, min_age_s=24 * 3600): return out +QUARANTINE_PREFIX = ".reap-own-quarantine-" + + +def reap(paths, root, min_age_s, _window=None): + """Remove each tree only after it has been moved out of reach and re-verified there. + + `os.rename` within one directory is atomic, so after it nobody holding the old path can + write into the tree; the content is then checked again under the new name, and a tree + that no longer matches goes back to its own name. `_window` is a test seam called with + the quarantine path between the rename and the re-check. Returns (removed, left). + """ + removed = left = 0 + for p in paths: + q = os.path.join(root, "%s%d-%s" % (QUARANTINE_PREFIX, os.getpid(), + os.path.basename(p))) + try: + os.rename(p, q) + except OSError: + left += 1 # gone or moved since the scan: not ours now + continue + if _window: + _window(q) + if not _has_fixture_shape(q, min_age_s): + try: + os.rename(q, p) # changed in the window: give it its name back + except OSError: + print(f"reap-own: {q} changed after the scan and could not be renamed back " + f"to {p}; left in place", file=sys.stderr) + left += 1 + continue + shutil.rmtree(q, ignore_errors=True) + if os.path.exists(q): + left += 1 + else: + removed += 1 + return removed, left + + +def _refuse(msg): + print(f"reap-own CANNOT RUN: {msg}", file=sys.stderr) + return 2 + + +def _unsafe_root(tmpdir): + """Why `tmpdir` must not be reaped, or None. Compared after realpath on both sides.""" + if not tmpdir: + return "the temp root is empty" + real = os.path.realpath(tmpdir) + home = os.path.realpath(os.path.expanduser("~")) if os.environ.get("HOME") else None + if real == os.path.realpath(os.sep): + return f"{tmpdir!r} is the filesystem root" + if home and real == home: + return f"{tmpdir!r} is $HOME" + if home and home.startswith(real.rstrip(os.sep) + os.sep): + return f"{tmpdir!r} is an ancestor of $HOME" + return None + + def main(argv): apply = "--apply" in argv tmpdir = tempfile.gettempdir() - min_age_h = 24.0 + min_age_raw = "24" it = iter(argv) for a in it: if a == "--tmpdir": tmpdir = next(it, "") elif a == "--min-age-hours": - min_age_h = float(next(it, "24")) + min_age_raw = next(it, "") elif a not in ("--reap-own", "--apply"): - print(f"reap-own CANNOT RUN: unknown argument {a!r}", file=sys.stderr) - return 2 + return _refuse(f"unknown argument {a!r}") + try: + min_age_h = float(min_age_raw) + except ValueError: + return _refuse(f"--min-age-hours {min_age_raw!r} is not a number") + if not math.isfinite(min_age_h) or min_age_h < 1: + return _refuse(f"--min-age-hours {min_age_raw!r} must be a finite number of at " + "least 1 — a younger tree may belong to a run still in progress") + why = _unsafe_root(tmpdir) + if why: + return _refuse(why) if not os.path.isdir(tmpdir): - print(f"reap-own CANNOT RUN: {tmpdir!r} is not a directory", file=sys.stderr) - return 2 + return _refuse(f"{tmpdir!r} is not a directory") + tmpdir = os.path.realpath(tmpdir) min_age_s = min_age_h * 3600 own = find_own(tmpdir, min_age_s) if not apply: @@ -160,18 +239,9 @@ def main(argv): if len(own) > 20: print(f" … and {len(own) - 20} more") return 0 - removed, skipped = 0, 0 - for p in own: - if not is_fixture_tree(p, min_age_s): # re-checked right before it goes - skipped += 1 - continue - shutil.rmtree(p, ignore_errors=True) - if os.path.exists(p): - skipped += 1 - else: - removed += 1 + removed, left = reap(own, tmpdir, min_age_s) print(f"reap-own: {len(own)} tree(s) matched under {tmpdir}; removed {removed}, " - f"left {skipped} (changed since the scan or would not go)") + f"left {left} (changed since the scan, or would not go)") return 0 diff --git a/test/reap_own_test.py b/test/reap_own_test.py index 7d5f4d5..3483278 100644 --- a/test/reap_own_test.py +++ b/test/reap_own_test.py @@ -26,7 +26,8 @@ def case(name, fn): residue.open_case(name) try: fn() - except AssertionError as e: + except Exception as e: # an error is a failure too, and must not stop the cases after it + e = e if isinstance(e, AssertionError) else f"{type(e).__name__}: {e}" failures.append(f"{name}: {e}") print(f"FAIL {name}: {e}") else: @@ -74,12 +75,20 @@ def sandbox(): return tmp -def cli(tmp, *extra): +def cli(tmp, *extra, home=None): + env = dict(os.environ) + if home is not None: + env["HOME"] = home r = subprocess.run([sys.executable, os.path.join(HERE, "residue.py"), "--reap-own", - "--tmpdir", tmp, *extra], capture_output=True, text=True) + "--tmpdir", tmp, *extra], capture_output=True, text=True, env=env) return r +def refused(r, why): + assert r.returncode == 2, f"{why}: expected exit 2, got {r.returncode}: {r.stdout}" + assert "CANNOT RUN" in r.stderr and "Traceback" not in r.stderr, f"{why}: {r.stderr}" + + STATES = ["content", "mode", "nochange", "newfile", "deleted", "gitchurn"] @@ -175,17 +184,95 @@ def a_missing_tmpdir_refuses(): assert "CANNOT RUN" in r.stderr, r.stderr -for n, f in [ - ("the seven distinct end states of the leaking fixture (eight cases) are recognised", every_terminal_state_is_recognised), +# ---- review round on PR #45: each case below was written before its fix and watched failing. +# The root cases run with HOME pointed into a sandbox and never pass --apply, so a reaper that +# wrongly accepted the root could only list, never remove. + +def the_filesystem_root_is_refused(): + refused(cli("/"), "--tmpdir /") + refused(cli(""), "--tmpdir ''") + + +def home_is_refused(): + home = os.path.join(sandbox(), "home") + os.makedirs(home) + refused(cli(home, home=home), "--tmpdir $HOME") + refused(cli(home + "/sub/..", home=home), "--tmpdir $HOME spelled through ..") + + +def an_ancestor_of_home_is_refused(): + box = sandbox() + home = os.path.join(box, "users", "me") + os.makedirs(home) + refused(cli(os.path.join(box, "users"), home=home), "--tmpdir parent of $HOME") + refused(cli(box, home=home), "--tmpdir grandparent of $HOME") + + +def a_bad_age_floor_is_refused(): + tmp = sandbox() + for v in ("nan", "inf", "-inf", "0", "0.5", "-3", "soon", ""): + refused(cli(tmp, "--min-age-hours", v), f"--min-age-hours {v!r}") + r = cli(tmp, "--min-age-hours", "1") + assert r.returncode == 0, f"--min-age-hours 1 refused: {r.stderr}" + + +def a_tree_changed_in_the_removal_window_is_put_back(): + tmp = sandbox() + t = fixture_tree(tmp, "tmpwindow01") + other = fixture_tree(tmp, "tmpwindow02") + + def tamper(quarantined): + # Somebody writes into the tree between the scan and the removal. + assert os.path.basename(quarantined) != "tmpwindow01", "the tree was not quarantined" + assert os.path.dirname(quarantined) == tmp, "quarantine left the temp root" + if "tmpwindow01" in quarantined: + put(os.path.join(quarantined, "copy", "late.txt"), b"mine\n") + + removed, left = reap_own.reap([t, other], tmp, min_age_s=0, _window=tamper) + assert os.path.isdir(t), "a tree changed in the window was removed" + assert os.path.isfile(os.path.join(t, "copy", "late.txt")), "the changed tree lost its write" + assert not os.path.exists(other), "the untouched tree was not removed" + assert (removed, left) == (1, 1), f"expected (1, 1), got {(removed, left)}" + assert sorted(os.listdir(tmp)) == ["tmpwindow01"], f"quarantine residue: {os.listdir(tmp)}" + + +def an_extra_empty_directory_is_not_the_fixture(): + tmp = sandbox() + t = fixture_tree(tmp, "tmpemptyd01") + os.makedirs(os.path.join(t, "copy", "mine")) + age(t) + t2 = fixture_tree(tmp, "tmpemptyd02") + os.makedirs(os.path.join(t2, "copy", ".git")) # .git without the churn state's index + age(t2) + found = sorted(os.path.basename(p) for p in reap_own.find_own(tmp)) + assert found == [], f"a tree with an extra empty directory was claimed: {found}" + + +def the_counts_are_the_fixture_s(): + assert len(reap_own.STATES) == 5, f"{len(reap_own.STATES)} file sets, the fixture has 5" + assert not any(st[0].get(".git/index") == b"before\n" for st in reap_own.STATES), \ + "the churn case's pre-snap state is not terminal" + + +CASES = [ + ("the fixture's eight cases, five distinct file sets, are all recognised", every_terminal_state_is_recognised), ("near-misses by one byte, entry, name, symlink or manifest are left alone", near_misses_are_left_alone), ("a tree younger than the age floor is left alone", a_young_tree_is_left_alone), ("dry run is the default and removes nothing", dry_run_is_the_default_and_removes_nothing), ("--apply removes exactly the matches", apply_removes_exactly_the_matches), ("a missing temp root refuses rather than reports clean", a_missing_tmpdir_refuses), -]: + ("--tmpdir / (and an empty root) is refused", the_filesystem_root_is_refused), + ("--tmpdir $HOME is refused", home_is_refused), + ("--tmpdir at an ancestor of $HOME is refused", an_ancestor_of_home_is_refused), + ("a non-finite, sub-hour or non-numeric age floor is refused", a_bad_age_floor_is_refused), + ("a tree changed in the removal window is put back, not removed", a_tree_changed_in_the_removal_window_is_put_back), + ("an extra empty directory makes a tree somebody else's", an_extra_empty_directory_is_not_the_fixture), + ("the end-state count is the fixture's, five", the_counts_are_the_fixture_s), +] +for n, f in CASES: case(n, f) if failures: - print(f"\nFAIL: {len(failures)} of 6") + print(f"\nFAIL: {len(failures)} of {len(CASES)}") sys.exit(1) -print("\nPASS: reap-own — 6 cases") +print(f"\nPASS: reap-own — {len(CASES)} cases") diff --git a/test/validate.py b/test/validate.py index ede39ee..00fd8fd 100644 --- a/test/validate.py +++ b/test/validate.py @@ -912,7 +912,9 @@ def check_priority_axes_are_the_manifesto_s_and_carry_no_scalar(): # it (B-158, ported back from make-skill 0.31.0, which widened this check first). What it does # NOT see, stated so nobody reads more into a green run: chained receivers (`a.b.mkdtemp(`), # a function reference stored and called later, `getattr`, and a parenthesised multi-line -# import. Only whole-line `#` comments and `"""` blocks are skipped. +# import. Only whole-line `#` comments and `"""` blocks are skipped — and that blanking is +# lexical, not a parse: a string literal that itself contains `"""` (such as `'"""'`) pairs +# with the next `"""` in the file, and every maker between the two is hidden from the check. TEMP_MAKERS = re.compile(r"(? allowed: first = hits[allowed] fail(f"{rel}: creates a temp tree outside the residue ledger — {first[0]!r} at " @@ -983,6 +999,10 @@ def check_temp_trees_go_through_the_residue_ledger(root=None): "context manager that removes it on every path and give the file an " "exact-count entry, with its reason, in `TEMP_TREE_ALLOWED` — or move the " "work into the suite")) + for m, n in exempt_shape_violations(text, shape): + fail(f"{rel}: {m!r} at line {n} is not a `with … Temporary" "Directory(` block — " + f"this file is exempt only for context-managed trees ({why}); a bare maker " + "inside the allowed count leaks exactly like one outside it") for rel in sorted(TEMP_TREE_ALLOWED): if rel not in rels: fail(f"{rel}: exempt from the temp-tree check but no longer exists — an exemption " @@ -1001,6 +1021,12 @@ def check_temp_trees_go_through_the_residue_ledger(root=None): fail("B-158 self-test: a renamed from-import of a temp-dir maker was not found") if temp_maker_hit('"""why ' + _M + '() is banned"""\n# or ' + _M + "()\n"): fail("B-158 self-test: an explanation in a docstring/comment was read as a violation") +# Review of PR #45: an exempt shipped file is held to the context-manager form, by line. +_shape_plant = ("import tempfile\nwith tempfile.Temporary" + "Directory() as d:\n pass\n" + "d = tempfile." + _M + "()\n") +if exempt_shape_violations(_shape_plant, "with") != [("tempfile." + _M + "(", 4)]: + fail("PR #45 self-test: a bare " + _M + "() beside a `with` form in an exempt shipped file " + "was not reported at line 4: " + repr(exempt_shape_violations(_shape_plant, "with"))) # AG-06a, asserted as a capability: the scan reads the shipped scripts, not only the suite. _scanned = {os.path.relpath(p, ROOT) for p in temp_scan_files(ROOT)} for _rel in TEMP_TREE_ALLOWED: