-
Notifications
You must be signed in to change notification settings - Fork 0
v0.11 item 5: enforcement coverage, a list and never a score #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2281,12 +2281,21 @@ | |||||
| "the list in force and exit.", | ||||||
| ) | ||||||
| @click.option("--json", "as_json", is_flag=True, help="Emit one ctrlrun.scan/v1 document.") | ||||||
| @click.option( | ||||||
| "--coverage", | ||||||
| "coverage_flag", | ||||||
| is_flag=True, | ||||||
| help="Also report what this store has never exercised (SPEC-v0.11 §7). Opens the store.", | ||||||
| ) | ||||||
| @STORE_URL_OPTION | ||||||
| def scan( | ||||||
| root: Path | None, | ||||||
| policy_path: Path | None, | ||||||
| excludes: tuple[str, ...], | ||||||
| vocabulary_path: str | None, | ||||||
| as_json: bool, | ||||||
| coverage_flag: bool, | ||||||
| store_url: str | None, | ||||||
| ) -> None: | ||||||
| """Report the consequential call sites and policy entries nothing is covering. | ||||||
|
|
||||||
|
|
@@ -2297,6 +2306,12 @@ | |||||
| It is a finder and not a proof. Every run prints what it could not look at, and a clean | ||||||
| scan means nothing was found where it looked. | ||||||
|
|
||||||
| With --coverage it also reads a store and reports what this deployment declared and has | ||||||
| never exercised. **That half is a list and not a score**: no percentage, no ratio, no badge. | ||||||
| A policy entry nothing exercised may be correctly unused, and it says so. It does not move | ||||||
| the exit code, for the same reason: a number that ranked a deployment would be a verdict on | ||||||
| the operator's document, which this tool does not give. | ||||||
|
|
||||||
| Exit codes: 0 nothing was found; 1 something was, including a suppressed finding or a | ||||||
| call whose name could not be resolved; 2 the scan could not run. | ||||||
| """ | ||||||
|
|
@@ -2330,12 +2345,43 @@ | |||||
| click.echo(f"ctrlrun scan: {refused}", err=True) | ||||||
| raise SystemExit(2) from refused | ||||||
|
|
||||||
| measured = None | ||||||
| if coverage_flag: | ||||||
| from ..coverage import coverage as run_coverage | ||||||
| from ..coverage import coverage_lines | ||||||
|
|
||||||
| store = _store(store_url) | ||||||
| try: | ||||||
| loaded = _loaded_policy() if policy_path is None else Policy.from_file(policy_path) | ||||||
| measured = run_coverage( | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Pass complete declaration inventories to
Expose the complete protected-action inventory from 🤖 Prompt for AI Agents |
||||||
| store, | ||||||
| policy_actions=sorted(loaded.actions), | ||||||
| protected_actions=sorted( | ||||||
| {finding.name for finding in report.findings if finding.name is not None} | ||||||
| ), | ||||||
| policy_path=str(policy_path) if policy_path else None, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Record the resolved policy source. When the command discovers the default policy, this expression records - policy_path=str(policy_path) if policy_path else None,
+ policy_path=loaded.source,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| ) | ||||||
| except CTRLRunError as exc: | ||||||
| raise _fail(exc) from exc | ||||||
|
|
||||||
| if as_json: | ||||||
| click.echo(json_module.dumps(report_document(report), indent=2)) | ||||||
| document = report_document(report) | ||||||
| if measured is not None: | ||||||
| # A **key**, not a merged document: `ctrlrun.scan/v1` answers a question about | ||||||
| # source and `ctrlrun.coverage/v1` answers one about a store, and folding them | ||||||
| # would make a consumer parse two shapes under one name. | ||||||
| document["coverage"] = measured.to_dict() | ||||||
| click.echo(json_module.dumps(document, indent=2)) | ||||||
| else: | ||||||
| for line in report_lines(report): | ||||||
| click.echo(line) | ||||||
| if measured is not None: | ||||||
| for line in coverage_lines(measured): | ||||||
|
|
||||||
| click.echo(line) | ||||||
|
|
||||||
| # **`--coverage` does not move the exit code** (rule 4). An unexercised policy entry is a | ||||||
| # fact about the record, not a finding about the operator, and an exit code that moved with | ||||||
| # it would be the score this item is forbidden to produce, wearing a shell's clothes. | ||||||
| if report.exit_code: | ||||||
| raise SystemExit(report.exit_code) | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| # SPDX-FileCopyrightText: 2026 The CTRLRun contributors | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Enforcement coverage: what this deployment has never exercised. SPEC-v0.11 §7. | ||
|
|
||
| The runtime half of `ctrlrun scan`, whose static half landed in v0.10. `scan` reads the source | ||
| and asks *is this call protected?*. This reads what the deployment has already recorded and asks | ||
| a different question: **which of the things you declared has nothing ever gone through?** | ||
|
|
||
| **From what is already written.** No new event type, no new column. The action name lives on the | ||
| **receipt** rather than on the event, which is a fact worth stating because it decides the whole | ||
| design: `ACTION_PROPOSED` carries an `action_hash` and nothing that maps it back to a name, and | ||
| every action that reached a decision leaves a receipt, a denial included. So the question is | ||
| answerable, and if it had needed a new event the answer would have been to say so and stop. | ||
|
|
||
| **Rule 4 (§1.1): a clean result is not a verdict.** No score, no percentage, no ratio, no badge, | ||
| and no sentence a reader could quote as one. `SPEC-v0.4.md` §3.9 is the precedent and it is worth | ||
| stating in full: `verify` never grades an operator's document, and a coverage number that ranked | ||
| their deployment would be the same claim in a new costume. | ||
|
|
||
| **A policy entry nothing exercised may be correctly unused.** An action declared for a quarterly | ||
| job, a deny rule that exists so the action is refused rather than unknown, a tool nobody has | ||
| needed yet: each is a reasonable thing to find here, and none of them is a defect. This module | ||
| reports a **list with a reason**, and the reason is a statement about the record rather than | ||
| about the operator. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Sequence | ||
| from dataclasses import dataclass | ||
| from enum import StrEnum | ||
| from typing import Any, Final, Protocol | ||
|
|
||
| #: `ctrlrun.coverage/v1`. Its own schema because it is its own document: `ctrlrun scan`'s report | ||
| #: answers a question about source and this answers one about a store, and folding them into one | ||
| #: name would make a consumer parse two shapes under it. | ||
| COVERAGE_SCHEMA: Final = "ctrlrun.coverage/v1" | ||
|
|
||
|
|
||
| class Unexercised(StrEnum): | ||
| """What kind of thing nothing has gone through.""" | ||
|
|
||
| POLICY_ACTION = "policy_action" | ||
| GATEWAY_TOOL = "gateway_tool" | ||
| PROTECTED_ACTION = "protected_action" | ||
|
|
||
|
|
||
| #: The reason each kind carries. **A statement about the record, never about the operator**: it | ||
| #: says what was not found, and says in the same breath that not finding it may be correct. | ||
| _REASON: Final = { | ||
| Unexercised.POLICY_ACTION: ( | ||
| "the policy declares this action and no receipt in this store names it" | ||
| ), | ||
| Unexercised.GATEWAY_TOOL: ( | ||
| "the gateway exposes this tool and no receipt in this store names the action it routes to" | ||
| ), | ||
| Unexercised.PROTECTED_ACTION: ( | ||
| "@protect declares this action in the source and no receipt in this store names it" | ||
| ), | ||
| } | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Unused: | ||
| """One declared thing nothing has exercised, and why it is on the list.""" | ||
|
|
||
| kind: Unexercised | ||
| name: str | ||
| reason: str | ||
|
|
||
| def to_dict(self) -> dict[str, Any]: | ||
| return {"kind": str(self.kind), "name": self.name, "reason": self.reason} | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class CoverageReport: | ||
| """The list, and what it was computed from (§7). | ||
|
|
||
| **There is no `score`, no `percentage`, no `ratio` and no `exit_code` that grades.** The | ||
| counts here are inputs an operator needs to read the list at all -- *nothing exercised, over | ||
| a store holding no receipts* and *nothing exercised, over a store holding forty thousand* are | ||
| different findings -- and neither is a verdict. `T560` greps this module's own output for the | ||
| vocabulary rule 4 forbids. | ||
| """ | ||
|
|
||
| #: Everything declared that nothing has exercised, in codepoint order within each kind. | ||
| unused: tuple[Unused, ...] | ||
| #: How many receipts the answer was computed from. Context, not a denominator. | ||
| receipts_read: int | ||
| #: How many distinct action names those receipts carry. | ||
| actions_seen: tuple[str, ...] | ||
| #: Where the declarations came from, so a reader can disagree with the list. | ||
| policy_path: str | None = None | ||
|
|
||
| def of(self, kind: Unexercised) -> tuple[Unused, ...]: | ||
| return tuple(item for item in self.unused if item.kind is kind) | ||
|
|
||
| def to_dict(self) -> dict[str, Any]: | ||
| return { | ||
| "schema": COVERAGE_SCHEMA, | ||
| "policy": self.policy_path, | ||
| "receipts_read": self.receipts_read, | ||
| "actions_seen": list(self.actions_seen), | ||
| "unused": [item.to_dict() for item in self.unused], | ||
| } | ||
|
|
||
|
|
||
| class _CoverageStore(Protocol): | ||
| def receipts(self) -> tuple[Any, ...]: ... | ||
|
|
||
|
|
||
| def _names_seen(store: _CoverageStore) -> tuple[str, ...]: | ||
| """Every action name this store's receipts carry, in codepoint order. | ||
|
|
||
| **Receipts, not events**, and the reason is not a preference: `ACTION_PROPOSED` carries an | ||
| `action_hash` and nothing that maps it back to a name, so the events alone cannot answer the | ||
| question. Every action that reached a decision leaves a receipt -- a denial included, which | ||
| matters here, because an action that is always denied **has** been exercised and belongs | ||
| nowhere on this list. | ||
|
|
||
| A row this binary cannot read back (`SPEC-v0.11.md` §5.2) carries no action name and is | ||
| skipped. It is already reported as `content_altered` by the chain, and a coverage list is not | ||
| the place to report a tamper a second time under a different name. | ||
| """ | ||
| seen = { | ||
| name | ||
| for receipt in store.receipts() | ||
| for name in (getattr(receipt, "action", None),) | ||
| if isinstance(name, str) and name | ||
| } | ||
| return tuple(sorted(seen)) | ||
|
|
||
|
|
||
| def coverage( | ||
| store: _CoverageStore, | ||
| *, | ||
| policy_actions: Sequence[str] = (), | ||
| gateway_tools: Sequence[tuple[str, str]] = (), | ||
| protected_actions: Sequence[str] = (), | ||
| policy_path: str | None = None, | ||
| ) -> CoverageReport: | ||
| """What this deployment declared and has never exercised (§7). | ||
|
|
||
| Everything is **supplied** rather than discovered: the policy's actions come from the loaded | ||
| policy, the gateway's tools from its configuration, and `@protect`'s actions from `scan`'s | ||
| static pass. This module reads a store and matches, in the shape `SPEC-v0.9.md` §5.4 settled | ||
| for a scope provider, and for the same reason: a module that resolved an operator's document | ||
| would be reading the policy from the wrong layer. | ||
|
|
||
| `gateway_tools` is `(tool, action)` because a tool's own name is what an operator recognises | ||
| and the action is what a receipt would carry. | ||
| """ | ||
| seen = set(_names_seen(store)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Use one receipt snapshot for the complete report.
Read the receipts once. Use that snapshot for both calculations. Proposed fix-def _names_seen(store: _CoverageStore) -> tuple[str, ...]:
+def _names_seen(receipts: Sequence[Any]) -> tuple[str, ...]:
seen = {
name
- for receipt in store.receipts()
+ for receipt in receipts
for name in (getattr(receipt, "action", None),)
if isinstance(name, str) and name
}
return tuple(sorted(seen))
def coverage(...):
- seen = set(_names_seen(store))
+ receipts = store.receipts()
+ seen = set(_names_seen(receipts))
...
- receipts_read=len(store.receipts()),
+ receipts_read=len(receipts),Also applies to: 172-173 🤖 Prompt for AI Agents |
||
| found: list[Unused] = [] | ||
|
|
||
| for action in sorted(set(policy_actions)): | ||
| if action not in seen: | ||
| found.append( | ||
| Unused(Unexercised.POLICY_ACTION, action, _REASON[Unexercised.POLICY_ACTION]) | ||
| ) | ||
| for tool, action in sorted(set(gateway_tools)): | ||
| if action not in seen: | ||
| found.append(Unused(Unexercised.GATEWAY_TOOL, tool, _REASON[Unexercised.GATEWAY_TOOL])) | ||
| for action in sorted(set(protected_actions)): | ||
| if action not in seen: | ||
| found.append( | ||
| Unused(Unexercised.PROTECTED_ACTION, action, _REASON[Unexercised.PROTECTED_ACTION]) | ||
| ) | ||
|
|
||
| return CoverageReport( | ||
| unused=tuple(found), | ||
| receipts_read=len(store.receipts()), | ||
| actions_seen=tuple(sorted(seen)), | ||
| policy_path=policy_path, | ||
| ) | ||
|
|
||
|
|
||
| #: The sentence this report ends with, always, whether the list is empty or not. | ||
| #: | ||
| #: **Rule 4's whole content, in the place a reader cannot miss.** An empty list is the one most | ||
| #: likely to be quoted as a verdict, so the sentence is not conditional on there being findings. | ||
| #: `SPEC-v0.4.md` §3.9's precedent: `verify` never grades an operator's document. | ||
| NOT_A_VERDICT: Final = ( | ||
| "This is a list of what has not been exercised, not a score. A policy entry nothing " | ||
| "exercised may be correctly unused: a quarterly job, a deny rule that exists so the action " | ||
| "is refused rather than unknown, a tool nobody has needed yet." | ||
| ) | ||
|
|
||
|
|
||
| def coverage_lines(report: CoverageReport) -> list[str]: | ||
| """The human rendering, in the shape `ctrlrun scan` already uses (§7). | ||
|
|
||
| A list with a reason per entry, and the sentence above. **No totals line, no ratio, and no | ||
| "N of M"**: the counts that appear are the inputs, labelled as such. | ||
| """ | ||
| lines = ["ctrlrun scan --coverage", ""] | ||
| lines.append( | ||
| f"read {report.receipts_read} receipt(s), naming {len(report.actions_seen)} action(s)" | ||
| ) | ||
| lines.append("") | ||
| for kind in Unexercised: | ||
| entries = report.of(kind) | ||
| if not entries: | ||
| continue | ||
| lines.append(f"never exercised: {kind} ({len(entries)})") | ||
| for entry in entries: | ||
| lines.append(f" {entry.name}") | ||
| lines.append(f" {entry.reason}") | ||
| lines.append("") | ||
| if not report.unused: | ||
| lines.append("everything declared has been exercised at least once") | ||
| lines.append("") | ||
| lines.append(NOT_A_VERDICT) | ||
| return lines | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Open the store beside the selected policy.
When
--policynames a policy outside the working directory and--store-urlis absent,_store(None)resolves the default policy location beforePolicy.from_file(policy_path)runs. Coverage can therefore compare the selected policy against another deployment's receipts or fail to find its database.Load the policy first. If no store URL is supplied, resolve
state_path(loaded.source).Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents