scrufflehog 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. scrufflehog-0.1.0/.github/workflows/release.yml +86 -0
  2. scrufflehog-0.1.0/.gitignore +7 -0
  3. scrufflehog-0.1.0/LICENSE +21 -0
  4. scrufflehog-0.1.0/PKG-INFO +116 -0
  5. scrufflehog-0.1.0/README.md +96 -0
  6. scrufflehog-0.1.0/docs/AGENTIC.md +63 -0
  7. scrufflehog-0.1.0/examples/scrufflehog.toml +53 -0
  8. scrufflehog-0.1.0/pyproject.toml +34 -0
  9. scrufflehog-0.1.0/src/scrufflehog/__init__.py +14 -0
  10. scrufflehog-0.1.0/src/scrufflehog/advisor.py +39 -0
  11. scrufflehog-0.1.0/src/scrufflehog/advisors/__init__.py +2 -0
  12. scrufflehog-0.1.0/src/scrufflehog/advisors/llm.py +167 -0
  13. scrufflehog-0.1.0/src/scrufflehog/cli.py +82 -0
  14. scrufflehog-0.1.0/src/scrufflehog/config.py +55 -0
  15. scrufflehog-0.1.0/src/scrufflehog/coverage/__init__.py +4 -0
  16. scrufflehog-0.1.0/src/scrufflehog/coverage/extract.py +104 -0
  17. scrufflehog-0.1.0/src/scrufflehog/coverage/semantics.py +19 -0
  18. scrufflehog-0.1.0/src/scrufflehog/engine.py +134 -0
  19. scrufflehog-0.1.0/src/scrufflehog/oracles.py +92 -0
  20. scrufflehog-0.1.0/src/scrufflehog/output/__init__.py +7 -0
  21. scrufflehog-0.1.0/src/scrufflehog/output/json_out.py +16 -0
  22. scrufflehog-0.1.0/src/scrufflehog/output/sarif.py +77 -0
  23. scrufflehog-0.1.0/src/scrufflehog/output/text.py +18 -0
  24. scrufflehog-0.1.0/src/scrufflehog/probes.py +79 -0
  25. scrufflehog-0.1.0/src/scrufflehog/runners/__init__.py +31 -0
  26. scrufflehog-0.1.0/src/scrufflehog/runners/go_runner.py +71 -0
  27. scrufflehog-0.1.0/src/scrufflehog/runners/node_runner.py +64 -0
  28. scrufflehog-0.1.0/src/scrufflehog/runners/python_runner.py +58 -0
  29. scrufflehog-0.1.0/src/scrufflehog/runners/rust_runner.py +67 -0
  30. scrufflehog-0.1.0/tests/test_advisor.py +99 -0
  31. scrufflehog-0.1.0/tests/test_live_runners.py +111 -0
  32. scrufflehog-0.1.0/tests/test_scrufflehog.py +190 -0
@@ -0,0 +1,86 @@
1
+ name: Release to PyPI
2
+
3
+ # Publishes scrufflehog to PyPI when a GitHub Release is published, using PyPI
4
+ # Trusted Publishing (OIDC): the job mints a short-lived OpenID Connect token
5
+ # that PyPI exchanges for upload rights. No API token is stored in the repo or
6
+ # in Actions secrets.
7
+ #
8
+ # One-time PyPI setup (pending publisher, since the project doesn't exist yet):
9
+ # PyPI -> Your projects -> Publishing -> Add a pending publisher
10
+ # PyPI project name: scrufflehog
11
+ # Owner: seanturner83
12
+ # Repository: scrufflehog
13
+ # Workflow filename: release.yml
14
+ # Environment: pypi
15
+ # The first successful run creates the project and claims the name.
16
+
17
+ on:
18
+ release:
19
+ types: [published]
20
+ # Manual trigger for re-runs / dry checks (still gated on the pypi environment).
21
+ workflow_dispatch:
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ build:
28
+ name: Build sdist + wheel
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - uses: actions/setup-python@v5
34
+ with:
35
+ python-version: "3.11"
36
+
37
+ - name: Build
38
+ run: |
39
+ python -m pip install --upgrade build
40
+ python -m build
41
+
42
+ - name: Check metadata
43
+ run: |
44
+ python -m pip install --upgrade twine
45
+ twine check dist/*
46
+
47
+ - name: Verify tag matches package version
48
+ if: github.event_name == 'release'
49
+ run: |
50
+ # Release tag is v<version>; the built wheel must match, or we'd
51
+ # publish a version that disagrees with the tag people pinned.
52
+ tag="${GITHUB_REF_NAME#v}"
53
+ whl=$(ls dist/*.whl | head -1)
54
+ pkg_ver=$(basename "$whl" | sed -E 's/^scrufflehog-([^-]+)-.*/\1/')
55
+ echo "tag=$tag wheel=$pkg_ver"
56
+ if [ "$tag" != "$pkg_ver" ]; then
57
+ echo "::error::release tag ($tag) != package version ($pkg_ver) — bump pyproject.toml or fix the tag"
58
+ exit 1
59
+ fi
60
+
61
+ - uses: actions/upload-artifact@v4
62
+ with:
63
+ name: dist
64
+ path: dist/
65
+
66
+ publish:
67
+ name: Publish to PyPI
68
+ needs: build
69
+ runs-on: ubuntu-latest
70
+ # The pypi environment is the second gate: Trusted Publishing on PyPI is
71
+ # scoped to this environment name, and it can carry a required-reviewer
72
+ # protection rule so a human approves each real publish.
73
+ environment:
74
+ name: pypi
75
+ url: https://pypi.org/p/scrufflehog
76
+ permissions:
77
+ id-token: write # OIDC token for Trusted Publishing — the only privilege needed
78
+ steps:
79
+ - uses: actions/download-artifact@v4
80
+ with:
81
+ name: dist
82
+ path: dist/
83
+
84
+ - name: Publish
85
+ uses: pypa/gh-action-pypi-publish@release/v1
86
+ # No `with: password:` — auth is the OIDC identity of this job.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Turner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: scrufflehog
3
+ Version: 0.1.0
4
+ Summary: Deterministically verify that your redactors actually redact.
5
+ Project-URL: Homepage, https://github.com/seanturner83/scrufflehog
6
+ Author: Sean Turner
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: logging,pii,redaction,secrets,security,testing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.11
16
+ Provides-Extra: agentic
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # scrufflehog
22
+
23
+ **Unit-test your redaction.**
24
+
25
+ Everyone scans for secrets that already leaked (trufflehog, gitleaks). Almost
26
+ nobody tests whether the redaction they *rely on* actually works. scrufflehog is
27
+ the inverse tool: it runs adversarial probes through your own redaction code and
28
+ deterministically asserts the secret is gone — and checks that your field
29
+ denylist/allow-list covers the sensitive names you think it does.
30
+
31
+ No model, no guessing: every verdict is a hard assertion against a planted secret
32
+ you control. Zero false positives by construction.
33
+
34
+ ## Two things it checks
35
+
36
+ **1. Transform-strength** — *does the redactor's output still contain, or
37
+ trivially reverse to, the secret?* It executes your redactor on planted probes
38
+ and applies three oracles:
39
+
40
+ - `literal_survival` — the secret appears verbatim in the output.
41
+ - `noop_passthrough` — the "redactor" returned its input unchanged.
42
+ - `reversible` — the output is a keyless, low-entropy transform (truncated or
43
+ unsalted hash, base64, static substitution) that a bounded candidate space
44
+ recovers. Catches "redaction" that only *looks* redacted.
45
+
46
+ **2. Coverage** — *is every sensitive field name actually on your list?* A field
47
+ denylist/allow-list is data, not behaviour, so scrufflehog extracts it straight
48
+ from source and checks a sensitive-field corpus against it — **without executing
49
+ your code**. This works across languages (Go maps, Python collections,
50
+ pino/`fast-redact` path lists, Rust sets).
51
+
52
+ ## Languages
53
+
54
+ | | transform-strength | coverage |
55
+ |---|---|---|
56
+ | Python | in-process import | ✓ |
57
+ | Go | driver built in your module | ✓ (map literals) |
58
+ | Rust | `cargo --example` driver | ✓ (set literals) |
59
+ | Node/JS | node driver via stdin | ✓ (pino path lists) |
60
+
61
+ ## Install
62
+
63
+ Latest from source (works today):
64
+
65
+ ```bash
66
+ pip install git+https://github.com/seanturner83/scrufflehog
67
+ ```
68
+
69
+ Once the first release is published, from PyPI:
70
+
71
+ ```bash
72
+ pip install scrufflehog
73
+ ```
74
+
75
+ ## Use
76
+
77
+ Write a `scrufflehog.toml` declaring your redactors (see `examples/`):
78
+
79
+ ```toml
80
+ [[transform]]
81
+ lang = "python"
82
+ module = "app/redact.py"
83
+ fn = "redact_value"
84
+ kind = "value"
85
+
86
+ [[coverage]]
87
+ module = "app/redact.py"
88
+ symbol = "SECRET_FIELDS"
89
+ extract = "py_collection"
90
+ match = "exact_ci"
91
+ ```
92
+
93
+ Then:
94
+
95
+ ```bash
96
+ scrufflehog verify --config scrufflehog.toml --target . --format text
97
+ scrufflehog verify --config scrufflehog.toml --target . --format sarif # for code-scanning
98
+ scrufflehog verify --config scrufflehog.toml --target . --fail-on-defect # CI gate
99
+ ```
100
+
101
+ ## Deterministic by default; optional agentic assist
102
+
103
+ The core is entirely deterministic and that's the point. An **optional** advisor
104
+ (`--advisor llm`) can propose domain-matched probes, discover redactors, and
105
+ confirm coverage hypotheses against real field usage — but it only ever supplies
106
+ *inputs and hypotheses*; the deterministic oracle still renders every verdict.
107
+ With no advisor, output is fully reproducible. See `docs/AGENTIC.md`.
108
+
109
+ ## Why "scrufflehog"
110
+
111
+ trufflehog finds the secrets. scrufflehog scruffs through the code that's
112
+ *supposed to hide them* and checks it actually does.
113
+
114
+ ## License
115
+
116
+ MIT.
@@ -0,0 +1,96 @@
1
+ # scrufflehog
2
+
3
+ **Unit-test your redaction.**
4
+
5
+ Everyone scans for secrets that already leaked (trufflehog, gitleaks). Almost
6
+ nobody tests whether the redaction they *rely on* actually works. scrufflehog is
7
+ the inverse tool: it runs adversarial probes through your own redaction code and
8
+ deterministically asserts the secret is gone — and checks that your field
9
+ denylist/allow-list covers the sensitive names you think it does.
10
+
11
+ No model, no guessing: every verdict is a hard assertion against a planted secret
12
+ you control. Zero false positives by construction.
13
+
14
+ ## Two things it checks
15
+
16
+ **1. Transform-strength** — *does the redactor's output still contain, or
17
+ trivially reverse to, the secret?* It executes your redactor on planted probes
18
+ and applies three oracles:
19
+
20
+ - `literal_survival` — the secret appears verbatim in the output.
21
+ - `noop_passthrough` — the "redactor" returned its input unchanged.
22
+ - `reversible` — the output is a keyless, low-entropy transform (truncated or
23
+ unsalted hash, base64, static substitution) that a bounded candidate space
24
+ recovers. Catches "redaction" that only *looks* redacted.
25
+
26
+ **2. Coverage** — *is every sensitive field name actually on your list?* A field
27
+ denylist/allow-list is data, not behaviour, so scrufflehog extracts it straight
28
+ from source and checks a sensitive-field corpus against it — **without executing
29
+ your code**. This works across languages (Go maps, Python collections,
30
+ pino/`fast-redact` path lists, Rust sets).
31
+
32
+ ## Languages
33
+
34
+ | | transform-strength | coverage |
35
+ |---|---|---|
36
+ | Python | in-process import | ✓ |
37
+ | Go | driver built in your module | ✓ (map literals) |
38
+ | Rust | `cargo --example` driver | ✓ (set literals) |
39
+ | Node/JS | node driver via stdin | ✓ (pino path lists) |
40
+
41
+ ## Install
42
+
43
+ Latest from source (works today):
44
+
45
+ ```bash
46
+ pip install git+https://github.com/seanturner83/scrufflehog
47
+ ```
48
+
49
+ Once the first release is published, from PyPI:
50
+
51
+ ```bash
52
+ pip install scrufflehog
53
+ ```
54
+
55
+ ## Use
56
+
57
+ Write a `scrufflehog.toml` declaring your redactors (see `examples/`):
58
+
59
+ ```toml
60
+ [[transform]]
61
+ lang = "python"
62
+ module = "app/redact.py"
63
+ fn = "redact_value"
64
+ kind = "value"
65
+
66
+ [[coverage]]
67
+ module = "app/redact.py"
68
+ symbol = "SECRET_FIELDS"
69
+ extract = "py_collection"
70
+ match = "exact_ci"
71
+ ```
72
+
73
+ Then:
74
+
75
+ ```bash
76
+ scrufflehog verify --config scrufflehog.toml --target . --format text
77
+ scrufflehog verify --config scrufflehog.toml --target . --format sarif # for code-scanning
78
+ scrufflehog verify --config scrufflehog.toml --target . --fail-on-defect # CI gate
79
+ ```
80
+
81
+ ## Deterministic by default; optional agentic assist
82
+
83
+ The core is entirely deterministic and that's the point. An **optional** advisor
84
+ (`--advisor llm`) can propose domain-matched probes, discover redactors, and
85
+ confirm coverage hypotheses against real field usage — but it only ever supplies
86
+ *inputs and hypotheses*; the deterministic oracle still renders every verdict.
87
+ With no advisor, output is fully reproducible. See `docs/AGENTIC.md`.
88
+
89
+ ## Why "scrufflehog"
90
+
91
+ trufflehog finds the secrets. scrufflehog scruffs through the code that's
92
+ *supposed to hide them* and checks it actually does.
93
+
94
+ ## License
95
+
96
+ MIT.
@@ -0,0 +1,63 @@
1
+ # Optional agentic layer — design
2
+
3
+ ## Principle (non-negotiable)
4
+
5
+ scrufflehog's core is **entirely deterministic** and stays that way. Every
6
+ verdict — defect or clean — comes from a hard assertion against known ground
7
+ truth (planted probe → run redactor → literal/hash-space/set-membership check).
8
+ Same input, same answer, zero false positives, no model. That determinism is the
9
+ product's whole thesis: the deterministic counterpart to probabilistic LLM
10
+ review.
11
+
12
+ The agentic layer is **optional, off by default, and never renders a verdict.**
13
+ Rule: *the agent proposes INPUTS and HYPOTHESES; the deterministic oracle still
14
+ decides.* This preserves the zero-FP guarantee while removing scrufflehog's two
15
+ real blind spots (hand-authored probes, unconfirmed coverage hypotheses).
16
+
17
+ ## Where an advisor adds value (three seams)
18
+
19
+ 1. **Probe generation** — read the redactor's signature/body and generate
20
+ probes that match its INPUT DOMAIN. Solves the real footgun (feeding bare
21
+ values to a URL-path redactor → false positives until domain-matched probes
22
+ were hand-written). Generated probes are still run through the deterministic
23
+ oracle; the agent supplies inputs, not verdicts.
24
+
25
+ 2. **Redactor + field discovery** — scan a repo and PROPOSE the registry
26
+ (redaction fns, denylist symbols, sensitive field names). Human/config
27
+ confirms. Discovery, not judgement.
28
+
29
+ 3. **Coverage-gap confirmation** — the honest-caveat killer. A coverage finding
30
+ is a hypothesis ("the list lacks `ssn`" — but does a field named `ssn` reach
31
+ this redactor?). An advisor greps the codebase for real field usage to
32
+ confirm or refute, turning a hypothesis into a confirmed finding or dropping
33
+ it.
34
+
35
+ ## Interface
36
+
37
+ ```python
38
+ class Advisor(Protocol):
39
+ def propose_probes(self, redactor_src: str, entry: dict) -> list[Probe]: ...
40
+ def discover_redactors(self, target: Path) -> list[dict]: ...
41
+ def confirm_coverage_gap(self, target: Path, field: str, redactor: str) -> Verdict: ...
42
+ ```
43
+
44
+ - `NoopAdvisor` (default): `propose_probes` → [], `discover` → [], `confirm` →
45
+ UNCONFIRMED (finding stands as a hypothesis, exactly today's behaviour).
46
+ - `LLMAdvisor` (optional module, extra dependency): implements the three via a
47
+ model. Lives behind `pip install scrufflehog[agentic]`.
48
+
49
+ CLI:
50
+ ```
51
+ scrufflehog verify --config x.toml --target . # pure deterministic
52
+ scrufflehog verify --config x.toml --target . --advisor llm # + agentic assist
53
+ ```
54
+
55
+ ## Invariants
56
+
57
+ - A defect is ALWAYS confirmed by the deterministic oracle. The advisor can add
58
+ probes that trigger one, or downgrade a coverage hypothesis to
59
+ confirmed/refuted, but it cannot manufacture a defect the oracle didn't verify.
60
+ - With `--advisor` absent, output is byte-identical to today. Reproducibility of
61
+ the deterministic path is a test invariant.
62
+ - Advisor failures (timeout, API error) degrade to the deterministic result,
63
+ never crash the run.
@@ -0,0 +1,53 @@
1
+ # Example scrufflehog config. Point module paths at your redactors (relative to
2
+ # the --target checkout). Delete the languages you don't use.
3
+
4
+ # --- transform-strength: execute the redactor on probes ---------------------
5
+
6
+ [[transform]]
7
+ lang = "python"
8
+ module = "app/redact.py"
9
+ fn = "redact_value" # def redact_value(s: str) -> str
10
+ kind = "value"
11
+ probe_set = "value" # "value" (any sensitive value) | "url_apikey" (URL-scoped)
12
+
13
+ [[transform]]
14
+ lang = "go"
15
+ module = "internal/logredact"
16
+ import = "github.com/example/app/internal/logredact"
17
+ fn = "Sanitize" # func Sanitize(s string) string
18
+ kind = "value"
19
+ # wrap = "error" # set if the fn takes an error, not a string
20
+
21
+ [[transform]]
22
+ lang = "node"
23
+ module = "dist/redact.js"
24
+ fn = "redactValue" # module.exports.redactValue = (s) => ...
25
+ kind = "value"
26
+ # export = "default" # set if module.exports IS the fn
27
+
28
+ [[transform]]
29
+ lang = "rust"
30
+ module = "." # crate root (Cargo.toml dir)
31
+ call = "app::redact::mask(&line)"
32
+
33
+ # --- coverage: static field-list check (no execution) -----------------------
34
+
35
+ [[coverage]]
36
+ module = "app/redact.py"
37
+ symbol = "SECRET_FIELDS" # a Python list/tuple/set of field names
38
+ extract = "py_collection"
39
+ match = "exact_ci"
40
+ # corpus = ["ssn", "cvv", "iban"] # optional; omit to use the built-in list
41
+
42
+ [[coverage]]
43
+ module = "internal/logredact/keys.go"
44
+ symbol = "SecretKeys" # var SecretKeys = map[string]struct{}{ "password": {}, ... }
45
+ extract = "go_map_keys"
46
+ match = "exact_ci"
47
+ doc_claims_substring = false
48
+
49
+ [[coverage]]
50
+ module = "src/logger.ts"
51
+ symbol = "redact" # pino redact: { paths: [ ... ] } or redact: [ ... ]
52
+ extract = "ts_redact_paths"
53
+ match = "exact_ci"
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "scrufflehog"
7
+ version = "0.1.0"
8
+ description = "Deterministically verify that your redactors actually redact."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Sean Turner" }]
13
+ keywords = ["security", "redaction", "pii", "secrets", "logging", "testing"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Topic :: Security",
20
+ ]
21
+ dependencies = [] # core is stdlib-only (tomllib on 3.11+)
22
+
23
+ [project.optional-dependencies]
24
+ agentic = [] # LLM advisor deps go here (opt-in)
25
+ dev = ["pytest>=7"]
26
+
27
+ [project.scripts]
28
+ scrufflehog = "scrufflehog.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/seanturner83/scrufflehog"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/scrufflehog"]
@@ -0,0 +1,14 @@
1
+ """scrufflehog — deterministically verify that your redactors actually redact.
2
+
3
+ Everyone scans for secrets that leaked. scrufflehog tests whether the redaction
4
+ you rely on actually works: it runs adversarial probes through your own redactor
5
+ and asserts the secret is gone and not trivially reversible, and checks your
6
+ field denylist/allow-list covers the sensitive names you think it does.
7
+ """
8
+ from .oracles import Defect, assert_output, reversible
9
+ from .probes import Probe, get_probe_set
10
+ from .engine import RunResult, run
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["Defect", "assert_output", "reversible", "Probe", "get_probe_set",
14
+ "RunResult", "run"]
@@ -0,0 +1,39 @@
1
+ """Optional advisor interface — the ONLY place non-determinism may enter.
2
+
3
+ An advisor proposes INPUTS and HYPOTHESES; it never renders a verdict. The
4
+ deterministic oracle still decides every defect. The default NoopAdvisor makes
5
+ the engine byte-identical to a pure deterministic run — the agentic layer is
6
+ strictly additive and opt-in. See docs/AGENTIC.md.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from .probes import Probe
14
+
15
+
16
+ class CoverageVerdict:
17
+ CONFIRMED = "confirmed" # a real field with this name reaches the redactor
18
+ REFUTED = "refuted" # no such field is logged here — drop the finding
19
+ UNCONFIRMED = "unconfirmed" # can't tell — finding stands as a hypothesis
20
+
21
+
22
+ @runtime_checkable
23
+ class Advisor(Protocol):
24
+ def propose_probes(self, redactor_src: str, entry: dict) -> list[Probe]: ...
25
+ def discover_redactors(self, target: Path) -> list[dict]: ...
26
+ def confirm_coverage_gap(self, target: Path, field: str, redactor: str) -> str: ...
27
+
28
+
29
+ class NoopAdvisor:
30
+ """Default. Adds nothing; the run stays purely deterministic."""
31
+
32
+ def propose_probes(self, redactor_src: str, entry: dict) -> list[Probe]:
33
+ return []
34
+
35
+ def discover_redactors(self, target: Path) -> list[dict]:
36
+ return []
37
+
38
+ def confirm_coverage_gap(self, target: Path, field: str, redactor: str) -> str:
39
+ return CoverageVerdict.UNCONFIRMED
@@ -0,0 +1,2 @@
1
+ """Optional advisors. Import the concrete one you want; the core never imports
2
+ these, so the base package stays dependency-free and deterministic."""