diff --git a/.claude/skills/verify/INSTRUCTIONS.md b/.claude/skills/verify/INSTRUCTIONS.md index b8cbff2..ab34d3e 100644 --- a/.claude/skills/verify/INSTRUCTIONS.md +++ b/.claude/skills/verify/INSTRUCTIONS.md @@ -49,13 +49,16 @@ Run in parallel: | Format | `ruff format --check mempalace_code/ tests/ scripts/` | 30s | | Tests | `python -m pytest tests/ -x -q -m "not needs_network"` | 120s | | Typecheck | `python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')"` | 120s | +| Strict slice typecheck | `python -m pyright -p pyrightconfig.strict.json` | 60s | +| Public safety | `python scripts/public_safety_scan.py --tracked --staged` | 30s | | Scorecard | `python scripts/quality_scorecard.py --check` | 30s | The scorecard check is stdlib-only (no install, no network) and validates the -quality scorecard's shape, determinism, and public-safety. It fails on malformed -or unsafe output. After a quality change lands, regenerate the committed -artifacts with `python scripts/quality_scorecard.py --write` (see -`docs/quality/README.md`). +quality scorecard's shape, determinism, public-safety, and committed artifact +freshness. The public-safety scan checks tracked and staged repository files for +private local paths, secret-like tokens, and local-only raw artifacts. After a +quality change lands, regenerate the committed artifacts with +`python scripts/quality_scorecard.py --write` (see `docs/quality/README.md`). ### If storage changed — add these diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4276d1d..8978ce4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,8 @@ jobs: - run: pip install ruff - run: ruff check mempalace_code/ tests/ scripts/ - run: ruff format --check mempalace_code/ tests/ scripts/ + - name: Public-safety scan (tracked + staged) + run: python scripts/public_safety_scan.py --tracked --staged - name: Quality scorecard (shape + determinism + public-safety) run: python scripts/quality_scorecard.py --check @@ -78,6 +80,8 @@ jobs: - run: pip install -e ".[dev,chroma,spellcheck,treesitter]" - name: Pyright typecheck run: python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" + - name: Pyright strict slice + run: python -m pyright -p pyrightconfig.strict.json model-tests: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7fd6bdd..87e33d9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ palace/ .venv/ venv/ .env +.verify-state # IDE .idea/ diff --git a/.verify-state b/.verify-state deleted file mode 100644 index fc99940..0000000 --- a/.verify-state +++ /dev/null @@ -1 +0,0 @@ -4e22621d37de6102c00947db71ebedd5319ec5da diff --git a/docs/BACKLOG.yaml b/docs/BACKLOG.yaml index 1cd8507..dba8dcb 100644 --- a/docs/BACKLOG.yaml +++ b/docs/BACKLOG.yaml @@ -80,9 +80,21 @@ items: - CI or `/verify` runs the scorecard command and fails on malformed output. resolution: "2026-06-05: Deterministic public scorecard: scripts/quality_scorecard.py emits Markdown+JSON; --check gates shape/determinism/public-safety in CI lint job and /verify; baseline committed to docs/quality/; update workflow in docs/quality/README.md" done_summary: "Deterministic public scorecard: scripts/quality_scorecard.py emits Markdown+JSON; --check gates shape/determinism/public-safety in CI lint job and /verify; baseline committed to docs/quality/; update workflow in docs/quality/README.md" + - key: AUTOPILOT-DEMO-PUBLIC-SAFETY-GATE + section: autopilot_demo + status: done + priority: P1 + summary: Add a repo-wide public-safety gate for tracked and staged files. + acceptance: + - Add a stdlib scanner that checks tracked worktree files and staged index blobs. + - Block secret-like tokens, real local machine paths, and local-only artifact paths without printing the matched secret text. + - Wire the scanner into CI and `/verify`. + - Remove or ignore any local-only artifact that is already tracked. + resolution: "2026-06-06: Added scripts/public_safety_scan.py --tracked --staged, CI and /verify wiring, focused tests, and removed tracked .verify-state while adding it to .gitignore." + done_summary: "Repo-wide public-safety scan now gates tracked/staged files and redacts matched content; .verify-state is local-only." - key: AUTOPILOT-DEMO-RUFF-RATCHET section: autopilot_demo - status: open + status: done priority: P1 summary: Reduce transitional Ruff ignores in a measurable ratchet without broad style-only churn. acceptance: @@ -90,9 +102,11 @@ items: - Remove at least one global ignore family or reduce per-file ignore entries by at least 25% for a focused module group. - Replace any necessary inline suppressions with reasoned, narrow suppressions; no new blanket `noqa` entries. - "`ruff check`, `ruff format --check`, Pyright, and the relevant focused tests pass; update the quality scorecard." + resolution: "2026-06-06: Reduced global Ruff ignores from 33 to 3 by keeping existing package/test debt scoped to per-file ignores and making new scripts inherit the stricter rule set." + done_summary: "Ruff global ignore ratchet: broad historical ignores are now scoped away from scripts; no new inline suppressions." - key: AUTOPILOT-DEMO-PYRIGHT-STRICT-SLICE section: autopilot_demo - status: open + status: done priority: P1 summary: Establish a strict Pyright slice for stable low-level modules and expand it gradually. acceptance: @@ -100,6 +114,30 @@ items: - Replace ad hoc `Any` and broad dictionaries in that slice with typed contracts where the code owns the boundary. - Keep compatibility shims and third-party stub gaps outside the first strict slice unless they can be fixed narrowly. - CI or `/verify` runs both the existing basic Pyright check and the strict-slice check; update the quality scorecard. + resolution: "2026-06-06: Added pyrightconfig.strict.json for version.py, mcp_tool_profiles.py, and disk_budget.py; annotated disk_budget.py enough to pass strict; wired strict slice into CI and /verify." + done_summary: "Initial strict Pyright slice gates three stable low-level modules in CI and /verify." + - key: AUTOPILOT-DEMO-PYRIGHT-STRICT-SLICE-EXPANSION + section: autopilot_demo + status: open + priority: P1 + summary: Expand the strict Pyright slice to config, reader, and mining scanner after adding typed boundaries. + acceptance: + - Add `mempalace_code/config.py`, `mempalace_code/reader.py`, and `mempalace_code/mining/scanner.py` to `pyrightconfig.strict.json`. + - Introduce typed config payload aliases, reader result types, gitignore rule types, and scan filter generics instead of broad dict/list shapes. + - Keep the existing basic Pyright gate green throughout. + - Update the quality scorecard and report the strict-slice expansion publicly. + - key: AUTOPILOT-DEMO-WORKFLOW-REVIEW-PROTOCOL + section: autopilot_demo + status: done + priority: P1 + summary: Document the public-safe adversarial Claude workflow protocol for repo quality work. + acceptance: + - Define review lenses, skeptical refutation, synthesis, implementation, and verification steps. + - Separate publishable summaries from local-only raw workflow artifacts. + - Link the protocol from quality docs. + - Include the canonical quality verification commands. + resolution: "2026-06-06: Added docs/quality/workflow-review-protocol.md and linked it from docs/quality/README.md." + done_summary: "Public-safe multi-agent workflow review protocol is documented for future quality improvements." - key: AUTOPILOT-DEMO-ARCHITECTURE-GUARD section: autopilot_demo status: open diff --git a/docs/quality/README.md b/docs/quality/README.md index 11ce1ba..219ac2d 100644 --- a/docs/quality/README.md +++ b/docs/quality/README.md @@ -10,6 +10,7 @@ before/after evidence instead of becoming invisible churn. |------|---------| | [`scorecard.md`](scorecard.md) | Human-readable baseline (committed). | | [`scorecard.json`](scorecard.json) | Machine-readable baseline (committed). | +| [`workflow-review-protocol.md`](workflow-review-protocol.md) | Public-safe multi-agent review protocol for quality work. | Both are generated by [`scripts/quality_scorecard.py`](../../scripts/quality_scorecard.py). @@ -24,8 +25,11 @@ python scripts/quality_scorecard.py --format both # both # Regenerate the committed artifacts under docs/quality/ python scripts/quality_scorecard.py --write -# Validate shape, determinism, and public-safety (CI / verify gate) +# Validate shape, determinism, public-safety, and committed freshness (CI / verify gate) python scripts/quality_scorecard.py --check + +# Scan tracked/staged repository files for publish-safety leaks +python scripts/public_safety_scan.py --tracked --staged ``` The script uses **only the Python standard library** (no project import, no @@ -54,11 +58,12 @@ absolute paths, no machine identifiers: ## Determinism & public-safety `--check` builds the scorecard twice and fails if the JSON differs, validates -the output shape (types, required keys, sort order, invariants), and runs a +the output shape (types, required keys, sort order, invariants), checks that the +committed Markdown/JSON artifacts are fresh, and runs a rendered-output public-safety scan for private paths and secret-like tokens. CI (`lint` job) and -`/verify` run `--check` so malformed or unsafe output fails the build. Because -`--check` validates *shape and determinism* (not exact counts), normal code -growth does not break CI — only a malformed scorecard does. +`/verify` also run `scripts/public_safety_scan.py --tracked --staged`, which +checks the repository files themselves for real local paths, secret-like tokens, +and local-only artifact paths. ## How Autopilot demo tasks update this scorecard @@ -79,3 +84,7 @@ delta here: Run `python scripts/quality_scorecard.py --check` before committing so the regenerated artifacts stay well-formed and public-safe. + +For adversarial multi-agent review of a quality change, use +[`workflow-review-protocol.md`](workflow-review-protocol.md) and publish only +the sanitized synthesis and verification evidence. diff --git a/docs/quality/scorecard.json b/docs/quality/scorecard.json index 6915d64..59bdc63 100644 --- a/docs/quality/scorecard.json +++ b/docs/quality/scorecard.json @@ -2,9 +2,9 @@ "code_size": { "package_code_lines": 18978, "package_files": 73, - "package_total_lines": 23441, - "test_files": 53, - "test_total_lines": 39096 + "package_total_lines": 23442, + "test_files": 54, + "test_total_lines": 39198 }, "largest_modules": [ { @@ -59,41 +59,11 @@ }, "ruff": { "global_ignore_rules": [ - "ARG001", - "ARG002", - "ARG005", "B007", - "B027", - "B033", - "B904", - "B905", - "DTZ001", - "DTZ005", - "DTZ011", "E501", - "PT001", - "PT006", - "PT017", - "RET504", - "RET505", - "RET508", - "SIM102", - "SIM103", - "SIM105", - "SIM108", - "SIM114", - "SIM115", - "SIM116", - "SIM117", - "UP006", - "UP015", - "UP024", - "UP028", - "UP035", - "UP036", - "UP045" + "SIM105" ], - "global_ignores": 33, + "global_ignores": 3, "per_file_ignores": { "by_pattern": [ { @@ -178,13 +148,13 @@ "mempalace_code", "tests" ], - "type_pyright_total": 108, + "type_pyright_total": 110, "type_pyright_unreasoned": 0, "unreasoned_total": 0 }, "tests": { - "test_files": 53, - "test_functions": 2259 + "test_files": 54, + "test_functions": 2266 }, "verification_commands": [ { @@ -203,6 +173,14 @@ "command": "python -m pyright --pythonpath \"$(python -c 'import sys; print(sys.executable)')\"", "name": "typecheck" }, + { + "command": "python -m pyright -p pyrightconfig.strict.json", + "name": "typecheck_strict_slice" + }, + { + "command": "python scripts/public_safety_scan.py --tracked --staged", + "name": "public_safety" + }, { "command": "python scripts/quality_scorecard.py --check", "name": "scorecard" diff --git a/docs/quality/scorecard.md b/docs/quality/scorecard.md index 8646ae4..81acdeb 100644 --- a/docs/quality/scorecard.md +++ b/docs/quality/scorecard.md @@ -9,10 +9,10 @@ Schema version: 1 | Metric | Value | |--------|------:| | Package files (`mempalace_code/`) | 73 | -| Package total lines | 23441 | +| Package total lines | 23442 | | Package code lines | 18978 | -| Test files (`tests/`) | 53 | -| Test total lines | 39096 | +| Test files (`tests/`) | 54 | +| Test total lines | 39198 | ## Largest Modules (top 10) @@ -34,7 +34,7 @@ Schema version: 1 | Metric | Value | |--------|------:| | Selected rule families | 12 | -| Global ignores | 33 | +| Global ignores | 3 | | Per-file ignore patterns | 2 | | Per-file ignore entries | 38 | @@ -58,7 +58,7 @@ Scope: `mempalace_code/`, `tests/` (excludes `tests/fixtures/`). | Metric | Value | |--------|------:| -| type/pyright ignores (total) | 108 | +| type/pyright ignores (total) | 110 | | type/pyright unreasoned | 0 | | noqa (total) | 41 | | noqa blanket | 0 | @@ -68,8 +68,8 @@ Scope: `mempalace_code/`, `tests/` (excludes `tests/fixtures/`). | Metric | Value | |--------|------:| -| Test files | 53 | -| Test functions | 2259 | +| Test files | 54 | +| Test functions | 2266 | ## Available Suites @@ -91,4 +91,6 @@ Scope: `mempalace_code/`, `tests/` (excludes `tests/fixtures/`). - **format**: `ruff format --check mempalace_code/ tests/ scripts/` - **tests**: `python -m pytest tests/ -x -q -m "not needs_network"` - **typecheck**: `python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')"` +- **typecheck_strict_slice**: `python -m pyright -p pyrightconfig.strict.json` +- **public_safety**: `python scripts/public_safety_scan.py --tracked --staged` - **scorecard**: `python scripts/quality_scorecard.py --check` diff --git a/docs/quality/workflow-review-protocol.md b/docs/quality/workflow-review-protocol.md new file mode 100644 index 0000000..105ed34 --- /dev/null +++ b/docs/quality/workflow-review-protocol.md @@ -0,0 +1,72 @@ +# Workflow Review Protocol + +Public-safe protocol for using a multi-agent Claude workflow to improve this +repo without publishing raw local evidence. + +## Trigger + +Use this after a focused implementation is ready for review, especially for +quality-gate, release, security, dependency, CI, or public-demo changes. + +## Inputs + +- Current branch diff against `main`. +- Relevant public files only: source, tests, public docs, CI config, package + metadata. +- Explicit task acceptance criteria and verification commands. +- Local-only evidence paths may be used by the operator, but must not be pasted + into public artifacts. + +## Lenses + +Run independent reviewers for these lenses: + +- Correctness: behavior, edge cases, failure modes. +- Determinism: stable output, ordering, caches, generated artifacts. +- Public-safety: secrets, private paths, local-only artifacts, publishable docs. +- Test coverage: real regression protection, not only happy-path assertions. +- Spec compliance: task acceptance criteria, repo rules, release boundaries. +- Maintainability: scoped design, duplication, future ratchets. + +## Refutation + +Each finding must pass a skeptical refutation step before implementation: + +- Check the exact code path and tests. +- Reject findings based only on style preference or imagined behavior. +- Keep only findings with a concrete file, behavior, or missing gate. +- Record deliberate deferrals separately from fixes. + +## Synthesis + +The synthesis lead deduplicates surviving findings into: + +- Implement now: actionable, low-ambiguity fixes in scope. +- Defer: valuable but separate backlog item. +- Reject: disproven or out-of-scope findings. + +The public summary may include counts and categories. Do not publish raw model +transcripts, private local paths, hostnames, tokens, or task workspace contents. + +## Implementation + +Apply only vetted, scoped fixes. Update tests and canonical gates first, then +update public docs/backlog/scorecard. Keep raw workflow outputs under ignored +local paths such as `.tasks/`, `.protocols/`, or `docs/audits/`. + +## Verification + +Minimum verification after acting on a workflow review: + +```bash +ruff check mempalace_code/ tests/ scripts/ +ruff format --check mempalace_code/ tests/ scripts/ +python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" +python -m pyright -p pyrightconfig.strict.json +python scripts/public_safety_scan.py --tracked --staged +python scripts/quality_scorecard.py --check +python -m pytest tests/ -x -q -m "not needs_network" +``` + +If hosted workflow behavior matters, verify the real GitHub Actions run before +calling the change published. diff --git a/mempalace_code/disk_budget.py b/mempalace_code/disk_budget.py index 888d6d2..a3198e4 100644 --- a/mempalace_code/disk_budget.py +++ b/mempalace_code/disk_budget.py @@ -4,11 +4,12 @@ checks, backup projection, DiskBudgetStatus, and DiskBudgetError. """ +from __future__ import annotations + import os import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional # Conservative default: require at least 1 GiB free before write-producing operations. DEFAULT_DISK_MIN_FREE_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB @@ -33,7 +34,7 @@ def total_footprint_bytes(self) -> int: return self.palace_bytes + self.backups_bytes -def parse_bytes(value) -> int: +def parse_bytes(value: object) -> int: """Parse an integer byte count from int, str, or str with optional suffix. Accepts: integers, digit strings, and human suffixes (KB, MB, GB, TB, @@ -111,7 +112,7 @@ def _dir_size(path: str) -> int: return total -def palace_footprint(palace_path: str) -> tuple: +def palace_footprint(palace_path: str) -> tuple[int, int]: """Return (palace_bytes, backups_bytes) for the palace and its sibling backups/ dir. Missing directories count as 0. Permission errors return 0 for that component. @@ -136,7 +137,7 @@ def free_bytes(path: str) -> int: def check_watch_budget( palace_path: str, min_free_bytes_threshold: int, -) -> "DiskBudgetStatus": +) -> DiskBudgetStatus: """Check whether the watcher is allowed to run under current disk conditions. Returns DiskBudgetStatus. allowed=True when free_bytes >= min_free_bytes_threshold. @@ -157,8 +158,8 @@ def check_backup_budget( palace_path: str, out_path: str, min_free_bytes_threshold: int, - kg_path: Optional[str] = None, -) -> "DiskBudgetStatus": + kg_path: str | None = None, +) -> DiskBudgetStatus: """Check whether creating a backup archive is safe given disk budget. Uses a conservative uncompressed estimate: palace directory size + KG size. diff --git a/pyproject.toml b/pyproject.toml index 957f02b..a2ea558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,44 +80,11 @@ select = ["E", "F", "W", "I", "UP", "B", "ARG", "DTZ", "PT", "RET", "SIM", "TCH" ignore = [ # Ruff format owns wrapping; avoid duplicate churn from the line-length lint. "E501", - # Transitional pyupgrade backlog: modern annotation/open-mode rewrites are - # mechanical but broad, so keep them out of the first strictness pass. - "UP006", - "UP015", - "UP024", - "UP028", - "UP035", - "UP036", - "UP045", - # Transitional maintainability backlog: current CLI/miner/storage code has - # several real refactor candidates that should be reviewed separately. + # Remaining repo-wide debt. Most historical ignores are now scoped below to + # the package/tests paths that still need cleanup, so new scripts do not + # inherit broad suppressions. "B007", - "B027", - "B033", - "B904", - "B905", - "DTZ001", - "DTZ005", - "DTZ011", - "RET504", - "RET505", - "RET508", - "SIM102", - "SIM103", "SIM105", - "SIM108", - "SIM114", - "SIM115", - "SIM116", - "SIM117", - # Pytest cleanup backlog: fixtures, callbacks, and patch side effects use - # framework-required signatures that need local underscore renames. - "ARG001", - "ARG002", - "ARG005", - "PT001", - "PT006", - "PT017", ] [tool.ruff.lint.per-file-ignores] diff --git a/pyrightconfig.strict.json b/pyrightconfig.strict.json new file mode 100644 index 0000000..a522d2a --- /dev/null +++ b/pyrightconfig.strict.json @@ -0,0 +1,16 @@ +{ + "include": [ + "mempalace_code/disk_budget.py", + "mempalace_code/mcp_tool_profiles.py", + "mempalace_code/version.py" + ], + "strict": [ + "mempalace_code/disk_budget.py", + "mempalace_code/mcp_tool_profiles.py", + "mempalace_code/version.py" + ], + "pythonVersion": "3.11", + "typeCheckingMode": "basic", + "reportMissingImports": true, + "reportMissingTypeStubs": false +} diff --git a/scripts/public_safety_scan.py b/scripts/public_safety_scan.py new file mode 100644 index 0000000..c21baf5 --- /dev/null +++ b/scripts/public_safety_scan.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Repository public-safety scan for publishable files. + +Checks tracked and staged text for private local paths, secret-like tokens, and +local-only artifact paths. Output is intentionally redacted: failures report the +rule id and file position, not the matched text. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +LOCAL_ONLY_PREFIXES = ( + ".tasks/", + ".protocols/", + ".verify-state", + ".codex-local/", + "docs/audits/", +) + +GENERIC_TEMP_ROOTS = frozenset({"/tmp", "/var/tmp", "/private/var/tmp"}) + + +@dataclass(frozen=True) +class PatternRule: + rule_id: str + pattern: re.Pattern[str] + + +@dataclass(frozen=True) +class PublicSafetyHit: + source: str + rule_id: str + line: int + column: int + + def summary(self) -> str: + return f"{self.source}:{self.line}:{self.column}: {self.rule_id}" + + +def _join_root(*parts: str) -> str: + return "".join(parts) + + +def _token_rules() -> list[PatternRule]: + return [ + PatternRule("github-token-prefix", re.compile(r"\b[g]hp_[A-Za-z0-9]{20,}")), + PatternRule("github-pat-prefix", re.compile(r"[g]ithub_pat_")), + PatternRule("pypi-token-prefix", re.compile(r"\b[p]ypi-[A-Za-z0-9_-]{20,}")), + PatternRule("openai-token-prefix", re.compile(r"\b[s]k-[A-Za-z0-9]{16,}")), + PatternRule("anthropic-token-prefix", re.compile(r"\b[s]k-ant-[A-Za-z0-9_-]{16,}")), + ] + + +def rendered_rules() -> list[PatternRule]: + """Rules for generated public artifacts that should contain no absolute roots.""" + roots = [ + ("macos-home-root", _join_root("/", "Users", "/")), + ("linux-home-root", _join_root("/", "home", "/")), + ("root-home-root", _join_root("/", "root", "/")), + ("service-root", _join_root("/", "srv", "/")), + ("opt-root", _join_root("/", "opt", "/")), + ("macos-temp-root", _join_root("/", "var", "/", "folders", "/")), + ("tmp-root", _join_root("/", "tmp", "/")), + ("windows-user-root", _join_root("C:", "\\", "Users", "\\")), + ] + return [PatternRule(rule_id, re.compile(re.escape(value))) for rule_id, value in roots] + ( + _token_rules() + ) + + +def repository_rules(repo_root: Path) -> list[PatternRule]: + """Rules for repo source files. + + Public examples can mention generic paths such as /tmp/example. What must not + land in the repo is this machine's actual home, temp, or checkout path. + """ + rules = _token_rules() + candidates: list[tuple[str, str]] = [] + + env_home = os.environ.get("HOME") + if env_home: + candidates.append(("local-home", env_home)) + for env_name in ("TMPDIR", "TMP", "TEMP", "USERPROFILE"): + env_val = os.environ.get(env_name) + if env_val: + candidates.append((f"local-env-{env_name.lower()}", env_val)) + candidates.extend( + [ + ("local-home", str(Path.home())), + ("local-temp", tempfile.gettempdir()), + ("repo-root", str(repo_root.resolve())), + ] + ) + + seen: set[str] = set() + for rule_id, raw_path in candidates: + normalized = Path(raw_path).expanduser().as_posix().rstrip("/") + if len(normalized) <= 3 or normalized in GENERIC_TEMP_ROOTS or normalized in seen: + continue + seen.add(normalized) + rules.append(PatternRule(rule_id, re.compile(re.escape(normalized) + r"(?:/|$)"))) + return rules + + +def scan_text(source: str, text: str, rules: list[PatternRule]) -> list[PublicSafetyHit]: + hits: list[PublicSafetyHit] = [] + seen_positions: set[tuple[str, int, int]] = set() + for rule in rules: + for match in rule.pattern.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + last_newline = text.rfind("\n", 0, match.start()) + column = match.start() + 1 if last_newline < 0 else match.start() - last_newline + location = (source, line, column) + if location in seen_positions: + continue + seen_positions.add(location) + hits.append(PublicSafetyHit(source, rule.rule_id, line, column)) + return hits + + +def scan_bytes(source: str, content: bytes, rules: list[PatternRule]) -> list[PublicSafetyHit]: + text = content.decode("utf-8", errors="ignore") + return scan_text(source, text, rules) + + +def scan_rendered_texts(*texts: str) -> list[str]: + hits: list[PublicSafetyHit] = [] + for idx, text in enumerate(texts, start=1): + hits.extend(scan_text(f"rendered:{idx}", text, rendered_rules())) + return sorted({hit.summary() for hit in hits}) + + +def _git(root: Path, args: list[str]) -> bytes: + return subprocess.check_output(["git", *args], cwd=root) + + +def _split_z(output: bytes) -> list[str]: + return [p.decode("utf-8", errors="surrogateescape") for p in output.split(b"\0") if p] + + +def tracked_paths(root: Path) -> list[str]: + return sorted(_split_z(_git(root, ["ls-files", "-z"]))) + + +def staged_paths(root: Path) -> list[str]: + return sorted( + _split_z(_git(root, ["diff", "--cached", "--name-only", "--diff-filter=ACMRT", "-z"])) + ) + + +def _read_worktree(root: Path, rel_path: str) -> bytes | None: + path = root / rel_path + if not path.is_file(): + return None + return path.read_bytes() + + +def _read_staged(root: Path, rel_path: str) -> bytes | None: + try: + return _git(root, ["show", f":{rel_path}"]) + except subprocess.CalledProcessError: + return None + + +def _path_policy_hit(source: str, rel_path: str) -> PublicSafetyHit | None: + normalized = rel_path.strip("/") + for prefix in LOCAL_ONLY_PREFIXES: + clean_prefix = prefix.strip("/") + if normalized == clean_prefix or normalized.startswith(prefix): + return PublicSafetyHit(source, "local-only-artifact-path", 1, 1) + return None + + +def scan_git_sources( + root: Path, *, tracked: bool, staged: bool +) -> tuple[list[PublicSafetyHit], int]: + rules = repository_rules(root) + hits: list[PublicSafetyHit] = [] + scanned = 0 + + if tracked: + for rel_path in tracked_paths(root): + source = f"tracked:{rel_path}" + content = _read_worktree(root, rel_path) + if content is None: + continue + path_hit = _path_policy_hit(source, rel_path) + if path_hit: + hits.append(path_hit) + scanned += 1 + hits.extend(scan_bytes(source, content, rules)) + + if staged: + for rel_path in staged_paths(root): + source = f"staged:{rel_path}" + path_hit = _path_policy_hit(source, rel_path) + if path_hit: + hits.append(path_hit) + content = _read_staged(root, rel_path) + if content is None: + continue + scanned += 1 + hits.extend(scan_bytes(source, content, rules)) + + return sorted(set(hits), key=lambda h: h.summary()), scanned + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Scan tracked/staged files for public-safety leaks." + ) + parser.add_argument("--repo-root", default=".", help=argparse.SUPPRESS) + parser.add_argument("--tracked", action="store_true", help="Scan tracked worktree files.") + parser.add_argument("--staged", action="store_true", help="Scan staged index blobs.") + args = parser.parse_args(argv) + + if not args.tracked and not args.staged: + parser.error("choose at least one of --tracked or --staged") + + root = Path(args.repo_root).resolve() + try: + hits, scanned = scan_git_sources(root, tracked=args.tracked, staged=args.staged) + except subprocess.CalledProcessError as exc: + print(f"public-safety-scan: FAIL - git command failed: {exc.cmd}", file=sys.stderr) + return 1 + + if hits: + print("public-safety-scan: FAIL", file=sys.stderr) + for hit in hits: + print(f" - {hit.summary()}", file=sys.stderr) + return 1 + + modes = ", ".join( + mode for mode, enabled in (("tracked", args.tracked), ("staged", args.staged)) if enabled + ) + print(f"public-safety-scan: OK ({modes}; scanned {scanned} file snapshots)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/quality_scorecard.py b/scripts/quality_scorecard.py index 876489e..41a2a92 100644 --- a/scripts/quality_scorecard.py +++ b/scripts/quality_scorecard.py @@ -37,6 +37,7 @@ import argparse import ast +import importlib.util import io import json import re @@ -68,26 +69,6 @@ # fallback agrees with the AST count across Python versions. _TEST_DEF_RE = re.compile(r"^\s*(?:async\s+)?def\s+(test\w*)\s*[\[(]") -# Public-safety patterns — the rendered output must never contain a private path, -# token, or key. Extends the commit-checkpoint preflight regex set with the home -# and temp roots that user-controlled config fields (pyright.include, ruff -# per-file-ignore keys) could carry verbatim. Path prefixes are matched bare so a -# non-ASCII username cannot slip past a trailing character class. -_FORBIDDEN_PATTERNS = ( - re.compile(r"/Users/"), - re.compile(r"/home/"), - re.compile(r"/root/"), - re.compile(r"/srv/"), - re.compile(r"/opt/"), - re.compile(r"/var/folders/"), - re.compile(r"/tmp/"), - re.compile(r"[A-Za-z]:\\Users\\"), - re.compile(r"[g]ithub_pat_"), - re.compile(r"\b[g]hp_[A-Za-z0-9]{20,}"), - re.compile(r"\b[p]ypi-[A-Za-z0-9_-]{20,}"), - re.compile(r"\b[s]k-[A-Za-z0-9]{16,}"), -) - # Known integration / smoke / contract surfaces. Presence is detected by path so # the scorecard reports which real-workflow suites exist, not just unit count. _KNOWN_SUITES = ( @@ -117,15 +98,35 @@ "typecheck", "python -m pyright --pythonpath \"$(python -c 'import sys; print(sys.executable)')\"", ), + ("typecheck_strict_slice", "python -m pyright -p pyrightconfig.strict.json"), + ("public_safety", "python scripts/public_safety_scan.py --tracked --staged"), ("scorecard", "python scripts/quality_scorecard.py --check"), ) +_PUBLIC_SAFETY_MODULE = None + def repo_root() -> Path: """Repository root — the parent of this script's ``scripts/`` directory.""" return Path(__file__).resolve().parent.parent +def _public_safety_module(): + """Load sibling public_safety_scan.py without requiring package installation.""" + global _PUBLIC_SAFETY_MODULE + if _PUBLIC_SAFETY_MODULE is not None: + return _PUBLIC_SAFETY_MODULE + module_path = Path(__file__).resolve().parent / "public_safety_scan.py" + spec = importlib.util.spec_from_file_location("_mempalace_public_safety_scan", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + _PUBLIC_SAFETY_MODULE = module + return module + + def _is_excluded(path: Path, root: Path) -> bool: rel = path.relative_to(root) if any(part in _SKIP_PARTS or part.endswith(".egg-info") for part in rel.parts): @@ -552,12 +553,23 @@ def require(cond: bool, msg: str) -> None: def scan_public_safety(*texts: str) -> list[str]: """Return rendered substrings that match a forbidden private/secret pattern.""" - hits: list[str] = [] - for text in texts: - for pat in _FORBIDDEN_PATTERNS: - for match in pat.finditer(text): - hits.append(f"{pat.pattern!r} matched {match.group(0)!r}") - return sorted(set(hits)) + return _public_safety_module().scan_rendered_texts(*texts) + + +def check_committed_artifacts(root: Path, markdown: str, json_text: str) -> list[str]: + """Return freshness errors for committed docs/quality artifacts.""" + errors: list[str] = [] + md_path = root / "docs" / "quality" / "scorecard.md" + json_path = root / "docs" / "quality" / "scorecard.json" + if not md_path.exists(): + errors.append("stale-artifact: docs/quality/scorecard.md is missing") + elif md_path.read_text(encoding="utf-8") != markdown + "\n": + errors.append("stale-artifact: docs/quality/scorecard.md is stale; run --write") + if not json_path.exists(): + errors.append("stale-artifact: docs/quality/scorecard.json is missing") + elif json_path.read_text(encoding="utf-8") != json_text: + errors.append("stale-artifact: docs/quality/scorecard.json is stale; run --write") + return errors def run_check(root: Path) -> int: @@ -579,6 +591,7 @@ def run_check(root: Path) -> int: md = render_markdown(first) js = render_json(first) problems.extend(f"public-safety: {h}" for h in scan_public_safety(md, js)) + problems.extend(check_committed_artifacts(root, md, js)) if problems: print("quality-scorecard: FAIL", file=sys.stderr) diff --git a/tests/test_public_safety_scan.py b/tests/test_public_safety_scan.py new file mode 100644 index 0000000..6e67500 --- /dev/null +++ b/tests/test_public_safety_scan.py @@ -0,0 +1,72 @@ +"""Tests for scripts/public_safety_scan.py.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def _load_module_from_path(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] # reason: existing script path always returns a spec + sys.modules[name] = mod + spec.loader.exec_module(mod) # type: ignore[union-attr] # reason: existing script path has a loader + return mod + + +ps = _load_module_from_path("public_safety_scan", ROOT / "scripts" / "public_safety_scan.py") + + +def test_rendered_scan_flags_generic_private_roots(): + planted = "/" + "Users" + "/alice/project" + assert ps.scan_rendered_texts(planted) + + +def test_repository_scan_allows_public_examples(): + examples = "\n".join( + [ + "/" + "Users" + "/you/.mempalace/palace", + "/" + "tmp" + "/mempalace-watch.log", + "export ANTHROPIC_API_KEY=sk-ant-...", + ] + ) + assert ps.scan_text("example.md", examples, ps.repository_rules(ROOT)) == [] + + +def test_repository_scan_flags_current_home_path(): + planted = str(Path.home() / "private-project" / "file.txt") + hits = ps.scan_text("doc.md", planted, ps.repository_rules(ROOT)) + assert [hit.rule_id for hit in hits] == ["local-home"] + + +def test_repository_scan_flags_secret_without_printing_match(tmp_path, capsys): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + token = "gh" + "p_" + "A" * 30 + (repo / "leak.txt").write_text(token + "\n", encoding="utf-8") + subprocess.run(["git", "add", "leak.txt"], cwd=repo, check=True, capture_output=True) + + assert ps.main(["--repo-root", str(repo), "--staged"]) == 1 + err = capsys.readouterr().err + assert "github-token-prefix" in err + assert token not in err + + +def test_repository_scan_rejects_local_only_artifact_path(tmp_path, capsys): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + artifact = repo / ".tasks" / "TASK-demo" / "raw.txt" + artifact.parent.mkdir(parents=True) + artifact.write_text("local evidence\n", encoding="utf-8") + subprocess.run(["git", "add", ".tasks/TASK-demo/raw.txt"], cwd=repo, check=True) + + assert ps.main(["--repo-root", str(repo), "--staged"]) == 1 + err = capsys.readouterr().err + assert "local-only-artifact-path" in err + assert "staged:.tasks/TASK-demo/raw.txt" in err diff --git a/tests/test_quality_scorecard.py b/tests/test_quality_scorecard.py index 9f08d56..7c8327a 100644 --- a/tests/test_quality_scorecard.py +++ b/tests/test_quality_scorecard.py @@ -336,6 +336,11 @@ def test_run_check_fails_on_public_safety_hit(monkeypatch): assert sc.run_check(ROOT) == 1 +def test_run_check_fails_on_stale_committed_artifacts(monkeypatch): + monkeypatch.setattr(sc, "check_committed_artifacts", lambda *_: ["stale"]) + assert sc.run_check(ROOT) == 1 + + def test_run_check_fails_when_build_raises(monkeypatch): def _boom(_root): raise RuntimeError("nope") diff --git a/tests/test_type_suppressions.py b/tests/test_type_suppressions.py index 7d728c4..b89218e 100644 --- a/tests/test_type_suppressions.py +++ b/tests/test_type_suppressions.py @@ -13,7 +13,9 @@ `# reason:` justification, are rejected. """ +import io import re +import tokenize from pathlib import Path ACCEPTED_RE = re.compile(r"#\s*(?:type|pyright):\s*ignore\[[^\]\s]+\]\s*#\s*reason:\s*\S") @@ -34,12 +36,25 @@ def _collect_enforced_files() -> list[Path]: return sorted(files) +def _comment_units(path: Path) -> list[tuple[int, str]]: + """Return (line_number, comment_text) pairs for real Python comment tokens.""" + src = path.read_text(encoding="utf-8") + try: + return [ + (tok.start[0], tok.string) + for tok in tokenize.generate_tokens(io.StringIO(src).readline) + if tok.type == tokenize.COMMENT + ] + except (tokenize.TokenError, IndentationError, SyntaxError): + return list(enumerate(src.splitlines(), start=1)) + + def _violations(path: Path) -> list[tuple[int, str]]: """Return (line_number, line) pairs that contain a suppression but fail the policy.""" result = [] - for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - if SUPPRESSION_RE.search(line) and not ACCEPTED_RE.search(line): - result.append((lineno, line.rstrip())) + for lineno, comment in _comment_units(path): + if SUPPRESSION_RE.search(comment) and not ACCEPTED_RE.search(comment): + result.append((lineno, comment.rstrip())) return result @@ -68,3 +83,13 @@ def test_fixture_is_rejected(): "Expected the negative fixture to contain unreasoned suppressions, but none found. " "Update tests/fixtures/unreasoned_suppression.py to include bare type: ignore lines." ) + + +def test_string_literal_mentions_are_ignored(tmp_path): + sample = tmp_path / "sample.py" + sample.write_text( + 'TEXT = "not a real # type: ignore suppression"\n' + "x = 1 # pyright: ignore[reportUnknownVariableType] # reason: test fixture\n", + encoding="utf-8", + ) + assert _violations(sample) == []