diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 149326a..b179a5a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "spectre-marketplace", "metadata": { - "version": "1.2.0", + "version": "1.2.1", "description": "Spectre — deterministic spec-driven Claude Code plugin (vision → spec → evaluate → lock → implement → verify)." }, "owner": { @@ -11,7 +11,7 @@ { "name": "spectre", "description": "Three-tier pre-lock spec evaluator + persistence-tier classifier. /vision, /implement, and /implement auto skills with action/verification gates and auto-routed Spectre-finding capture.", - "version": "1.2.0", + "version": "1.2.1", "source": "./" } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2496fa2..8d9940c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,61 @@ All notable changes to the Spectre plugin. +## v1.2.1 — 2026-05-15 + +Defect-pack hotfix. Seven fixes addressing Tier-1 regex false-positives, walker stop-signal inconsistency, post-ship-iteration false-positive on zero-exemplar views, missing substitution evidence in the eval sidecar, and missing operator-mode flag on lock state. No spec contract changes — v1.0/v1.1/v1.1.1/v1.2.0 locked specs remain valid. + +**Test count:** 1923 (v1.2.0) → 1967 (v1.2.1). +44 new tests, 0 regressions. + +### Fixed — Tier-1 `_SQL_RE` + `_SHELL_EVAL_RE` lexical-context filtering (defects #1, #2) + +- `_SQL_RE` no longer fires on Python/TS identifier collisions: `hashlib.update()`, `os.replace()`, `errors="replace"`, `.replace()`. Opener boundary tightened to require non-`.` predecessor; bare `UPDATE`/`REPLACE` now require SQL-shape continuation (`UPDATE SET`, `REPLACE INTO|VALUES`). `INSERT INTO` and `DELETE FROM` are specific enough to stand alone. +- `_SHELL_EVAL_RE`'s `$(...)` branch no longer fires inside inert string literals (JSON bodies, log strings, argv elements). A new `_classify_and_strip_literals` pre-pass distinguishes **executable string payloads** (the argument of `bash -c`, `python3 -c`, `psql -c`, etc.) from **inert data**. Inert literals are masked before sink scanning; executable payloads remain verbatim so live `bash -c "DELETE FROM x"` still fires. +- Allowlisted executable-payload interpreters: `bash`, `sh`, `zsh`, `dash`, `python`, `python3`, `node`, `nodejs`, `perl`, `ruby`, `psql`, `mysql`, `sqlite3`. Conservative fallback on unbalanced quotes (returns original action; today's behavior preserved). +- 18 new tests in `tests/test_substrate_ast_lexical_filtering.py` covering 6 false-positives, 8 true-positives, and 4 boundary cases. + +### Fixed — `_action_authored_path` accepts relative paths + `touch` (defect #3) + +- Authoring-verb regexes (`tee`, `>`, `>>`, `cat >`, `cp`, `install`, plus new `touch`) now match relative paths when a project root is threaded through. The v1.2 Fix H `--project` flag is the carrier; relative authoring was previously rejected, forcing operators into `: > path` workarounds and false `self-cycle-produces` findings on natural idioms like `tee schemas/x.json`. +- **Workspace-boundary guard:** every authored path is normalized via `pathlib.Path.resolve()` and rejected if it escapes the project root. `../etc/passwd`, out-of-root absolute paths, and symlink escapes are not cleared — `self-cycle-produces` still fires for them, preventing false-clears that would mask real authoring problems. +- Backward-compatible: when called without a project root, only absolute paths are recognized (today's behavior). +- 10 new tests in `tests/test_spec_ast_relative_authoring.py` including an end-to-end integration test through `classify()`. + +### Fixed — walker stop predicate unified (defect #4) + +- New `_recommend_stop_predicate(state, draft_text)` function is the single source of truth for the walker stop signal. Previously the explicit `walker coverage` subcommand computed coverage without first calling `_refresh_pending`, producing `recommended-stop=no` despite `pending=0 deferred=0` when the draft had been edited externally after the last answer. +- Both the post-answer emission path and the explicit-coverage entrypoint now route through the predicate. The `walker coverage` subcommand also persists the refreshed pending set so subsequent reads see the same view. +- 3 new tests in `tests/test_walker_stop_predicate_consistency.py`. + +### Fixed — `excessive-post-ship-iteration` zero-exemplar exception (defect #5) + +- The aggregate check no longer penalizes operators for picking `post-ship-iteration` when a view has zero compatible exemplars in the catalog — the deferral was forced, not chosen. +- `post-ship-iteration-deferral` findings now carry a structured `reason` field (added to `findings.Finding` as a non-fingerprinted attribute): `"operator-deferral"` when compatible exemplars existed but the operator chose to defer, `"no-compatible-exemplar"` when the catalog was empty for the view's fingerprint. +- The aggregate `excessive-post-ship-iteration` warn counts only the operator-deferral subset. Empty-catalog deferrals get a different recovery hint pointing to catalog contribution (`docs/exemplars//.md`). +- 6 new tests in `tests/test_cross_view_gate_no_compatible_exception.py` including an end-to-end integration test that exercises both reasons. + +### Added — substitution log in eval sidecar (defect #6) + +- `eval_metadata.write_sidecar()` now accepts an optional `substitutions: list[dict]` kwarg. Each entry shape: `{"from": , "to": , "reason": , "tier1_check_name": , "step_id": }`. +- Logged when an agent rewrites action content or verification commands to satisfy a Tier-1 check — contemporaneous evidence, not a finding. Empty array when no rewrites; absent key when the caller doesn't supply the kwarg (back-compat). +- Forwarded by the `write-sidecar` CLI subcommand so Python and shell callers stay in sync. +- 4 new tests in `tests/test_eval_sidecar_substitutions.py`. + +### Added — `operator_mode` flag on lock state (defect #7) + +- Each lock entry in `state/.locks.json` now records `operator_mode: "interactive" | "auto"` so downstream audit/evidence can distinguish operator-driven locks from `/implement auto` runs. +- `supervisor.LockState.acquire()` accepts an `operator_mode` kwarg (default `"interactive"`); the supervisor's `acquire` request op accepts an `operator_mode` field (default `"interactive"`). +- Backward-compatible: pre-1.2.1 lock files that lack the field default to `"interactive"` on reconcile. +- 3 new tests in `tests/test_supervisor_operator_mode.py`. + +### Surface bumps + +- `.claude-plugin/marketplace.json` 1.2.0 → 1.2.1 (both `metadata.version` and `plugins[0].version`) +- `README.md` test-count badge: 1923 → 1967 +- `README.md` version badge: 1.2.0 → 1.2.1 +- `findings.Finding` gains a non-fingerprinted `reason: str | None` field +- `eval_metadata.write_sidecar` gains an optional `substitutions: list[dict] | None` kwarg + ## v1.2.0 — 2026-05-15 Coverage + diagnostics minor release. 13 fixes across five classes surfaced by Vidence's v1.1 dogfooding. No spec contract changes — locked specs from v1.0/v1.1/v1.1.1 remain valid. v1.2 evaluator may surface additional `warn`/`info` findings on existing specs where new checks now apply. diff --git a/README.md b/README.md index 5db90e1..7cea738 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > Spectre — a deterministic spec-driven Claude Code plugin. Vision → Spec → Evaluate → Lock → Implement → Verify, with three-tier pre-lock review and per-project resource locking. -[![tests](https://img.shields.io/badge/tests-1923%20passing-brightgreen)](#tests) [![python](https://img.shields.io/badge/python-3.11%2B-blue)](#install) [![stdlib only](https://img.shields.io/badge/deps-stdlib%20only-blue)](#install) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![version](https://img.shields.io/badge/version-1.2.0-blue)](CHANGELOG.md) +[![tests](https://img.shields.io/badge/tests-1967%20passing-brightgreen)](#tests) [![python](https://img.shields.io/badge/python-3.11%2B-blue)](#install) [![stdlib only](https://img.shields.io/badge/deps-stdlib%20only-blue)](#install) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![version](https://img.shields.io/badge/version-1.2.1-blue)](CHANGELOG.md) ## Table of Contents @@ -116,7 +116,7 @@ Full vocabulary registry: [`docs/glossary.md`](docs/glossary.md) (75+ status cod Full reference — hooks, skills, spec step schema, sidecar format, layout, finding-kind taxonomy: [`docs/API.md`](docs/API.md). -**v1.2 components** — plugin `1.2.0` ([`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json)), `EVALUATOR_VERSION = "1.0.0"` ([`bin/spec_evaluator.py`](bin/spec_evaluator.py)), `WALKER_VERSION = "1.0.0"` ([`bin/walker.py`](bin/walker.py)). Walker state files persisted under v0.9 are rejected on load; remove `state/.walk.json` and re-run `/vision` to migrate (hard cutover from v0.9; no migration tool). +**v1.2 components** — plugin `1.2.1` ([`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json)), `EVALUATOR_VERSION = "1.0.0"` ([`bin/spec_evaluator.py`](bin/spec_evaluator.py)), `WALKER_VERSION = "1.0.0"` ([`bin/walker.py`](bin/walker.py)). Walker state files persisted under v0.9 are rejected on load; remove `state/.walk.json` and re-run `/vision` to migrate (hard cutover from v0.9; no migration tool). **`spectre` CLI surface** — top-level wrapper resolves `${CLAUDE_PLUGIN_ROOT}`, exports `PYTHONPATH`, dispatches to `python3 -m bin.`: @@ -133,7 +133,7 @@ Full reference — hooks, skills, spec step schema, sidecar format, layout, find ## Tests ```bash -pytest tests/ # 1923 tests, stdlib + pytest +pytest tests/ # 1967 tests, stdlib + pytest pytest tests/ -v # verbose pytest tests/test_spec_evaluator.py -v # single module ``` diff --git a/bin/cross_view_gate.py b/bin/cross_view_gate.py index c73cf72..3f0440a 100644 --- a/bin/cross_view_gate.py +++ b/bin/cross_view_gate.py @@ -182,8 +182,31 @@ def _check_cross_view_references( } -def _emit_deferral_finding(section: str) -> "_findings.Finding": - """Return a post-ship-iteration-deferral info finding for the given view section.""" +def _emit_deferral_finding( + section: str, + reason: str = "operator-deferral", +) -> "_findings.Finding": + """Return a post-ship-iteration-deferral info finding for the given view section. + + ``reason`` distinguishes: + - ``operator-deferral`` (default) — the operator chose post-ship-iteration + despite compatible exemplars being available. Excessive deferrals across + views signal poor process; the aggregate check counts these. + - ``no-compatible-exemplar`` — the view's catalog has zero exemplars + matching the receiver-fingerprint. The deferral was forced, not chosen; + the aggregate check skips it. Recovery hint redirects to catalog + contribution rather than process correction. + """ + if reason == "no-compatible-exemplar": + suggested = ( + f"§{section}'s catalog is empty for this fingerprint — consider " + f"contributing an exemplar via docs/exemplars//.md." + ) + else: + suggested = ( + f"Add a compatible exemplar to ~/.spectre/exemplars/ or the plugin " + f"catalog, then re-run the walker to bind §{section}." + ) return _findings.Finding( tier=2, kind="post-ship-iteration-deferral", @@ -195,21 +218,57 @@ def _emit_deferral_finding(section: str) -> "_findings.Finding": f"§{section} deferred exemplar selection to post-ship iteration — " f"no catalog exemplar matched the view's receiver-fingerprint." ), - suggested_fix=( - f"Add a compatible exemplar to ~/.spectre/exemplars/ or the plugin catalog, " - f"then re-run the walker to bind §{section}." - ), + suggested_fix=suggested, + reason=reason, ) +def _no_compatible_exemplar( + section: str, + view_fp: str | None, + catalog: "_catalog.Catalog", +) -> bool: + """True iff the catalog has zero exemplars compatible with this view's fingerprint. + + "Compatible" means the exemplar's view-type covers the section AND its + `calibrated_for` either is empty (any-match) or includes the view's + receiver-fingerprint. When the view has no declared fingerprint, fall back + to "view-type alone must match" — same coverage check. + """ + view_types = _VIEW_TO_CATALOG_TYPES.get(section, set()) + if not view_types: + return False + for ex in catalog.exemplars.values(): + if not (set(ex.view_types) & view_types): + continue + if not ex.calibrated_for or view_fp is None: + return False # any-match exemplar exists + if view_fp in ex.calibrated_for: + return False + return True + + def _check_exemplar_bindings( view_blocks: dict[str, str], + substrate_blocks: dict[str, str] | None = None, ) -> list[_findings.Finding]: results: list[_findings.Finding] = [] catalog = _catalog.load_catalog() + fingerprints = ( + _extract_receiver_fingerprints(substrate_blocks) + if substrate_blocks is not None + else {} + ) for section, block in view_blocks.items(): if _is_not_applicable(block): continue + substrate_key = _VIEW_SECTION_TO_SUBSTRATE_KEY.get(section) + view_fp = fingerprints.get(substrate_key) if substrate_key else None + deferral_reason = ( + "no-compatible-exemplar" + if _no_compatible_exemplar(section, view_fp, catalog) + else "operator-deferral" + ) # Parse taxonomy-version declarations (`taxonomy-version: help-text:1, error-text:1`) spec_taxonomies: dict[str, int] = {} for tv_match in _TAXONOMY_VERSION_RE.finditer(block): @@ -228,7 +287,7 @@ def _check_exemplar_bindings( # _EXEMPLAR_REF_RE — detect it with its own regex. Emit at most one deferral # finding per section even if multiple style-keys are deferred. if _POST_SHIP_RE.search(block): - results.append(_emit_deferral_finding(section)) + results.append(_emit_deferral_finding(section, reason=deferral_reason)) _section_deferred = True # Find exemplar bindings for m in _EXEMPLAR_REF_RE.finditer(block): @@ -244,7 +303,7 @@ def _check_exemplar_bindings( # missing-catalog error. Skip if the section already emitted a deferral. if raw_ref == "post-ship-iteration": if not _section_deferred: - results.append(_emit_deferral_finding(section)) + results.append(_emit_deferral_finding(section, reason=deferral_reason)) _section_deferred = True continue status, matches = _catalog.lookup_status(raw_ref) @@ -460,7 +519,14 @@ def _check_excessive_post_ship_iteration( fingerprint. More than one suggests a broader catalog structural gap — the operator should file a catalog issue rather than deferring silently. """ - count = sum(1 for f in findings if f.kind == "post-ship-iteration-deferral") + # Only count operator-chosen deferrals. Views with zero compatible + # exemplars (`reason == "no-compatible-exemplar"`) force the choice; + # penalizing the operator for the only valid option is the v1.2 defect. + count = sum( + 1 for f in findings + if f.kind == "post-ship-iteration-deferral" + and (f.reason or "operator-deferral") == "operator-deferral" + ) if count > 1: return [_findings.Finding( tier=2, @@ -498,7 +564,7 @@ def classify(spec_path: pathlib.Path) -> list[_findings.Finding]: view_blocks = _extract_view_blocks(body) results: list[_findings.Finding] = [] results.extend(_check_cross_view_references(view_blocks, substrate_blocks)) - results.extend(_check_exemplar_bindings(view_blocks)) + results.extend(_check_exemplar_bindings(view_blocks, substrate_blocks)) results.extend(_check_fingerprint_vs_hard_contract(body, substrate_blocks)) results.extend(_check_fingerprint_vs_exemplar(view_blocks, substrate_blocks)) # Aggregation pass — must run after all per-view checks have emitted. diff --git a/bin/eval_metadata.py b/bin/eval_metadata.py index 61b196c..d1d8824 100644 --- a/bin/eval_metadata.py +++ b/bin/eval_metadata.py @@ -240,6 +240,7 @@ def write_sidecar( contract_resolution: dict | None = None, substrate_resolution: dict | None = None, findings_inline: list[dict] | None = None, + substitutions: list[dict] | None = None, ) -> pathlib.Path: """Atomic write of .eval.json next to the spec file. @@ -268,6 +269,12 @@ def write_sidecar( through; if the payload already contains a ``contract_resolution`` key it is forwarded automatically (see the CLI handler below). Python API callers should pass the value from ``result.sidecar_payload.get("contract_resolution")``. + + *substitutions* — v1.2.1 #6 evidence trail. When non-None, each entry is + a dict ``{"from": , "to": , "reason": , + "tier1_check_name": , "step_id": }`` recording an agent's + rewrite of action content or verification commands to satisfy a Tier-1 + check. Not a finding — contemporaneous evidence the operator can audit. """ spec_path = pathlib.Path(spec_path) sidecar_path = sidecar_path_for(spec_path) @@ -308,6 +315,14 @@ def write_sidecar( if findings_inline is not None: payload["findings"] = findings_inline + # v1.2.1 #6: substitution evidence. Each entry shape: + # {"from": str, "to": str, "reason": str, + # "tier1_check_name": str, "step_id": str} + # Logged when an agent rewrites action content or verification commands + # to satisfy a Tier-1 check. Evidence, not a finding. + if substitutions is not None: + payload["substitutions"] = substitutions + # Atomic write: mkstemp + os.replace fd, tmp = tempfile.mkstemp( dir=sidecar_path.parent, prefix=sidecar_path.name, suffix=".tmp" @@ -490,6 +505,7 @@ def write_envelope_alongside_sidecar( contract_resolution=payload.get("contract_resolution"), substrate_resolution=payload.get("substrate_resolution"), findings_inline=payload.get("findings_inline"), + substitutions=payload.get("substitutions"), ) except KeyError as exc: _status.emit("error", "eval_metadata.sidecar_missing_field", dest="stderr", diff --git a/bin/findings.py b/bin/findings.py index c344e8b..c23d071 100644 --- a/bin/findings.py +++ b/bin/findings.py @@ -159,6 +159,12 @@ class Finding: # works even when the model omits the "missing: X;" message prefix. # NOT included in fingerprint() — message text already excluded there. target_artifact: str | None = None + # v1.2.1 — structured reason for findings that share a kind but have + # distinct semantic causes (e.g. `post-ship-iteration-deferral` may stem + # from an operator-chosen deferral or from a view with zero compatible + # exemplars). Lets aggregators count only the operator-deferral subset. + # NOT included in fingerprint() — same rationale as target_artifact. + reason: str | None = None def __post_init__(self) -> None: if self.kind not in KNOWN_KINDS: diff --git a/bin/spec_ast.py b/bin/spec_ast.py index d4528c5..41b524f 100644 --- a/bin/spec_ast.py +++ b/bin/spec_ast.py @@ -898,13 +898,46 @@ def _parse_mutates_paths(body: str) -> list[str]: return paths -def _action_authored_path(action: str) -> list[str]: +# Accept both absolute and relative authoring paths. Relative paths require +# a project root and must resolve inside it (workspace-boundary guard). +_AUTHORED_PATH_CHARSET = r"[a-zA-Z0-9_./-]" + + +def _path_inside_root(raw: str, project_root: pathlib.Path | None) -> str | None: + """Return the path if it resolves inside project_root, else None. + + Absolute paths must be under project_root after resolve(). Relative paths + are resolved against project_root. If project_root is None (today's + behavior), only absolute paths are accepted unchanged and no containment + check runs — preserves API compatibility for callers that have not yet + threaded the project root through. `..` escapes and symlink-escapes are + rejected once a project_root is supplied. + """ + if project_root is None: + return raw if raw.startswith("/") else None + try: + candidate = pathlib.Path(raw) + if not candidate.is_absolute(): + candidate = project_root / candidate + resolved = candidate.resolve() + except (OSError, ValueError): + return None + root = project_root.resolve() + if resolved == root or root in resolved.parents: + return raw + return None + + +def _action_authored_path( + action: str, project_root: pathlib.Path | None = None +) -> list[str]: """Heuristic: return file paths that the action plausibly *creates/writes*. Looks for: - - heredoc targets: `cat > /path < path < /path or >> /path redirects (file writes) + - Any > path or >> path redirects (file writes) + - touch path (single-arg authoring verb; relative or absolute) - CLI output flags: a token in _SELF_CYCLE_OUTPUT_OPTS means the next non-flag token is an authored output destination. Also handles the equals-form (--out=path). @@ -913,24 +946,47 @@ def _action_authored_path(action: str) -> list[str]: within them. Keeping mkdir paths out prevents false "authored" matches when a subsequent step invokes a file inside the created directory. + Path scope: + - With ``project_root=None`` (today's behavior): only absolute paths are + recognized. + - With a project root provided: relative paths are resolved against it. + In both modes, paths that escape ``project_root`` (`../etc/passwd`, + out-of-root absolute paths, symlink-escapes) are rejected — the + workspace-boundary guard protects against false-clears of + ``self-cycle-produces``. + Returns a list of created/written file paths. """ created: list[str] = [] - # Redirect writes: > /path or >> /path - for m in re.finditer(r"(?:>>?)\s*(/[a-zA-Z0-9_/.-]+)", action): - created.append(m.group(1)) - # cat > /path <\s*(/[a-zA-Z0-9_/.-]+)", action): - created.append(m.group(1)) - # tee /path - for m in re.finditer(r"\btee\s+(/[a-zA-Z0-9_/.-]+)", action): - created.append(m.group(1)) - # cp src /dest — destination is the last abs path - for m in re.finditer(r"\bcp\s+\S+\s+(/[a-zA-Z0-9_/.-]+)", action): - created.append(m.group(1)) - # install ... /dest - for m in re.finditer(r"\binstall\b[^&|;]*\s(/[a-zA-Z0-9_/.-]+)(?:\s|$)", action): - created.append(m.group(1)) + + def _add(raw: str) -> None: + guarded = _path_inside_root(raw, project_root) + if guarded is not None: + created.append(guarded) + + cs = _AUTHORED_PATH_CHARSET + # Redirect writes: > path or >> path + for m in re.finditer(rf"(?:>>?)\s*({cs}+)", action): + _add(m.group(1)) + # cat > path <\s*({cs}+)", action): + _add(m.group(1)) + # tee path + for m in re.finditer(rf"\btee\s+({cs}+)", action): + _add(m.group(1)) + # cp src dest — destination is the last path + for m in re.finditer(rf"\bcp\s+\S+\s+({cs}+)", action): + _add(m.group(1)) + # install ... dest + for m in re.finditer(rf"\binstall\b[^&|;]*\s({cs}+)(?:\s|$)", action): + _add(m.group(1)) + # touch path1 [path2 ...] — single-arg authoring verb. Touch creates the + # named file if absent, so it qualifies as authoring for self-cycle + # purposes. Multiple paths handled by greedy tokenisation. + for m in re.finditer(r"\btouch\s+([^\n&|;]+)", action): + for part in m.group(1).split(): + if re.fullmatch(rf"{cs}+", part): + _add(part) # CLI output flags: -o path, --out path, --out=path, etc. # Tokenise the action to find flag → next-token pairs. tokens: list[str] | None = None @@ -985,6 +1041,7 @@ def _module_path_candidates(module: str) -> list[str]: def _check_action_invokes_uncreated_artifact( steps: list[dict], mutates_paths: list[str], + project_root: pathlib.Path | None = None, ) -> list[_findings.Finding]: """Gap A: block if an action invokes an absolute path under mutates: that no prior step authored. @@ -1067,7 +1124,7 @@ def _check_action_invokes_uncreated_artifact( )) # --- Now record what this step authors --- - authored.extend(_action_authored_path(action)) + authored.extend(_action_authored_path(action, project_root=project_root)) return results @@ -1593,7 +1650,10 @@ def _action_token_matches_produces_path(token: str, produces_path: str) -> bool: return produces_path == token or produces_path.endswith("/" + token.lstrip("/")) -def _check_self_cycle_produces(steps: list[dict]) -> list[_findings.Finding]: +def _check_self_cycle_produces( + steps: list[dict], + project_root: pathlib.Path | None = None, +) -> list[_findings.Finding]: """Tier 1 block: a step's action consumes a path it also produces, with no earlier step's produces: covering that path. @@ -1618,7 +1678,7 @@ def _check_self_cycle_produces(steps: list[dict]) -> list[_findings.Finding]: action_tokens = _extract_action_path_tokens(action) # Exclude paths the action itself *writes* (redirect destinations, # cp/install/tee targets) — those are outputs, not consumed inputs. - authored = set(_action_authored_path(action)) + authored = set(_action_authored_path(action, project_root=project_root)) this_produces_paths = _produces_file_paths(produces) for tok in dict.fromkeys(action_tokens): @@ -2238,11 +2298,18 @@ def _check_verification_depth(step: dict, step_n: int) -> list[_findings.Finding )] -def classify(spec_path: pathlib.Path) -> list[_findings.Finding]: +def classify( + spec_path: pathlib.Path, + project_root: pathlib.Path | None = None, +) -> list[_findings.Finding]: """Tier 1 deterministic classifier. Returns Finding list (possibly empty). PURE parse/structure/tautology. Does NOT call bin.tier or bin.resources. Budget: <100ms. + + ``project_root`` (optional) enables relative-path authoring detection in + self-cycle and uncreated-artifact checks. Paths that resolve outside the + root are still treated as unauthored (workspace-boundary guard). """ text = spec_path.read_text(encoding="utf-8") text = text.replace("\r\n", "\n").replace("\r", "\n") # CRLF/CR normalization @@ -2325,7 +2392,9 @@ def classify(spec_path: pathlib.Path) -> list[_findings.Finding]: # ── Check 6 (Gap A): action invokes uncreated artifact ─────────────────── mutates_paths = _parse_mutates_paths(body) if mutates_paths: - results.extend(_check_action_invokes_uncreated_artifact(steps, mutates_paths)) + results.extend(_check_action_invokes_uncreated_artifact( + steps, mutates_paths, project_root=project_root + )) # ── Check 7 (Gap C): unowned-requirement (e2e assertions) ──────────────── results.extend(_check_unowned_requirement(steps)) @@ -2334,7 +2403,7 @@ def classify(spec_path: pathlib.Path) -> list[_findings.Finding]: results.extend(_check_negative_paths(steps, body)) # ── Check 9 (v0.8 §42): self-cycle produces ────────────────────────────── - results.extend(_check_self_cycle_produces(steps)) + results.extend(_check_self_cycle_produces(steps, project_root=project_root)) # ── Check 10 (v0.9 §46): implicit-precondition-missing ─────────────────── results.extend(_check_implicit_precondition_missing(steps)) diff --git a/bin/spec_evaluator.py b/bin/spec_evaluator.py index adf2b3d..90fa796 100644 --- a/bin/spec_evaluator.py +++ b/bin/spec_evaluator.py @@ -597,6 +597,7 @@ def evaluate( *, config_path: pathlib.Path | None = None, bundle_persist_dir: pathlib.Path | None = None, + project_root: pathlib.Path | None = None, ) -> EvaluatorResult: """Full pipeline: build bundle → Tier 1 → Tier 2 → Tier 3 (if config enables) → aggregate findings → compute max severity → persist bundle → return result. @@ -624,7 +625,7 @@ def evaluate( _persist_bundle(bundle, bundle_persist_dir) # ── Step 3: Tier 1 ────────────────────────────────────────────────────── - tier1_findings = _spec_ast.classify(draft_path) + tier1_findings = _spec_ast.classify(draft_path, project_root=project_root) tier1_findings.extend(_substrate_ast.classify(draft_path)) # Tier 1.5 spec-author lints (v0.3.1): runuser-no-cd, unsafe-heredoc. # Folded into Tier 1 results so callers see a single deterministic group. @@ -886,7 +887,12 @@ def _resolve(p: str) -> pathlib.Path: config_path = _resolve(args.config) if args.config else None bundle_dir = _resolve(args.bundle_dir) if args.bundle_dir else None try: - result = evaluate(spec_path, config_path=config_path, bundle_persist_dir=bundle_dir) + result = evaluate( + spec_path, + config_path=config_path, + bundle_persist_dir=bundle_dir, + project_root=project_root, + ) except Exception as exc: # noqa: BLE001 _status.emit("error", "eval.run", dest="stderr", reason=str(exc), remediation="check spec syntax then run 'spectre walker get-state' to inspect walk state") diff --git a/bin/substrate_ast.py b/bin/substrate_ast.py index c67c3ea..439ba06 100644 --- a/bin/substrate_ast.py +++ b/bin/substrate_ast.py @@ -274,8 +274,20 @@ def _trust_profile(body: str) -> set[str]: r"|\beval\b" r"|\$\(" ) +# SQL keyword opener must be preceded by start-of-string, whitespace, a shell +# statement boundary, or a quote (start of a kept interpreter payload) — never +# by `.` (which would indicate a method call like `hashlib.update()`, +# `os.replace()`, `.replace()`). `UPDATE` and `REPLACE` are bare verbs that +# collide with English ("update the index", "replace the value"), so they +# additionally require a following SQL-shape: `UPDATE
SET` or +# `REPLACE INTO|VALUES`. `INSERT INTO` and `DELETE FROM` are specific enough +# to stand alone. _SQL_RE = re.compile( - r"\b(?:INSERT|UPDATE|REPLACE|DELETE\s+FROM)\b", re.IGNORECASE + r"(?:^|(?<=[\s;|&(\"']))" + r"(?:INSERT\s+INTO|DELETE\s+FROM" + r"|UPDATE\s+\w+\s+SET" + r"|REPLACE\s+(?:INTO|VALUES))\b", + re.IGNORECASE, ) _TEMPLATE_RE = re.compile(r"\b(?:jinja2|template_render|format_map)\b") _NETWORK_EGRESS_RE = re.compile( @@ -285,6 +297,76 @@ def _trust_profile(body: str) -> set[str]: re.IGNORECASE, ) +# Interpreters whose `-c` / `-e` argument is an executable code payload. +# Inside a payload the sink regexes must still see the code (so a live +# `bash -c "DELETE FROM x"` fires `_SQL_RE`). Outside a payload, an inert +# literal — JSON body, log message, error string — is masked before scanning. +_EXEC_PAYLOAD_INTRO_RE = re.compile( + r"\b(?:bash|sh|zsh|dash|python|python3|node|nodejs|perl|ruby|" + r"psql|mysql|sqlite3)\s+(?:-[A-Za-z]+\s+)*-[ce]\s*$" +) +_LITERAL_PLACEHOLDER = "\x01" # inert spans collapse to a single control char + + +def _classify_and_strip_literals(action: str) -> str: + """Mask inert string-literal spans so sink regexes scan only live code. + + Walks the action left-to-right. Each quoted span (``"..."``, ``'...'``, + ``\"\"\"...\"\"\"``, ``'''...'''``) is classified: + + - *Executable* — the span is the argument of a recognized interpreter + `-c` / `-e` invocation (`bash -c`, `python3 -c`, `psql -c`, …). The + span body is kept verbatim because it is live code. + - *Inert* — JSON body, log string, error message, argv element. The + span body collapses to a placeholder so collision-prone tokens + inside data (``$(book)``, ``"update"``, ``"DELETE FROM"``) no longer + reach the sink scanners. + + Conservative on unbalanced input: returns the original action unchanged + so coverage degrades to today's behavior instead of silently passing + dangerous payloads. + """ + out: list[str] = [] + i = 0 + n = len(action) + while i < n: + ch = action[i] + if ch not in ("\"", "'"): + out.append(ch) + i += 1 + continue + # Detect triple-quote first; longer-match-wins. + if i + 2 < n and action[i + 1] == ch and action[i + 2] == ch: + quote = ch * 3 + else: + quote = ch + close = action.find(quote, i + len(quote)) + if quote == ch: # single-char quote: honour backslash escapes + scan = i + 1 + while scan < n: + c = action[scan] + if c == "\\" and scan + 1 < n: + scan += 2 + continue + if c == ch: + close = scan + break + scan += 1 + else: + close = -1 + if close == -1: + return action # unbalanced — fall back to today's behavior + body_start = i + len(quote) + body = action[body_start:close] + prefix = "".join(out) + is_exec = bool(_EXEC_PAYLOAD_INTRO_RE.search(prefix)) + if is_exec: + out.append(quote + body + quote) + else: + out.append(_LITERAL_PLACEHOLDER) + i = close + len(quote) + return "".join(out) + def _value_in_action(action: str, contract_entry: str) -> bool: """True if the contract entry's value substring appears in action.""" @@ -321,12 +403,13 @@ def _classify_source_step_sinks(action: str) -> list[str]: def _sink_kinds_from_action(action: str) -> list[str]: + scanned = _classify_and_strip_literals(action) sinks: list[str] = [] - if _SHELL_EVAL_RE.search(action): + if _SHELL_EVAL_RE.search(scanned): sinks.append("shell-eval") - if _SQL_RE.search(action) or _TEMPLATE_RE.search(action): + if _SQL_RE.search(scanned) or _TEMPLATE_RE.search(scanned): sinks.append("sql-or-template") - if _NETWORK_EGRESS_RE.search(action): + if _NETWORK_EGRESS_RE.search(scanned): sinks.append("network-egress") return sinks diff --git a/bin/supervisor.py b/bin/supervisor.py index 48a2447..fc1b2bf 100644 --- a/bin/supervisor.py +++ b/bin/supervisor.py @@ -53,30 +53,49 @@ def _atomic_write_json(path: Path, data: dict) -> None: raise +_VALID_OPERATOR_MODES = ("interactive", "auto") + + class LockState: """In-memory + JSON-persisted lock state for one project.""" def __init__(self, locks_path: Path): self.locks_path = Path(locks_path) self.resources: dict[str, int] = {} # resource_id -> capacity - # Internal 4-tuple storage: (track, pid, start_time, granted_at_iso) - self._holders: dict[str, list[tuple[str, int, float, str]]] = {} - self.queues: dict[str, list[tuple[str, int, float, str]]] = {} + # Internal 5-tuple storage: (track, pid, start_time, granted_at_iso, operator_mode) + # operator_mode (v1.2.1 #7) records whether the lock was acquired by + # an interactive /vision/walker session or an auto-mode /implement run. + # Downstream consumers (audit, evidence trail) can distinguish the two. + self._holders: dict[str, list[tuple[str, int, float, str, str]]] = {} + self.queues: dict[str, list[tuple[str, int, float, str, str]]] = {} def register_resource(self, resource_id: str, capacity: int) -> None: self.resources[resource_id] = capacity self._holders.setdefault(resource_id, []) self.queues.setdefault(resource_id, []) - def acquire(self, resource_id: str, *, track: str, actor_pid: int, actor_start_time: float) -> bool: + def acquire( + self, + resource_id: str, + *, + track: str, + actor_pid: int, + actor_start_time: float, + operator_mode: str = "interactive", + ) -> bool: if resource_id not in self.resources: raise KeyError(f"unknown resource: {resource_id}") + if operator_mode not in _VALID_OPERATOR_MODES: + raise ValueError( + f"operator_mode must be one of {_VALID_OPERATOR_MODES}, got {operator_mode!r}" + ) granted_at = datetime.now(timezone.utc).isoformat() + entry = (track, actor_pid, actor_start_time, granted_at, operator_mode) if len(self._holders[resource_id]) < self.resources[resource_id]: - self._holders[resource_id].append((track, actor_pid, actor_start_time, granted_at)) + self._holders[resource_id].append(entry) self._persist() return True - self.queues[resource_id].append((track, actor_pid, actor_start_time, granted_at)) + self.queues[resource_id].append(entry) self._persist() return False @@ -129,19 +148,25 @@ def reconcile(self) -> None: st = entry["actor_start_time"] if _actor_alive(pid, st): granted_at = entry.get("granted_at", datetime.now(timezone.utc).isoformat()) - self._holders[rid].append((entry["track"], pid, st, granted_at)) + # v1.2.1 #7: default to interactive for pre-1.2.1 lock files + # that don't carry the field. + operator_mode = entry.get("operator_mode", "interactive") + self._holders[rid].append( + (entry["track"], pid, st, granted_at, operator_mode) + ) self._persist() def _persist(self) -> None: all_locks = [] for rid, holders in self._holders.items(): - for track, pid, st, granted_at in holders: + for track, pid, st, granted_at, operator_mode in holders: all_locks.append({ "resource": rid, "track": track, "actor_pid": pid, "actor_start_time": st, "granted_at": granted_at, + "operator_mode": operator_mode, }) _atomic_write_json(self.locks_path, { "version": LOCK_FILE_VERSION, @@ -156,11 +181,14 @@ def handle_request(state: LockState, req: dict[str, Any]) -> dict[str, Any]: for k in ("track", "resource_id", "actor_pid", "actor_start_time"): if k not in req: return {"ok": False, "error": f"missing field: {k}"} + # v1.2.1 #7: operator_mode defaults to "interactive" so pre-1.2.1 + # callers (and tests) keep working unchanged. granted = state.acquire( req["resource_id"], track=req["track"], actor_pid=req["actor_pid"], actor_start_time=req["actor_start_time"], + operator_mode=req.get("operator_mode", "interactive"), ) resp: dict[str, Any] = {"ok": True, "granted": granted} if not granted: diff --git a/bin/track.py b/bin/track.py index b301b0c..519c49d 100644 --- a/bin/track.py +++ b/bin/track.py @@ -108,7 +108,13 @@ def _self_actor() -> tuple[int, float]: return pid, float(after[19]) -def acquire(project_root: Path, *, track_name: str, resource_id: str) -> dict[str, Any]: +def acquire( + project_root: Path, + *, + track_name: str, + resource_id: str, + operator_mode: str = "interactive", +) -> dict[str, Any]: pid, st = _self_actor() return _send(project_root, { "op": "acquire", @@ -116,6 +122,7 @@ def acquire(project_root: Path, *, track_name: str, resource_id: str) -> dict[st "resource_id": resource_id, "actor_pid": pid, "actor_start_time": st, + "operator_mode": operator_mode, }) diff --git a/bin/walker.py b/bin/walker.py index 9a487cb..c19c5a9 100644 --- a/bin/walker.py +++ b/bin/walker.py @@ -1253,6 +1253,22 @@ def _refresh_pending(state: WalkState, draft_text: str) -> None: state.pending.extend(generate_operator_concerns(state, draft_text)) +def _recommend_stop_predicate(state: WalkState, draft_text: str) -> dict: + """Single source of truth for the walker stop signal. + + Refreshes dynamic concerns from the current draft text, then computes the + coverage dict — including the ``recommended_stop`` boolean. Every + code path that reports or acts on the stop signal MUST call this rather + than computing coverage from a possibly-stale state snapshot. + + Mutates ``state`` (via :func:`_refresh_pending`); callers that read the + result must persist the state if they want subsequent reads to see the + same view. + """ + _refresh_pending(state, draft_text) + return _compute_coverage(state, draft_text) + + def _compute_coverage(state: WalkState, draft_text: str) -> dict: """Compute coverage metrics for the current walk state. @@ -2662,11 +2678,9 @@ def _drive_to_completeness_satisfied( except OSError: pass - # Fire newly-applicable generators now that the draft may have evolved - _refresh_pending(state, draft_text) - - # Coverage and recommend-stop transition - cov = _compute_coverage(state, draft_text) + # Unified stop-signal predicate — refreshes generators and computes + # coverage from a single state snapshot. + cov = _recommend_stop_predicate(state, draft_text) prev_emitted = state.last_recommend_stop_emitted if cov["recommended_stop"] and not prev_emitted: _status.emit("result", "walker.recommend-stop", reason="coverage-complete") @@ -2721,7 +2735,7 @@ def _drive_to_completeness_satisfied( ) sys.exit(1) - # Compute and emit full coverage line + # Compute and emit full coverage line — unified predicate draft_text = "" draft_path_for_cov = pathlib.Path(args.draft) if getattr(args, "draft", None) else state.spec_draft_path if draft_path_for_cov.exists(): @@ -2729,7 +2743,7 @@ def _drive_to_completeness_satisfied( draft_text = draft_path_for_cov.read_text(encoding="utf-8") except OSError: pass - cov = _compute_coverage(state, draft_text) + cov = _recommend_stop_predicate(state, draft_text) _status.emit( "result", "walker.coverage", answered=cov["answered"], @@ -2771,7 +2785,12 @@ def _drive_to_completeness_satisfied( draft_text = draft_path_cov.read_text(encoding="utf-8") except OSError: pass - cov = _compute_coverage(state, draft_text) + cov = _recommend_stop_predicate(state, draft_text) + # Persist refreshed pending so subsequent reads see the same view. + try: + persist(state, state_path) + except OSError: + pass # coverage is read-only-ish; persistence failure is non-fatal if args.json_mode: print(json.dumps(cov, indent=2)) diff --git a/tests/test_cross_view_gate_no_compatible_exception.py b/tests/test_cross_view_gate_no_compatible_exception.py new file mode 100644 index 0000000..8c6a1ee --- /dev/null +++ b/tests/test_cross_view_gate_no_compatible_exception.py @@ -0,0 +1,93 @@ +"""`excessive-post-ship-iteration` exception for zero-exemplar views (v1.2.1 #5). + +When a view's catalog has zero compatible exemplars, the operator is forced +to pick `post-ship-iteration` — penalizing that choice is wrong. The +deferral is tagged `reason="no-compatible-exemplar"` and the aggregate check +counts only `operator-deferral` deferrals. +""" +from bin import cross_view_gate, findings + + +def _deferral(section: str, reason: str) -> findings.Finding: + return cross_view_gate._emit_deferral_finding(section, reason=reason) + + +def test_112_two_no_compatible_deferrals_do_not_trigger_aggregate_warn(): + # Both views forced into post-ship-iteration due to empty catalog — + # operator had no choice; aggregate warn must NOT fire. + f1 = _deferral("9", "no-compatible-exemplar") + f2 = _deferral("10", "no-compatible-exemplar") + warns = cross_view_gate._check_excessive_post_ship_iteration([f1, f2]) + assert warns == [] + + +def test_113_two_operator_deferrals_trigger_aggregate_warn(): + # Both views had compatible exemplars available; operator chose to + # defer — aggregate warn fires as designed. + f1 = _deferral("9", "operator-deferral") + f2 = _deferral("10", "operator-deferral") + warns = cross_view_gate._check_excessive_post_ship_iteration([f1, f2]) + assert len(warns) == 1 + assert warns[0].kind == "excessive-post-ship-iteration" + + +def test_114_mixed_deferrals_count_only_operator_chosen(): + # One forced, two chosen — only the two operator-deferrals count. + f1 = _deferral("9", "no-compatible-exemplar") + f2 = _deferral("10", "operator-deferral") + f3 = _deferral("11", "operator-deferral") + warns = cross_view_gate._check_excessive_post_ship_iteration([f1, f2, f3]) + assert len(warns) == 1 + + +def test_115_no_compatible_finding_carries_catalog_contribution_hint(): + f = _deferral("9", "no-compatible-exemplar") + assert f.reason == "no-compatible-exemplar" + assert "contributing an exemplar" in (f.suggested_fix or "") + + +def test_116_operator_deferral_finding_carries_catalog_install_hint(): + f = _deferral("9", "operator-deferral") + assert f.reason == "operator-deferral" + assert "~/.spectre/exemplars/" in (f.suggested_fix or "") + + +# ── End-to-end: gui-only fingerprint forces deferral, no excessive warn ───── + +def test_117_gui_only_fingerprint_emits_no_compatible_deferral_no_aggregate_warn( + tmp_path, +): + """Spec where §8.5 uses gui-only but help-text catalog has only cli-* + exemplars. The §11 deferral is forced (no-compatible-exemplar) and the + aggregate warn does not fire even with two deferred views.""" + from bin import _catalog, cross_view_gate + # Reset catalog cache so the live plugin catalog is loaded. + _catalog._LOAD_CACHE = None + body = ( + "# X\n\n**Generated:** 2026-05-15\n**Slug:** x\n**Spec-version:** 1.0\n\n" + "## 8. Receiver Calibration\n\n" + "### 8.1 Hard contract\n" + "- mutates: /tmp/out\n" + "- never-touches: /etc\n" + "- decision-budget: none\n" + "- reboot-survival: none\n\n" + "### 8.5 Human-user substrate\n" + "- receiver-fingerprint: gui-only\n\n" + "### 8.7 Operator substrate\n" + "- receiver-fingerprint: self-operated\n\n" + "## 11. Human-User View\n\n" + "### Exemplar bindings\n" + "- help-text-style: post-ship-iteration\n\n" + "## 13. Operator View\n\n" + "### Exemplar bindings\n" + "- log-format-style: post-ship-iteration\n" + ) + spec = tmp_path / "x.spec.md" + spec.write_text(body, encoding="utf-8") + findings = cross_view_gate.classify(spec) + deferrals = [f for f in findings if f.kind == "post-ship-iteration-deferral"] + excessive = [f for f in findings if f.kind == "excessive-post-ship-iteration"] + no_compat = [d for d in deferrals if d.reason == "no-compatible-exemplar"] + assert len(deferrals) == 2 + assert len(no_compat) >= 1, "gui-only fingerprint must produce a no-compatible-exemplar deferral" + assert excessive == [], "deferrals forced by empty catalog must not trigger excessive warn" diff --git a/tests/test_eval_sidecar_substitutions.py b/tests/test_eval_sidecar_substitutions.py new file mode 100644 index 0000000..f0ab598 --- /dev/null +++ b/tests/test_eval_sidecar_substitutions.py @@ -0,0 +1,113 @@ +"""Eval sidecar substitution log (v1.2.1 #6). + +`write_sidecar(substitutions=...)` writes a `substitutions: [...]` array to +the sidecar payload as contemporaneous evidence of agent rewrites that +satisfied Tier-1 checks. Empty when no rewrite happened; populated when +rewrites did happen. Not a finding — auditable evidence. +""" +import json +import pathlib + +from bin import eval_metadata + + +def test_118_substitutions_omitted_when_none(tmp_path: pathlib.Path): + spec = tmp_path / "x.spec.md" + spec.write_text("# x\n", encoding="utf-8") + returned = eval_metadata.write_sidecar( + spec, + evaluator_version="1.0.0", + tiers_run=[1], + findings=[], + dismissals=[], + config_path=None, + config_hash=None, + deepseek_model_version=None, + policy_hash="abc123", + substitutions=None, + ) + assert returned.exists() and returned.name == "x.spec.md.eval.json" + payload = json.loads(returned.read_text(encoding="utf-8")) + assert "substitutions" not in payload + + +def test_119_substitutions_empty_list_written_as_empty(tmp_path: pathlib.Path): + spec = tmp_path / "x.spec.md" + spec.write_text("# x\n", encoding="utf-8") + returned = eval_metadata.write_sidecar( + spec, + evaluator_version="1.0.0", + tiers_run=[1], + findings=[], + dismissals=[], + config_path=None, + config_hash=None, + deepseek_model_version=None, + policy_hash="abc123", + substitutions=[], + ) + assert returned.exists() + payload = json.loads(returned.read_text(encoding="utf-8")) + assert payload["substitutions"] == [] + + +def test_120_substitutions_populated_round_trips(tmp_path: pathlib.Path): + spec = tmp_path / "x.spec.md" + spec.write_text("# x\n", encoding="utf-8") + entry = { + "from": "python3 -c 'import x'", + "to": "pip show x | grep -q 'Name: x'", + "reason": "Tier-1 _SHELL_EVAL_RE false-positive workaround", + "tier1_check_name": "untrusted-flow-unguarded", + "step_id": "step-3", + } + returned = eval_metadata.write_sidecar( + spec, + evaluator_version="1.0.0", + tiers_run=[1], + findings=[], + dismissals=[], + config_path=None, + config_hash=None, + deepseek_model_version=None, + policy_hash="abc123", + substitutions=[entry], + ) + assert returned.exists() + payload = json.loads(returned.read_text(encoding="utf-8")) + assert payload["substitutions"] == [entry] + + +def test_121_substitutions_multiple_entries_preserved_in_order( + tmp_path: pathlib.Path, +): + spec = tmp_path / "x.spec.md" + spec.write_text("# x\n", encoding="utf-8") + entries = [ + { + "from": "tee /etc/x", "to": "tee state/x", + "reason": "out-of-root", "tier1_check_name": "self-cycle-produces", + "step_id": "step-1", + }, + { + "from": "bash -c 'echo'", "to": "echo hi", + "reason": "simplify", "tier1_check_name": "soft-verification", + "step_id": "step-2", + }, + ] + returned = eval_metadata.write_sidecar( + spec, + evaluator_version="1.0.0", + tiers_run=[1], + findings=[], + dismissals=[], + config_path=None, + config_hash=None, + deepseek_model_version=None, + policy_hash="abc123", + substitutions=entries, + ) + assert returned.exists() + payload = json.loads(returned.read_text(encoding="utf-8")) + assert payload["substitutions"] == entries + assert len(payload["substitutions"]) == 2 diff --git a/tests/test_spec_ast_relative_authoring.py b/tests/test_spec_ast_relative_authoring.py new file mode 100644 index 0000000..fff8fce --- /dev/null +++ b/tests/test_spec_ast_relative_authoring.py @@ -0,0 +1,130 @@ +"""Relative-path authoring + workspace-boundary guard for spec_ast (v1.2.1 #3). + +`_action_authored_path` now recognizes relative paths (when a project root is +supplied) and the `touch` authoring verb. Paths that resolve outside the +project root — `../etc/passwd`, absolute paths to system locations, symlink +escapes — are not considered authored, so `self-cycle-produces` keeps firing +for them. +""" +import pathlib + +import pytest + +from bin import spec_ast + + +@pytest.fixture +def project_root(tmp_path: pathlib.Path) -> pathlib.Path: + (tmp_path / "schemas").mkdir() + (tmp_path / "state").mkdir() + return tmp_path + + +# ── Relative-path authoring (was rejected pre-v1.2.1) ──────────────────────── + +def test_99_tee_relative_path_is_authored(project_root: pathlib.Path): + paths = spec_ast._action_authored_path( + "tee schemas/x.json", project_root=project_root + ) + assert "schemas/x.json" in paths + + +def test_100_touch_relative_path_is_authored(project_root: pathlib.Path): + paths = spec_ast._action_authored_path( + "touch state/cookie.json", project_root=project_root + ) + assert "state/cookie.json" in paths + + +def test_101_redirect_relative_path_is_authored(project_root: pathlib.Path): + paths = spec_ast._action_authored_path( + "echo hello > state/log.txt", project_root=project_root + ) + assert "state/log.txt" in paths + + +# ── Boundary guard: out-of-root paths must NOT be cleared ─────────────────── + +def test_102_dotdot_escape_not_authored(project_root: pathlib.Path): + paths = spec_ast._action_authored_path( + "touch ../etc/passwd", project_root=project_root + ) + assert paths == [] + + +def test_103_absolute_outside_root_not_authored(project_root: pathlib.Path): + paths = spec_ast._action_authored_path( + "tee /etc/spectre.conf", project_root=project_root + ) + assert paths == [] + + +def test_104_absolute_inside_root_authored(project_root: pathlib.Path): + target = project_root / "schemas" / "y.json" + paths = spec_ast._action_authored_path( + f"tee {target}", project_root=project_root + ) + assert str(target) in paths + + +# ── Backward compatibility: None project_root keeps today's behavior ──────── + +def test_105_no_project_root_only_abs_paths_authored(): + paths = spec_ast._action_authored_path("tee schemas/x.json") + assert paths == [] # relative path unrecognized without a root + + +def test_106_no_project_root_abs_path_authored(): + paths = spec_ast._action_authored_path("tee /tmp/x.json") + assert "/tmp/x.json" in paths + + +# ── Touch with multiple paths ─────────────────────────────────────────────── + +def test_107_touch_multi_path_all_authored(project_root: pathlib.Path): + paths = spec_ast._action_authored_path( + "touch state/a.json state/b.json", project_root=project_root + ) + assert "state/a.json" in paths + assert "state/b.json" in paths + + +# ── classify() integration: relative authoring clears self-cycle finding ──── + +def test_108_classify_with_project_root_clears_self_cycle( + project_root: pathlib.Path, tmp_path: pathlib.Path +): + # A two-step spec where step 1's action authors a relative path via + # `tee` and step 2 references that path. Without the project_root, + # step 1's authoring goes unrecognized and `self-cycle-produces` may + # fire when step 1's produces: file matches its own action. + spec = tmp_path / "spec.md" + spec.write_text( + "# Test Spec\n" + "**Slug:** test-spec\n" + "## 1. Hard Problem\nfoo\n" + "## 2. First Principles\n- foo\n" + "## 3. Algorithm Audit\n- Delete: none\n- Simplify: none\n- Accelerate: none\n" + "## 4. Speed-of-Light Limit\nfoo\n" + "## 5. Physics Guardrails\n- foo\n" + "## 6. Steps\n\n" + "```yaml\n" + "- step: 1\n" + " why: seed config\n" + " action: tee schemas/x.json\n" + " verification: test -f schemas/x.json\n" + " produces: [file:schemas/x.json]\n" + " requires: []\n" + "```\n\n" + "## 7. Success Criteria\n- [ ] done\n\n" + "## 8. Receiver Calibration\n\n" + "### 8.1 Hard contract\n\n" + "- mutates: schemas/x.json\n" + "- never-touches: /etc\n" + "- decision-budget: none\n" + "- reboot-survival: none\n", + encoding="utf-8", + ) + findings = spec_ast.classify(spec, project_root=project_root) + self_cycles = [f for f in findings if f.kind == "self-cycle-produces"] + assert self_cycles == [] diff --git a/tests/test_substrate_ast_lexical_filtering.py b/tests/test_substrate_ast_lexical_filtering.py new file mode 100644 index 0000000..0fa0fe6 --- /dev/null +++ b/tests/test_substrate_ast_lexical_filtering.py @@ -0,0 +1,133 @@ +"""Lexical-context filtering for substrate_ast sink detection (v1.2.1 #1+#2). + +`_classify_and_strip_literals` masks inert quoted spans (JSON bodies, log +strings, argv elements) before sink regexes scan, while keeping the body of +recognized interpreter `-c` / `-e` payloads verbatim so live code still +fires. `_SQL_RE` also tightened so method-call positions (`hashlib.update`, +`os.replace`, `.replace`) no longer match. +""" +from bin import substrate_ast + + +# ── 6 false-positives from v1.2 dogfood (must NOT fire) ────────────────────── + +def test_81_hashlib_update_method_call_no_sql_sink(): + assert substrate_ast._sink_kinds_from_action( + "python3 -m mytool hash --algo sha256 # uses hashlib.update() internally" + ) == [] + + +def test_82_os_replace_method_call_no_sql_sink(): + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action( + "python3 -c 'import os; os.replace(src, dst)'" + ) + + +def test_83_errors_replace_kwarg_no_sql_sink(): + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action( + 'open(path, encoding="utf-8", errors="replace")' + ) + + +def test_84_ts_dot_replace_no_sql_sink(): + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action( + 'const x = "a".replace("b", "c");' + ) + + +def test_85_dollar_paren_inside_json_string_no_shell_sink(): + assert "shell-eval" not in substrate_ast._sink_kinds_from_action( + 'echo \'{"title": "$(book)", "id": 42}\' > out.json' + ) + + +def test_86_python3_dash_c_payload_strings_masked_no_sql_sink(): + # python3 -c 'INSERT INTO …' inside a literal would fire SQL today even + # though it is the verb in a docstring; the kept-span policy means we + # still see the body, but the SQL-shape requirement prevents prose + # collisions. A live `INSERT INTO` inside the payload is a real sink and + # is expected to fire (covered separately). + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action( + "python3 -c 'print(\"this update is benign\")'" + ) + + +# ── 8 true-positives that MUST still fire ──────────────────────────────────── + +def test_87_live_bash_dash_c_with_sql_fires(): + sinks = substrate_ast._sink_kinds_from_action( + 'bash -c "DELETE FROM users WHERE id=1"' + ) + assert "shell-eval" in sinks + assert "sql-or-template" in sinks + + +def test_88_live_psql_dash_c_with_insert_fires(): + sinks = substrate_ast._sink_kinds_from_action( + 'psql -c "INSERT INTO t VALUES (1)"' + ) + assert "sql-or-template" in sinks + + +def test_89_live_sh_dash_c_with_curl_fires(): + sinks = substrate_ast._sink_kinds_from_action( + 'sh -c "$(curl -fsSL https://example.com/install.sh)"' + ) + assert "shell-eval" in sinks + + +def test_90_python3_dash_c_with_var_interpolation_fires(): + sinks = substrate_ast._sink_kinds_from_action( + 'python3 -c "$USER_INPUT"' + ) + assert "shell-eval" in sinks + + +def test_91_node_dash_e_payload_fires_shell_eval(): + sinks = substrate_ast._sink_kinds_from_action( + 'node -e "require(\'fs\').writeFileSync(p, body)"' + ) + assert "shell-eval" in sinks + + +def test_92_live_insert_top_level_fires(): + sinks = substrate_ast._sink_kinds_from_action( + "INSERT INTO audit (event) VALUES (?)" + ) + assert "sql-or-template" in sinks + + +def test_93_live_dollar_paren_subshell_fires(): + sinks = substrate_ast._sink_kinds_from_action( + "tar -czf /tmp/out.tar.gz $(find . -name '*.log')" + ) + assert "shell-eval" in sinks + + +def test_94_unbalanced_quote_conservative_fallback(): + action = 'bash -c "DELETE FROM x' # unterminated literal + sinks = substrate_ast._sink_kinds_from_action(action) + assert "shell-eval" in sinks # `bash -c` still fires + assert "sql-or-template" in sinks # falls back to today's scan; SQL fires + + +# ── 4 boundary cases ───────────────────────────────────────────────────────── + +def test_95_triple_quoted_multiline_literal_masked(): + action = '''python3 myscript.py --readme """contains DELETE FROM in docs"""''' + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action) + + +def test_96_escaped_quote_inside_literal_handled(): + action = r'echo "he said \"INSERT\" but meant push"' + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action) + + +def test_97_mixed_shell_quoting_inert_kept_inert(): + action = """echo '"DELETE FROM logs"'""" + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action) + + +def test_98_comment_containing_update_no_sink(): + action = "echo hello # call update on the index later" + assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action) diff --git a/tests/test_supervisor_operator_mode.py b/tests/test_supervisor_operator_mode.py new file mode 100644 index 0000000..29a998c --- /dev/null +++ b/tests/test_supervisor_operator_mode.py @@ -0,0 +1,51 @@ +"""Supervisor lock state carries operator_mode (v1.2.1 #7). + +Each lock entry records `operator_mode: "interactive" | "auto"` so downstream +audit/evidence can distinguish operator-driven locks from /implement auto-mode +locks. +""" +import json +import pathlib + +import pytest + +from bin import supervisor + + +def test_122_acquire_defaults_to_interactive_mode(tmp_path: pathlib.Path): + state = supervisor.LockState(tmp_path / ".locks.json") + state.register_resource("port:8080", capacity=1) + granted = state.acquire( + "port:8080", track="auth", actor_pid=12345, actor_start_time=1000.0 + ) + assert granted is True + payload = json.loads((tmp_path / ".locks.json").read_text()) + assert payload["locks"][0]["operator_mode"] == "interactive" + + +def test_123_acquire_records_auto_mode_when_passed(tmp_path: pathlib.Path): + state = supervisor.LockState(tmp_path / ".locks.json") + state.register_resource("port:8080", capacity=1) + granted = state.acquire( + "port:8080", + track="auth", + actor_pid=12345, + actor_start_time=1000.0, + operator_mode="auto", + ) + assert granted is True + payload = json.loads((tmp_path / ".locks.json").read_text()) + assert payload["locks"][0]["operator_mode"] == "auto" + + +def test_124_acquire_raises_on_unknown_operator_mode(tmp_path: pathlib.Path): + state = supervisor.LockState(tmp_path / ".locks.json") + state.register_resource("port:8080", capacity=1) + with pytest.raises(ValueError, match="operator_mode"): + state.acquire( + "port:8080", + track="auth", + actor_pid=12345, + actor_start_time=1000.0, + operator_mode="unsupervised", + ) diff --git a/tests/test_v1_1_e2e.py b/tests/test_v1_1_e2e.py index 91b9b56..b07046f 100644 --- a/tests/test_v1_1_e2e.py +++ b/tests/test_v1_1_e2e.py @@ -218,10 +218,19 @@ def test_v1_1_acceptance_synthetic_spec(tmp_path: pathlib.Path) -> None: assert "view-fingerprint-contradicts-exemplar-binding" in kinds # Fix 2: two post-ship-iteration deferrals (§9 and §12) - assert sum(1 for f in all_findings if f.kind == "post-ship-iteration-deferral") == 2 - - # Fix 2 aggregator: excessive-post-ship-iteration fires when count > 1 - assert "excessive-post-ship-iteration" in kinds + deferrals = [f for f in all_findings if f.kind == "post-ship-iteration-deferral"] + assert len(deferrals) == 2 + + # v1.2.1 #5: §9 (help-text placeholder catalog) has no exemplar compatible + # with `human-typed` fingerprint → tagged `no-compatible-exemplar`. + # §12 (api-shape) has exemplars compatible with `api-consumer` → + # tagged `operator-deferral`. The aggregate warn counts only the + # operator-deferral subset, so with one operator-deferral the warn + # does NOT fire (≤ 1 threshold). + by_reason = {d.reason for d in deferrals} + assert "no-compatible-exemplar" in by_reason + assert "operator-deferral" in by_reason + assert "excessive-post-ship-iteration" not in kinds # Fix 3: behavioral why + structural-only verification on step 2 assert "verification-too-shallow-for-claim" in kinds diff --git a/tests/test_walker_no_compatible_exemplar.py b/tests/test_walker_no_compatible_exemplar.py index 4967b92..0fe3c69 100644 --- a/tests/test_walker_no_compatible_exemplar.py +++ b/tests/test_walker_no_compatible_exemplar.py @@ -162,14 +162,19 @@ def test_single_deferral_emits_info(tmp_path, monkeypatch): def test_two_deferrals_emits_excessive_warn(tmp_path, monkeypatch): """Spec with two views bound to post-ship-iteration → two info findings - PLUS one excessive-post-ship-iteration warn finding.""" + PLUS one excessive-post-ship-iteration warn finding. + + v1.2.1 #5: fingerprints chosen so the catalog has compatible exemplars, + making both deferrals operator-chosen (not catalog-forced). The aggregate + warn only fires for operator-chosen deferrals. + """ monkeypatch.setattr(_catalog, "_LOAD_CACHE", None) spec = _write_spec( tmp_path, extra_substrate=( "### 8.5 Human-user substrate\n" - "- receiver-fingerprint: gui-only\n\n" + "- receiver-fingerprint: cli-power-user\n\n" "### 8.7 Operator substrate\n" "- receiver-fingerprint: on-call-engineer\n\n" ), @@ -210,11 +215,15 @@ def test_prefixed_sentinel_emits_deferral_not_exemplar_not_found(tmp_path, monke """ monkeypatch.setattr(_catalog, "_LOAD_CACHE", None) + # v1.2.1 #5: fingerprints chosen so compatible exemplars EXIST in the + # catalog (cli-power-user for help-text; on-call-engineer for log-format). + # The deferral is therefore an operator choice, not catalog-forced — + # `excessive-post-ship-iteration` aggregate warn fires as designed. spec = _write_spec( tmp_path, extra_substrate=( "### 8.5 Human-user substrate\n" - "- receiver-fingerprint: gui-only\n\n" + "- receiver-fingerprint: cli-power-user\n\n" "### 8.7 Operator substrate\n" "- receiver-fingerprint: on-call-engineer\n\n" ), @@ -237,5 +246,7 @@ def test_prefixed_sentinel_emits_deferral_not_exemplar_not_found(tmp_path, monke assert len(deferral) == 2, f"expected 2 deferral findings, got {len(deferral)}" assert all(f.severity == "info" for f in deferral) assert all(f.tier == 2 for f in deferral) - assert len(excessive) == 1, "two deferrals must trigger excessive-post-ship-iteration warn" + assert all(f.reason == "operator-deferral" for f in deferral), \ + "compatible-exemplar deferrals must be tagged operator-deferral" + assert len(excessive) == 1, "two operator-chosen deferrals must trigger excessive warn" assert excessive[0].severity == "warn" diff --git a/tests/test_walker_stop_predicate_consistency.py b/tests/test_walker_stop_predicate_consistency.py new file mode 100644 index 0000000..fb0f5aa --- /dev/null +++ b/tests/test_walker_stop_predicate_consistency.py @@ -0,0 +1,62 @@ +"""Walker stop predicate consistency (v1.2.1 #4). + +`_recommend_stop_predicate` is the single source of truth for the walker +stop signal. Every code path that reports or emits ``recommended_stop`` must +go through it, so the post-answer view and the explicit ``walker coverage`` +subcommand cannot disagree. +""" +import pathlib + +from bin import walker + + +def _fully_satisfied_state() -> walker.WalkState: + """A walk state with every gating flag flipped on and no pending concerns.""" + return walker.WalkState( + spec_intent="x", + spec_draft_path=pathlib.Path("specs/x.spec.md.draft"), + lifecycle_asked=True, + prompt_design_asked=True, + semantic_criteria_asked=True, + product_input_asked=True, + product_output_asked=True, + human_user_asked=True, + integrator_asked=True, + operator_asked=True, + ) + + +def test_109_predicate_recommends_stop_when_all_gates_pass(): + state = _fully_satisfied_state() + cov = walker._recommend_stop_predicate(state, draft_text="") + assert cov["recommended_stop"] is True + assert cov["pending"] == 0 + assert cov["deferred"] == 0 + + +def test_110_predicate_blocks_stop_when_pending_remains(): + state = _fully_satisfied_state() + state.pending.append( + walker.Concern( + id="dummy-1", + kind="edge-case", + receivers=["implement"], + depends_on=[], + summary="x", + ) + ) + cov = walker._recommend_stop_predicate(state, draft_text="") + assert cov["recommended_stop"] is False + assert cov["pending"] >= 1 + + +def test_111_predicate_runs_refresh_pending_so_post_answer_view_agrees(): + # Both call sites (post-answer + walker coverage) must call the predicate; + # this test pins that the predicate is the single point where + # `_refresh_pending` fires before coverage is computed. + state = _fully_satisfied_state() + cov1 = walker._recommend_stop_predicate(state, draft_text="") + cov2 = walker._recommend_stop_predicate(state, draft_text="") + # Idempotent: re-running on the same state produces the same view. + assert cov1["recommended_stop"] == cov2["recommended_stop"] + assert cov1["pending"] == cov2["pending"]