diff --git a/CHANGELOG.md b/CHANGELOG.md index 6884171..a1281fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,10 +37,26 @@ change between minor versions. prints a bare `VERIFIED`. This is what makes "Signetry never merges on its own judgement" a checkable property of every receipt instead of a promise in a README. -### Fixed - -- Two repo-root-relative links in `docs/RELEASING.md` resolved from `docs/` and were - therefore broken. +### Added — policy registry + +- **`signetry policies`** and **`signetry init --policy `**. Six starter admission + contracts for common repository shapes: `docs-only`, `dependency-bump`, + `python-library`, `node-service`, `monorepo-service`, `ci-workflow-fix`. Writing the + first contract is where adoption stalls, and "which globs should an agent be allowed to + touch in this stack" is a real security decision most teams defer. +- The published file **is** the installed file. `init --policy` copies the registry bytes + verbatim — no templating, no merge — so an adopter can diff their + `.signetry/admission.yaml` against the registry and get nothing back. Verified in CI. +- Every entry carries its own evidence. A policy declares example paths it must block and + must allow in `# @policy` header comments, and `tests/test_policy_registry.py` runs each + claim through the real `evaluate_contract`. A policy whose documentation does not match + its behaviour fails CI. The `allows` direction is the one that catches an over-broad + forbidden glob quietly making a policy useless. +- `ci-workflow-fix` carries a `caution` that `init` prints at adoption time, because write + access to `.github/workflows` is a privilege-escalation path and a registry that shipped + it silently would be worse than one that omitted it. +- New public helper `is_policy_placeholder`, and `signetry_core/policies/` ships in the + wheel (confirmed against a built artifact, not assumed). ### Changed — licence: open core (BUSL-1.1, converting to Apache-2.0) @@ -69,6 +85,22 @@ change between minor versions. across all Signetry repositories (bar the engine/integration licence wording) so the legal terms cannot drift per-repo again. See [CLA.md](CLA.md) §2–3. +### Fixed + +- **A scaffold placeholder was reported as declared provenance.** `signetry init` writes `policy_owner: your-team`, and `policy_status()` reported + `declared` — *"Policy declares a human owner and version (change-controlled + metadata)"* — for a file no human had read. Every receipt from a freshly initialised + repo asserted change-control that did not exist. +- Placeholder provenance is now treated as **absent**, with its own status value: + `declared` / `placeholder` / `incomplete`, each carrying a `note` explaining which. + Consumers must treat anything other than `declared` as not change-controlled; the extra + values exist to say *why*, which is actionable, and never mean "good enough". +- Note for consumers matching on this field: a repo that ran `signetry init` and never + edited the provenance keys now reports `placeholder` where it previously reported + `declared`. That is the bug being fixed, not a regression. +- Two repo-root-relative links in `docs/RELEASING.md` resolved from `docs/` and were + therefore broken. + ### Added — Python insecure-deserialisation coverage - `marshal.load(s)` and `shelve.open` now flagged (CWE-502) — both execute arbitrary diff --git a/README.md b/README.md index bee9c54..ef967a3 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,34 @@ The GitHub Action is the highest-reach checkpoint: it sits at the repo, so it governs *any* agent that opens a PR. Make **"Signetry Admission"** a required status check and nothing merges without a signed receipt. `auto_merge` is always false. +## Start from a policy instead of a blank file (`signetry policies`) + +Writing the first admission contract is where adoption stalls — "which paths should an +agent be allowed to touch in this stack" is a real security decision, and most teams +defer it. Six starter policies ship in the box: + +```bash +signetry policies # docs-only, dependency-bump, python-library, +signetry init --policy python-library # node-service, monorepo-service, ci-workflow-fix +``` + +Two things make these worth trusting rather than just copying: + +- **What ships is what lands.** `init --policy` writes the registry file byte-for-byte — + no templating, no merge. Diff your `.signetry/admission.yaml` against the published + policy and you get nothing back. +- **Every policy carries its own evidence.** Each one declares example paths it must + block and must allow, and CI runs those claims through the same `evaluate_contract` the + pipeline uses. A policy whose documentation doesn't match its behaviour fails the + build — including the `allows` direction, which is what catches an over-broad forbidden + glob quietly making a policy useless. + +A registry policy ships `policy_owner: your-team`, and Signetry reports that as +`placeholder`, not `declared`: a borrowed policy nobody at your org has read is not +change-controlled, and the receipt says so until a human adopts it. See +[docs/site/policy-registry.md](docs/site/policy-registry.md) — contributing a policy is +the most useful change you can make here without touching the kernel. + ## Find vulnerabilities — then govern the fix (`signetry scan`) `signetry-core` also ships a **layered SAST detection engine**: a deterministic, diff --git a/docs/site/policy-registry.md b/docs/site/policy-registry.md new file mode 100644 index 0000000..1eaa247 --- /dev/null +++ b/docs/site/policy-registry.md @@ -0,0 +1,114 @@ +# Policy registry + +Writing your first admission contract is where adoption stalls. The format is simple — +a few globs and a diff budget — but deciding *what an agent should be allowed to touch in +this stack* is a real security decision, and most teams put it off. + +The registry answers it with named policies for common repository shapes: + +```bash +signetry policies # what's available +signetry init --policy python-library # install one +``` + +That writes `.signetry/admission.yaml` and prints the scope you just adopted. + +## What's in it + +| id | For | Scope | +|---|---|---| +| `docs-only` | any repo | Markdown, text and images. No code, no config, no CI. | +| `dependency-bump` | any repo | Manifests and lockfiles only. The narrowest useful policy. | +| `python-library` | src-layout Python | Library code, tests, `pyproject.toml`. Keeps pytest green. | +| `node-service` | Node / TypeScript | App code and tests. Not the build or release path. | +| `monorepo-service` | monorepos | One service directory; siblings and shared packages excluded. | +| `ci-workflow-fix` | GitHub Actions | Workflow files only, one at a time. **Read its caution.** | + +`docs-only` is the usual starting point: it lets a team watch the whole pipeline — +scope enforcement, verifier, signed receipt — on a change that cannot break anything. + +## Two properties worth knowing + +### What ships is what lands + +A registry entry is a literal, valid `.signetry/admission.yaml`. `init --policy` copies +the bytes verbatim: no templating, no merging, no rewriting. Diff your installed file +against the published one and you get nothing. + +Metadata lives in `# @policy` header comments, which the contract parser ignores and a +human reading the installed file still benefits from. + +### Every entry carries its own evidence + +Each policy declares example paths it must block and example paths it must allow: + +```yaml +# @policy blocks: .github/workflows/release.yml, setup.py, tests/conftest.py +# @policy allows: src/mylib/core.py, tests/test_core.py, pyproject.toml +``` + +`tests/test_policy_registry.py` runs every one of those through the real +`evaluate_contract` — the same function the admission pipeline uses. A policy whose +claims don't hold fails CI. + +The `allows` direction matters as much as `blocks`: it's what catches an over-broad +forbidden glob quietly making a policy useless, which is the failure mode you would +otherwise discover months later when an agent could never propose anything. + +## A registry policy is not an owned policy + +Every entry ships `policy_owner: your-team`, and Signetry treats that as **unowned**: + +``` +$ signetry init --policy python-library +wrote /repo/.signetry/admission.yaml (python-library — Python library (src layout, pytest)) + scope 5 allowed pattern(s), 10 forbidden, max 12 file(s) + checks pytest -q + owner unowned — set policy_owner and policy_version to adopt this policy as your own +``` + +Receipts report `policy_status: placeholder` until a human sets a real `policy_owner` and +`policy_version`. This is deliberate. A borrowed policy nobody at your organization has +read is not change-controlled, and a receipt claiming otherwise would be worse than one +that admits the gap. Adopting a policy is a human act; the registry can't perform it for +you. + +`placeholder` doesn't restrict what a change can earn — authority still comes from the +deterministic contract, the independent verifier and your required checks. It only stops +the receipt from asserting provenance that doesn't exist. + +## Contributing a policy + +This is the most useful thing you can add to Signetry without touching the kernel, and +the bar is *evidence*, not taste. + +1. Add `signetry_core/policies/.yaml`. The filename must match `@policy id`. +2. Fill in the required header keys: `id`, `title`, `summary`, `author`, `blocks`, + `allows`. Add `stack` and `caution` where they help. +3. Choose `blocks` and `allows` examples that would actually catch a mistake. Three or + four of each, using realistic paths for the stack. Include at least one `blocks` entry + that sits *inside* your `allowed_paths` — carving an exception out of a directory you + otherwise own is the part people get wrong. +4. Run `pytest tests/test_policy_registry.py`. Your policy is validated the moment the + file exists; there is nothing to register. + +Rules the tests enforce, so you don't have to remember them: + +- Every claimed block is refused, and every claimed allow passes. +- `blocks` and `allows` don't overlap. +- The policy declares real scope and a bounded diff budget — a contract with neither + gets its scope silently replaced by the default on load, governing nothing while + appearing to. +- `policy_owner` is a recognised placeholder, so no adopter inherits a false claim of + ownership. + +What makes a policy worth merging: a repository shape people actually have, a scope you +can defend line by line, and comments explaining *why* something is forbidden rather than +just that it is. `python-library` forbids `conftest.py` at any depth — the comment says +it executes at collection time on every developer machine, which is the reasoning a +reviewer needs. + +Policies that permit something risky are acceptable if they are honest about it. See +`ci-workflow-fix`: it allows workflow edits because teams genuinely need that task +governed rather than done outside Signetry, and it carries a `caution` that `signetry +init` prints at adoption time. A risky policy with no caution will be sent back. diff --git a/mkdocs.yml b/mkdocs.yml index d573233..3e8ca40 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - Home: index.md - Quickstart: quickstart.md - Scan a repo in 60s: scan-quickstart.md + - Policy registry: policy-registry.md - Concepts: concepts.md - Capabilities & Proof: capabilities.md - GitHub Action: github-action.md diff --git a/signetry_core/__init__.py b/signetry_core/__init__.py index 2af202b..9693cb8 100644 --- a/signetry_core/__init__.py +++ b/signetry_core/__init__.py @@ -37,6 +37,7 @@ evaluate_gates, evaluate_passport, gate_pr, + is_policy_placeholder, issue_passport, load_contract, guard, @@ -99,6 +100,7 @@ "Finding", "FindingsReport", "semgrep_available", + "is_policy_placeholder", "load_contract", "guard", "GuardDecision", diff --git a/signetry_core/cli.py b/signetry_core/cli.py index f0a68e6..86a14c2 100644 --- a/signetry_core/cli.py +++ b/signetry_core/cli.py @@ -11,6 +11,8 @@ signetry comment # render the canonical PR comment signetry admit-extension # govern a skill / MCP extension signetry init # scaffold .signetry/admission.yaml + signetry init --policy python-library # scaffold from the policy registry + signetry policies # list the policy registry signetry completion zsh # shell completion script ``admit`` exits non-zero unless the run earns branch-PR authority (L2), so it @@ -37,6 +39,7 @@ to_slsa_provenance, verify_receipt, ) +from .policy_registry import available_policies, load_policy, policy_ids def _print(obj: Any, as_json: bool) -> None: @@ -413,8 +416,22 @@ def cmd_guard(args: argparse.Namespace) -> int: def cmd_init(args: argparse.Namespace) -> int: - """Scaffold a starter ``.signetry/admission.yaml`` in a repo so a new user is one - command away from a governed change. Never overwrites without ``--force``.""" + """Scaffold a ``.signetry/admission.yaml`` in a repo so a new user is one command + away from a governed change — the built-in starter, or a named registry policy via + ``--policy``. Never overwrites without ``--force``.""" + if getattr(args, "list_policies", False): + return cmd_policies(args) + + entry = None + if getattr(args, "policy", None): + # Resolve the policy BEFORE touching the filesystem, so a typo leaves the repo + # exactly as it was rather than half-initialised. + try: + entry = load_policy(args.policy) + except KeyError as exc: + print(f"error: {exc.args[0]}", file=sys.stderr) + return 2 + root = Path(args.repo).resolve() if not root.is_dir(): print(f"error: {root} is not a directory", file=sys.stderr) @@ -424,15 +441,80 @@ def cmd_init(args: argparse.Namespace) -> int: print(f"error: {dest} already exists (use --force to overwrite)", file=sys.stderr) return 1 dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(_STARTER_CONTRACT) - print(f"wrote {dest}") + + if entry is None: + dest.write_text(_STARTER_CONTRACT) + print(f"wrote {dest}") + print("Next: edit the scope, then run signetry admit . (or add the GitHub Action).") + return 0 + + # Byte-for-byte. An adopter can diff their file against the published policy. + dest.write_text(entry.text, encoding="utf-8") + print(f"wrote {dest} ({entry.id} — {entry.title})") + if entry.caution: + print() + print(" CAUTION " + _wrap(entry.caution, indent=" ")) + print() + contract = entry.contract + print(f" scope {len(contract.allowed_paths)} allowed pattern(s), " + f"{len(contract.forbidden_paths)} forbidden, max {contract.max_files_changed} file(s)") + if not contract.required_checks: + print(" checks none declared — set required_checks to your test command, or the " + "pipeline verifies nothing") + else: + print(f" checks {', '.join(contract.required_checks)}") + # Say this every time. A borrowed policy is not an owned policy, and the receipt + # will keep reporting it as unowned until a human puts their name on it. + print(" owner unowned — set policy_owner and policy_version to adopt this policy " + "as your own") + print() print("Next: edit the scope, then run signetry admit . (or add the GitHub Action).") return 0 +def _wrap(text: str, *, width: int = 78, indent: str = "") -> str: + """Collapse whitespace and wrap, so a multi-line YAML comment reads as a paragraph.""" + words = " ".join(text.split()) + lines, current = [], "" + for word in words.split(" "): + candidate = f"{current} {word}".strip() + if len(candidate) > width and current: + lines.append(current) + current = word + else: + current = candidate + if current: + lines.append(current) + return ("\n" + indent).join(lines) + + +def cmd_policies(args: argparse.Namespace) -> int: + """List the policy registry: named, validated admission policies for common stacks.""" + entries = available_policies() + if getattr(args, "json", False): + _print({"policies": [e.to_public() for e in entries]}, True) + return 0 + if not entries: + print("No policies found. This is a packaging bug — please report it.", file=sys.stderr) + return 1 + print("Policy registry — signetry init --policy \n") + for entry in entries: + print(f" {entry.id}") + print(f" {entry.title}") + print(f" {_wrap(entry.summary, indent=' ')}") + if entry.stack: + print(f" stack: {', '.join(entry.stack)}") + if entry.caution: + print(f" CAUTION: {_wrap(entry.caution, indent=' ')}") + print() + print("Every policy above is validated in CI against the paths it claims to block and") + print("allow. Contribute one: https://github.com/Signetry/core (docs/site/policy-registry.md)") + return 0 + + # Static shell-completion scripts. Kept simple + dependency-free (no argcomplete): # they complete the subcommand names, which is the high-value case. -_COMMANDS = "admit verify brake provenance gates comment admit-extension guard init completion" +_COMMANDS = "admit verify brake provenance gates comment admit-extension guard init policies completion" _COMPLETIONS = { "bash": f"""# signetry bash completion — add to ~/.bashrc: eval "$(signetry completion bash)" _signetry_complete() {{ @@ -544,11 +626,18 @@ def build_parser() -> argparse.ArgumentParser: p_guard.add_argument("--hook-output", action="store_true", help="Emit Claude Code PreToolUse decision JSON (deny blocks; exit 0).") p_guard.set_defaults(func=cmd_guard) - p_init = sub.add_parser("init", help="Scaffold a starter .signetry/admission.yaml in a repo.") + p_init = sub.add_parser("init", help="Scaffold a .signetry/admission.yaml — the starter, or a registry policy.") p_init.add_argument("repo", nargs="?", default=".", help="Repo directory to write into (default: current dir).") p_init.add_argument("--force", action="store_true", help="Overwrite an existing .signetry/admission.yaml.") + p_init.add_argument("--policy", metavar="ID", + help=f"Install a registry policy instead of the starter ({', '.join(policy_ids())}).") + p_init.add_argument("--list-policies", action="store_true", help="List the policy registry and exit.") p_init.set_defaults(func=cmd_init) + p_pol = sub.add_parser("policies", help="List the policy registry: named, CI-validated admission policies.") + p_pol.add_argument("--json", action="store_true", help="Emit the registry as JSON (id, metadata, parsed contract).") + p_pol.set_defaults(func=cmd_policies) + p_comp = sub.add_parser("completion", help="Print a shell completion script (bash | zsh | fish).") p_comp.add_argument("shell", choices=["bash", "zsh", "fish"], help="Shell to emit completion for.") p_comp.set_defaults(func=cmd_completion) diff --git a/signetry_core/pipeline/__init__.py b/signetry_core/pipeline/__init__.py index bf5e0a6..83c054b 100644 --- a/signetry_core/pipeline/__init__.py +++ b/signetry_core/pipeline/__init__.py @@ -14,6 +14,7 @@ contract_from_dict, default_contract, evaluate_contract, + is_policy_placeholder, load_contract, ) from .gates import Gate, GateSummary, evaluate_gates @@ -92,6 +93,7 @@ "contract_from_dict", "default_contract", "evaluate_contract", + "is_policy_placeholder", "load_contract", "guard", "guard_path", diff --git a/signetry_core/pipeline/contract.py b/signetry_core/pipeline/contract.py index b62b9f1..510f534 100644 --- a/signetry_core/pipeline/contract.py +++ b/signetry_core/pipeline/contract.py @@ -85,6 +85,47 @@ # Invariant preserved: capabilities can only RESTRICT. An empty list ("not # declared") means "no additional restriction from this class" — never a widening. +# Scaffold placeholders. `signetry init` and every registry policy ship provenance +# fields pre-filled with a stand-in so the shape is obvious, which means a team that +# never edits the file would otherwise get a receipt asserting a declared human owner +# it does not have. A placeholder is treated as ABSENT: unowned, never change- +# controlled. Matched case-insensitively, after stripping <>/[]{} wrappers. +_POLICY_PLACEHOLDERS = frozenset({ + "your-team", "your-org", "your-company", "yourteam", "yourorg", "your team", + "team", "org", "owner", "example", "example-team", "acme", "acme-corp", + "todo", "tbd", "fixme", "changeme", "change-me", "unset", "unknown", "none", + "n/a", "na", "null", "placeholder", "signetry-registry", +}) + + +_POLICY_STATUS_NOTES = { + "declared": ( + "Policy declares a human owner and version (change-controlled metadata). " + "This is declared provenance, not a cryptographic signature." + ), + "placeholder": ( + "Policy provenance is still scaffold text (e.g. 'your-team'), so no human has " + "adopted this policy. Treat it as unowned: set policy_owner to a real team and " + "policy_version to a version you control. Authority is unaffected — it still " + "requires the deterministic contract, independent verifier, and required checks." + ), + "incomplete": ( + "Policy metadata is incomplete (no declared owner/version). Authority still " + "requires the deterministic contract, independent verifier, and required " + "checks; production teams should own and version the policy." + ), +} + + +def is_policy_placeholder(value: str) -> bool: + """True when a provenance value is scaffold text rather than a real declaration. + + Public because the registry-validation harness asserts that every shipped policy + template is recognised here — a template that slipped past this set would hand + every adopter a false ``declared`` status.""" + s = (value or "").strip().strip("<>[](){}").strip() + return s.lower() in _POLICY_PLACEHOLDERS + @dataclass(frozen=True) class Contract: @@ -148,29 +189,34 @@ def policy_status(self) -> dict[str, Any]: ``incomplete``. Values: - ``declared`` — a human owner AND version are declared (change- controlled metadata, but not cryptographically proven). + - ``placeholder`` — the owner is still scaffold text (``your-team``, ``TODO``, + a registry template) — nobody has adopted this policy. - ``incomplete`` — owner or version missing (default posture). - ``cryptographically-signed`` — reserved for a policy carrying a verifiable signature; not asserted here (no policy-signature scheme is verified yet), stated so the field is honest. + Consumers MUST treat any value other than ``declared`` as NOT change-controlled. + The extra values exist to say *why* it is not, which is actionable; they never + mean "good enough". + Expiry/approval timestamps are advisory metadata surfaced for review; they do not by themselves widen authority (authority is still gated by the deterministic contract + verifier + checks). """ - declared = bool(self.policy_owner and self.policy_version) - status = "declared" if declared else "incomplete" + placeholder = is_policy_placeholder(self.policy_owner) + declared = bool(self.policy_owner and self.policy_version) and not placeholder + if declared: + status = "declared" + elif placeholder: + status = "placeholder" + else: + status = "incomplete" return { "status": status, "owner": self.policy_owner or None, "version": self.policy_version or None, "approved_at": self.policy_approved_at or None, - "note": ( - "Policy declares a human owner and version (change-controlled metadata). " - "This is declared provenance, not a cryptographic signature." - if declared else - "Policy metadata is incomplete (no declared owner/version). Authority still " - "requires the deterministic contract, independent verifier, and required " - "checks; production teams should own and version the policy." - ), + "note": _POLICY_STATUS_NOTES[status], } def hash(self) -> str: diff --git a/signetry_core/policies/ci-workflow-fix.yaml b/signetry_core/policies/ci-workflow-fix.yaml new file mode 100644 index 0000000..8dc533e --- /dev/null +++ b/signetry_core/policies/ci-workflow-fix.yaml @@ -0,0 +1,42 @@ +# @policy id: ci-workflow-fix +# @policy title: Fix one CI workflow (deliberately narrow, read the caution) +# @policy summary: The inverse of every other policy here: CI workflow files are the ONLY +# thing in scope, for the specific task of repairing a broken build. One file per change, +# and the workflows that hold your release credentials stay forbidden. +# @policy caution: Write access to .github/workflows is the classic privilege-escalation +# path — a workflow can read repository secrets and run arbitrary code with them on the +# next push. Adopt this only for the CI-repair task, never as a repo's standing policy, +# and review the diff yourself. Signetry never merges, so a human still sees every change; +# this policy narrows what an agent may propose, it does not make the proposal safe. +# @policy stack: github-actions +# @policy author: signetry-registry +# @policy blocks: .github/workflows/release.yml, .github/workflows/publish-to-npm.yml, .github/workflows/deploy-prod.yml, src/index.ts, .github/actions/setup/action.yml +# @policy allows: .github/workflows/ci.yml, .github/workflows/test.yml, .github/workflows/lint.yml +version: 2 +task_type: ci-repair + +# Workflow files only. Custom actions under .github/actions/** are excluded on purpose: +# they execute inside every job that calls them, so they are a wider surface than the +# workflow that invokes them. +allowed_paths: + - ".github/workflows/**" + +# The workflows that hold credentials are exactly the ones worth attacking, so they are +# carved out of the scope above. Rename these patterns to match your own repository — +# a release workflow called something else is not covered by this list. +forbidden_paths: + - ".github/workflows/*release*" + - ".github/workflows/*publish*" + - ".github/workflows/*deploy*" + - ".github/workflows/*sign*" + - "**/*secret*" + - "**/.env*" + +# One workflow per change. A CI repair that rewrites the whole pipeline is not a repair, +# and reviewing one file properly beats skimming six. +max_files_changed: 1 +required_checks: + - "actionlint" + +policy_owner: your-team +policy_version: "1.0" diff --git a/signetry_core/policies/dependency-bump.yaml b/signetry_core/policies/dependency-bump.yaml new file mode 100644 index 0000000..de0ef0f --- /dev/null +++ b/signetry_core/policies/dependency-bump.yaml @@ -0,0 +1,49 @@ +# @policy id: dependency-bump +# @policy title: Dependency bump (manifest + lockfile only) +# @policy summary: The narrowest useful scope. An agent may edit dependency manifests and +# lockfiles and nothing else — no source, no config, no CI. Start here if you are unsure +# which policy to adopt; it is very hard to do damage inside it. +# @policy stack: any, npm, pip, cargo, go, bundler +# @policy author: signetry-registry +# @policy blocks: src/index.js, .github/workflows/ci.yml, Dockerfile, .env, tests/test_api.py +# @policy allows: package.json, package-lock.json, requirements.txt, poetry.lock, Cargo.toml, Cargo.lock, go.mod, go.sum, Gemfile.lock +version: 2 +task_type: dependency-remediation + +allowed_paths: + - "package.json" + - "package-lock.json" + - "yarn.lock" + - "pnpm-lock.yaml" + - "requirements.txt" + - "requirements/*.txt" + - "poetry.lock" + - "pyproject.toml" + - "uv.lock" + - "Cargo.toml" + - "Cargo.lock" + - "go.mod" + - "go.sum" + - "Gemfile" + - "Gemfile.lock" + +forbidden_paths: + - ".github/**" + - "infra/**" + - "deploy/**" + - "Dockerfile*" + - "**/*secret*" + - "**/.env*" + +# A dependency bump that touches many files is not a dependency bump. +max_files_changed: 4 + +# SET THIS BEFORE YOU RELY ON IT. This policy spans npm/pip/cargo/go, so the registry +# cannot know your test command — and inventing one would be worse than leaving it out. +# An empty list means nothing is verified, and the pipeline reports exactly that rather +# than implying a suite passed. Put your real command here: "npm test", "pytest -q", +# "cargo test", "go test ./...". +required_checks: [] + +policy_owner: your-team +policy_version: "1.0" diff --git a/signetry_core/policies/docs-only.yaml b/signetry_core/policies/docs-only.yaml new file mode 100644 index 0000000..0fd6e0d --- /dev/null +++ b/signetry_core/policies/docs-only.yaml @@ -0,0 +1,40 @@ +# @policy id: docs-only +# @policy title: Documentation only (prose and images) +# @policy summary: Markdown, text and images. No code, no config, no CI, no lockfiles. The +# safest policy in the registry, and a good first governed task for a team that wants to +# watch the pipeline work end to end before pointing an agent at source. +# @policy stack: any +# @policy author: signetry-registry +# @policy blocks: src/app.py, package.json, .github/workflows/ci.yml, mkdocs.yml, scripts/publish-docs.sh +# @policy allows: README.md, docs/guide.md, docs/img/diagram.png, CHANGELOG.md, docs/adr/0001-record.md +version: 2 +task_type: documentation + +allowed_paths: + - "*.md" + - "docs/**" + - "**/*.md" + - "**/*.txt" + - "**/*.png" + - "**/*.jpg" + - "**/*.svg" + +# A docs tree is not inert. mkdocs.yml, a Sphinx conf.py and a publish script all +# execute — and they usually sit right next to the prose, inside docs/**. Prose-only +# has to mean prose only, so these are forbidden even where the allowlist covers them. +forbidden_paths: + - ".github/**" + - "**/*.yml" + - "**/*.yaml" + - "**/conf.py" + - "**/*.sh" + - "**/.env*" + +max_files_changed: 20 + +# Prose has no test suite, and pretending otherwise would be theatre. The value here +# comes from scope enforcement and the signed receipt, not from a check gate. +required_checks: [] + +policy_owner: your-team +policy_version: "1.0" diff --git a/signetry_core/policies/monorepo-service.yaml b/signetry_core/policies/monorepo-service.yaml new file mode 100644 index 0000000..19f7efd --- /dev/null +++ b/signetry_core/policies/monorepo-service.yaml @@ -0,0 +1,40 @@ +# @policy id: monorepo-service +# @policy title: One service in a monorepo (rename the directory) +# @policy summary: Scopes an agent to a single service inside a monorepo and keeps it out of +# its siblings and out of the shared packages every service depends on. Replace +# "services/checkout" throughout with your own service directory — this is the one policy +# here that does nothing useful until you edit it. +# @policy stack: monorepo, turborepo, nx, pnpm-workspaces, bazel +# @policy author: signetry-registry +# @policy blocks: services/payments/src/charge.ts, packages/shared/src/auth.ts, .github/workflows/ci.yml, turbo.json, services/checkout/Dockerfile +# @policy allows: services/checkout/src/handler.ts, services/checkout/package.json, services/checkout/test/handler.test.ts +version: 2 +task_type: feature-work + +# Rename this. Everything outside it is a violation, which is the entire point: a +# monorepo makes "one service" a convention, and this turns it into an enforced boundary. +allowed_paths: + - "services/checkout/**" + +# packages/** is the trap. Shared libraries look local to the agent, but a change there +# ships to every service in the repo at once — the blast radius is the whole monorepo, +# not the service being worked on. Dockerfile is forbidden even INSIDE the owned service: +# forbidden_paths always beats allowed_paths, which is how you carve an exception out of +# a directory you otherwise own. +forbidden_paths: + - "packages/**" + - "libs/**" + - ".github/**" + - "turbo.json" + - "nx.json" + - "pnpm-workspace.yaml" + - "Dockerfile*" + - "**/*secret*" + - "**/.env*" + +max_files_changed: 15 +required_checks: + - "pnpm --filter checkout test" + +policy_owner: your-team +policy_version: "1.0" diff --git a/signetry_core/policies/node-service.yaml b/signetry_core/policies/node-service.yaml new file mode 100644 index 0000000..a8a0fd8 --- /dev/null +++ b/signetry_core/policies/node-service.yaml @@ -0,0 +1,46 @@ +# @policy id: node-service +# @policy title: Node/TypeScript service (src + tests) +# @policy summary: A deployed Node or TypeScript service. Application code and tests are in +# scope; the build/release path, container definitions and runtime config are not — those +# are how a change to a service becomes a change to production. +# @policy stack: node, typescript, npm, pnpm, jest, vitest +# @policy author: signetry-registry +# @policy blocks: .github/workflows/deploy.yml, Dockerfile, docker-compose.yml, k8s/deployment.yaml, .env.production, next.config.js +# @policy allows: src/routes/users.ts, src/lib/db.ts, test/users.test.ts, src/index.ts, package.json +version: 2 +task_type: feature-work + +allowed_paths: + - "src/**" + - "lib/**" + - "test/**" + - "tests/**" + - "**/*.test.ts" + - "**/*.test.js" + - "**/*.spec.ts" + - "package.json" + - "README.md" + +# Editing the service is not the same as editing how the service is built, shipped or +# configured at runtime. Build config (next.config.js, webpack, vite) also executes on +# every build machine, which makes it a supply-chain surface rather than app code. +forbidden_paths: + - ".github/**" + - "Dockerfile*" + - "docker-compose*" + - "k8s/**" + - "helm/**" + - "infra/**" + - "terraform/**" + - "next.config.*" + - "webpack.config.*" + - "vite.config.*" + - "**/*secret*" + - "**/.env*" + +max_files_changed: 15 +required_checks: + - "npm test" + +policy_owner: your-team +policy_version: "1.0" diff --git a/signetry_core/policies/python-library.yaml b/signetry_core/policies/python-library.yaml new file mode 100644 index 0000000..869b03f --- /dev/null +++ b/signetry_core/policies/python-library.yaml @@ -0,0 +1,41 @@ +# @policy id: python-library +# @policy title: Python library (src layout, pytest) +# @policy summary: A src-layout Python package. The agent may change library code, tests and +# the project manifest, and must keep pytest green. Anything that executes at install or +# collection time stays off-limits, as does CI. +# @policy stack: python, pytest, hatchling, setuptools, poetry, uv +# @policy author: signetry-registry +# @policy blocks: .github/workflows/release.yml, setup.py, tox.ini, tests/conftest.py, .env.production, deploy/helm/values.yaml +# @policy allows: src/mylib/core.py, src/mylib/__init__.py, tests/test_core.py, pyproject.toml, README.md +version: 2 +task_type: feature-work + +allowed_paths: + - "src/**" + - "tests/**" + - "pyproject.toml" + - "README.md" + - "CHANGELOG.md" + +# setup.py, tox/nox config and conftest.py execute during install or test collection, +# so a patch there runs code on every developer machine and every CI job that touches +# the package — a far larger blast radius than the library code itself. conftest.py is +# blocked at ANY depth, including inside tests/, which the allowlist otherwise permits. +forbidden_paths: + - ".github/**" + - "setup.py" + - "setup.cfg" + - "tox.ini" + - "noxfile.py" + - "conftest.py" + - "infra/**" + - "deploy/**" + - "**/*secret*" + - "**/.env*" + +max_files_changed: 12 +required_checks: + - "pytest -q" + +policy_owner: your-team +policy_version: "1.0" diff --git a/signetry_core/policy_registry.py b/signetry_core/policy_registry.py new file mode 100644 index 0000000..e329806 --- /dev/null +++ b/signetry_core/policy_registry.py @@ -0,0 +1,171 @@ +"""The policy registry — named, audited admission policies anyone can contribute. + +Writing a first admission contract is the step where adoption stalls: the format is +simple, but deciding *what an agent should be allowed to touch in this stack* is not. +The registry answers that with ready policies for common repository shapes, and +`signetry init --policy ` drops one in. + +Two properties keep this from being a folder of untested YAML: + +**What ships is what lands.** A registry entry is a literal, valid +``.signetry/admission.yaml``. ``signetry init --policy`` copies the bytes verbatim — +no templating, no merge, no rewriting. Metadata lives in ``# @policy`` header +comments, which the contract parser ignores and a human reading the installed file +still benefits from. You can diff what you got against what is published. + +**Every entry carries its own evidence.** Each policy declares example paths it +MUST block and example paths it MUST allow, and ``tests/test_policy_registry.py`` +runs all of them through the real ``evaluate_contract``. A policy whose claims do +not hold fails CI. Nothing here is asserted without being checked. + +Entries deliberately ship placeholder ``policy_owner`` values, so a repo that adopts +one and never edits it reports ``policy_status: placeholder`` — unowned — rather than +a borrowed claim of change control. Adopting a policy is a human act; the registry +cannot perform it for you. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .pipeline.contract import Contract, _parse_admission_text, contract_from_dict + +POLICY_DIR = Path(__file__).parent / "policies" + +# `# @policy key: value` — the only metadata mechanism. Comments, so they survive +# into the installed file as documentation and stay invisible to the parser. +_META_RE = re.compile(r"^#\s*@policy\s+([a-z_]+)\s*:\s*(.*)$") + +# A value may wrap onto following lines, indented by two or more spaces after the `#`. +# The single-space form (`# a normal comment`) is NOT a continuation, which is what +# keeps the ordinary explanatory comments in a policy file out of its metadata. +_CONT_RE = re.compile(r"^#\s{2,}(\S.*)$") + +# Keys every entry must declare. Absent or empty → the entry is invalid and the +# validation test fails. There is no default for "who wrote this" or "what it blocks". +REQUIRED_META = ("id", "title", "summary", "author", "blocks", "allows") + +_LIST_KEYS = frozenset({"blocks", "allows", "stack"}) + + +@dataclass(frozen=True) +class PolicyEntry: + """One registry policy: its metadata, its literal bytes, and its parsed contract.""" + + id: str + title: str + summary: str + author: str + path: Path + text: str + blocks: tuple[str, ...] = () + allows: tuple[str, ...] = () + stack: tuple[str, ...] = () + caution: str = "" + meta: dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def contract(self) -> Contract: + """The policy parsed through the real loader — not a separate code path.""" + return contract_from_dict(_parse_admission_text(self.text), source="registry") + + def to_public(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "summary": self.summary, + "author": self.author, + "stack": list(self.stack), + "caution": self.caution or None, + "blocks": list(self.blocks), + "allows": list(self.allows), + "contract": self.contract.to_public(), + } + + +def _parse_meta(text: str) -> dict[str, Any]: + """Read the `@policy` header block at the top of a policy file. + + Parsing stops at the first line that is neither a comment nor blank — i.e. at the + contract itself. Metadata is a header, not something that can hide further down a + file, and a contributor cannot accidentally turn a mid-file comment into metadata.""" + raw_meta: dict[str, str] = {} + order: list[str] = [] + current: str | None = None + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if not stripped.startswith("#"): + break # the contract starts here; the header is over + m = _META_RE.match(stripped) + if m: + current = m.group(1) + if current not in raw_meta: + order.append(current) + raw_meta[current] = m.group(2).strip() + continue + cont = _CONT_RE.match(stripped) + if cont and current: + raw_meta[current] = f"{raw_meta[current]} {cont.group(1).strip()}".strip() + continue + current = None # a plain comment ends the current value + + meta: dict[str, Any] = {} + for key in order: + value = " ".join(raw_meta[key].split()) + if key in _LIST_KEYS: + meta[key] = tuple(p.strip() for p in value.split(",") if p.strip()) + else: + meta[key] = value + return meta + + +def _entry_from_path(path: Path) -> PolicyEntry: + text = path.read_text(encoding="utf-8") + meta = _parse_meta(text) + missing = [k for k in REQUIRED_META if not meta.get(k)] + if missing: + raise ValueError(f"{path.name}: missing @policy metadata: {', '.join(missing)}") + stated = str(meta["id"]) + if stated != path.stem: + raise ValueError(f"{path.name}: @policy id is {stated!r} but the filename says {path.stem!r}") + return PolicyEntry( + id=stated, + title=str(meta["title"]), + summary=str(meta["summary"]), + author=str(meta["author"]), + path=path, + text=text, + blocks=tuple(meta.get("blocks", ())), + allows=tuple(meta.get("allows", ())), + stack=tuple(meta.get("stack", ())), + caution=str(meta.get("caution", "")), + meta=meta, + ) + + +def available_policies() -> list[PolicyEntry]: + """Every valid registry entry, sorted by id. + + A malformed entry raises rather than being skipped: silently dropping a policy + would make ``signetry policies`` quietly under-report the registry, and a + contributor's broken file would look like it was never added at all.""" + if not POLICY_DIR.is_dir(): + return [] + return sorted((_entry_from_path(p) for p in POLICY_DIR.glob("*.yaml")), key=lambda e: e.id) + + +def policy_ids() -> list[str]: + return [e.id for e in available_policies()] + + +def load_policy(policy_id: str) -> PolicyEntry: + """Look up one policy by id. Raises ``KeyError`` naming the valid ids.""" + wanted = (policy_id or "").strip().lower() + for entry in available_policies(): + if entry.id == wanted: + return entry + raise KeyError(f"unknown policy {policy_id!r} (available: {', '.join(policy_ids()) or 'none'})") diff --git a/tests/test_policy_provenance.py b/tests/test_policy_provenance.py new file mode 100644 index 0000000..73e6649 --- /dev/null +++ b/tests/test_policy_provenance.py @@ -0,0 +1,95 @@ +"""Scaffold provenance must not read as declared provenance. + +``signetry init`` writes ``policy_owner: your-team``. Before this was fixed, +``policy_status()`` reported ``declared`` — *"Policy declares a human owner and version +(change-controlled metadata)"* — for a file no human had read, so every receipt from a +freshly initialised repo asserted change-control that did not exist. + +The rule these tests pin down: a placeholder is treated as **absent**. Not "probably +fine", not "close enough" — absent, with a distinct status saying why. +""" +from __future__ import annotations + +import pytest + +from signetry_core import is_policy_placeholder +from signetry_core.pipeline.contract import contract_from_dict + + +def _status(owner: str | None, version: str | None = "1.0") -> dict: + payload = {"version": 2, "task_type": "feature-work", "allowed_paths": ["src/**"]} + if owner is not None: + payload["policy_owner"] = owner + if version is not None: + payload["policy_version"] = version + return contract_from_dict(payload, source="test").policy_status() + + +# The exact string `signetry init` writes. If this ever reports `declared` again, the +# regression is back and every starter repo is lying in its receipts. +def test_the_string_signetry_init_writes_is_not_declared(): + assert _status("your-team")["status"] == "placeholder" + + +@pytest.mark.parametrize( + "owner", + [ + "your-team", "your-org", "YOUR-TEAM", " your-team ", "", + "[your-org]", "{your-team}", "TODO", "tbd", "changeme", "example", + "acme-corp", "placeholder", "n/a", "unset", "signetry-registry", + ], +) +def test_recognised_placeholder_forms(owner): + """Case, surrounding whitespace and <>/[]{} wrappers must not defeat the check — + those are exactly the forms a scaffold or a docs example uses.""" + assert is_policy_placeholder(owner) + assert _status(owner)["status"] == "placeholder" + + +@pytest.mark.parametrize( + "owner", + ["platform-team", "security@acme.com", "Team Yourself", "org-infra", "sre"], +) +def test_a_real_owner_is_still_declared(owner): + """The fix must not swallow legitimate owners. ``Team Yourself`` and ``org-infra`` + contain placeholder substrings; matching is on the whole value, not a substring, + so they stay declared.""" + assert not is_policy_placeholder(owner) + assert _status(owner)["status"] == "declared" + + +def test_missing_owner_is_incomplete_not_placeholder(): + """The three statuses are distinguishable: nothing declared is ``incomplete``, + scaffold text is ``placeholder``. Collapsing them would lose the actionable part.""" + assert _status(None, None)["status"] == "incomplete" + assert _status("platform-team", None)["status"] == "incomplete" + + +def test_every_status_explains_itself(): + """A consumer that surfaces the status to a human needs a note with it — otherwise + ``placeholder`` is just a word and nobody knows what to do about it.""" + for owner, version in [("platform-team", "1.0"), ("your-team", "1.0"), (None, None)]: + result = _status(owner, version) + assert result["note"], f"{result['status']}: empty note" + assert len(result["note"]) > 40 + + placeholder_note = _status("your-team")["note"] + assert "your-team" in placeholder_note # names the offending value's shape + assert "policy_owner" in placeholder_note # and the field to fix + + +def test_placeholder_owner_is_still_reported_verbatim(): + """The receipt must not hide what the file actually said. The status is the judgement; + ``owner`` stays the raw value so a reader can see the scaffold text for themselves.""" + result = _status("your-team") + assert result["owner"] == "your-team" + + +def test_empty_and_whitespace_owners_are_not_placeholders_but_incomplete(): + """An empty value is absent, which is already handled by the ``incomplete`` path. + ``is_policy_placeholder("")`` must not be True, or "" would end up in the set of + things that look like scaffold text and the two cases would blur.""" + assert not is_policy_placeholder("") + assert not is_policy_placeholder(" ") + assert _status("")["status"] == "incomplete" + assert _status(" ")["status"] == "incomplete" diff --git a/tests/test_policy_registry.py b/tests/test_policy_registry.py new file mode 100644 index 0000000..71011db --- /dev/null +++ b/tests/test_policy_registry.py @@ -0,0 +1,215 @@ +"""The registry's own evidence. + +Every shipped policy declares the paths it blocks and the paths it allows. This module +runs those declarations through the real ``evaluate_contract`` — the same function the +admission pipeline uses — so a policy whose claims do not hold fails CI instead of +misleading whoever adopts it. + +The pattern is deliberate: the registry does not get to assert that a policy works. It +has to demonstrate it, per claim, with the production code path. +""" +from __future__ import annotations + +import pytest + +from signetry_core import is_policy_placeholder +from signetry_core.cli import main as cli_main +from signetry_core.pipeline.contract import evaluate_contract, load_contract +from signetry_core.policy_registry import ( + POLICY_DIR, + REQUIRED_META, + available_policies, + load_policy, + policy_ids, +) + +ENTRIES = available_policies() +IDS = [e.id for e in ENTRIES] + +# Fail loudly if the registry is empty. Every per-policy test below is parametrized over +# ENTRIES, so an empty registry would make this whole file vacuously green — the exact +# "no evidence reads as success" failure the project exists to prevent. +def test_the_registry_is_not_empty(): + assert ENTRIES, f"no policies found in {POLICY_DIR}" + + +def test_every_yaml_file_in_the_dir_is_a_loadable_entry(): + """A file that fails to parse must not be silently skipped.""" + on_disk = {p.stem for p in POLICY_DIR.glob("*.yaml")} + assert on_disk == set(IDS), f"unloadable or unlisted policy files: {on_disk ^ set(IDS)}" + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_metadata_is_complete(entry): + for key in REQUIRED_META: + assert getattr(entry, key), f"{entry.id}: empty required metadata {key!r}" + assert entry.id == entry.path.stem + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_policy_enforces_something(entry): + """A contract with no scope rules is a no-op that ``load_contract`` would silently + replace with the default scope — so it would govern nothing while appearing to.""" + c = entry.contract + assert c.allowed_paths or c.forbidden_paths, f"{entry.id}: declares no scope at all" + assert c.max_files_changed > 0, f"{entry.id}: unbounded diff budget" + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_declared_blocks_are_actually_blocked(entry): + """Each ``@policy blocks:`` path must be refused by the real evaluator.""" + for path in entry.blocks: + result = evaluate_contract([path], entry.contract) + assert not result.passed, ( + f"{entry.id} claims to block {path!r} but the contract admits it" + ) + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_declared_allows_are_actually_allowed(entry): + """And each ``@policy allows:`` path must pass. This is the direction that catches an + over-broad forbidden glob quietly making a policy useless.""" + for path in entry.allows: + result = evaluate_contract([path], entry.contract) + assert result.passed, ( + f"{entry.id} claims to allow {path!r} but the contract refuses it: " + f"{'; '.join(result.violations)}" + ) + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_blocks_and_allows_do_not_overlap(entry): + """A path in both lists means the policy's own documentation contradicts itself.""" + overlap = set(entry.blocks) & set(entry.allows) + assert not overlap, f"{entry.id}: {overlap} declared as both blocked and allowed" + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_a_template_never_claims_declared_provenance(entry): + """The registry must not hand an adopter a policy that already looks change-controlled. + + A shipped template with a real-looking ``policy_owner`` would make every receipt + report ``policy_status: declared`` — asserting that a human owns rules nobody at the + adopting org has read. Templates must resolve to ``placeholder``.""" + contract = entry.contract + assert is_policy_placeholder(contract.policy_owner), ( + f"{entry.id}: policy_owner {contract.policy_owner!r} is not a recognised placeholder" + ) + assert contract.policy_status()["status"] == "placeholder" + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_installing_a_policy_writes_it_byte_for_byte(tmp_path, entry): + """``init --policy`` copies the published bytes. No templating, no merge, no rewrite — + so an adopter can diff their installed file against the registry and get nothing.""" + assert cli_main(["init", str(tmp_path), "--policy", entry.id]) == 0 + dest = tmp_path / ".signetry" / "admission.yaml" + assert dest.read_text(encoding="utf-8") == entry.text + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_an_installed_policy_survives_the_real_loader(tmp_path, entry): + """The bytes we ship must round-trip through ``load_contract`` unchanged in meaning. + + This is what catches a policy that parses in isolation but gets its scope replaced by + the default contract on load (the "present but empty" merge path).""" + assert cli_main(["init", str(tmp_path), "--policy", entry.id]) == 0 + loaded = load_contract(tmp_path) + assert loaded.source == "repo" + assert loaded.allowed_paths == entry.contract.allowed_paths + assert loaded.forbidden_paths == entry.contract.forbidden_paths + assert loaded.max_files_changed == entry.contract.max_files_changed + assert loaded.required_checks == entry.contract.required_checks + # And the blocks it advertised still hold after a real load from disk. + for path in entry.blocks: + assert not evaluate_contract([path], loaded).passed + + +# --- loader behaviour -------------------------------------------------------- + + +def test_load_policy_is_case_insensitive_and_trims(): + first = IDS[0] + assert load_policy(f" {first.upper()} ").id == first + + +def test_unknown_policy_raises_and_names_the_valid_ids(): + with pytest.raises(KeyError) as exc: + load_policy("no-such-policy") + message = str(exc.value) + assert "no-such-policy" in message + for pid in IDS: + assert pid in message + + +def test_policy_ids_matches_available_policies(): + assert policy_ids() == IDS + + +@pytest.mark.parametrize("entry", ENTRIES, ids=IDS) +def test_to_public_is_json_serializable(entry): + import json + + payload = entry.to_public() + json.loads(json.dumps(payload)) + assert payload["id"] == entry.id + assert payload["contract"]["policy_status"]["status"] == "placeholder" + + +# --- CLI surface ------------------------------------------------------------- + + +def test_policies_command_lists_every_entry(capsys): + assert cli_main(["policies"]) == 0 + out = capsys.readouterr().out + for entry in ENTRIES: + assert entry.id in out + assert entry.title in out + + +def test_policies_json_is_machine_readable(capsys): + import json + + assert cli_main(["policies", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert [p["id"] for p in payload["policies"]] == IDS + + +def test_init_with_unknown_policy_fails_without_writing(tmp_path, capsys): + assert cli_main(["init", str(tmp_path), "--policy", "nope"]) == 2 + assert not (tmp_path / ".signetry" / "admission.yaml").exists() + err = capsys.readouterr().err + assert "nope" in err and IDS[0] in err + + +def test_init_prints_the_caution_when_a_policy_carries_one(tmp_path, capsys): + """A policy that documents a risk must surface it at the moment of adoption, not only + in a file the adopter may never reopen.""" + cautioned = [e for e in ENTRIES if e.caution] + assert cautioned, "expected at least one policy to carry a @policy caution" + entry = cautioned[0] + assert cli_main(["init", str(tmp_path), "--policy", entry.id]) == 0 + out = capsys.readouterr().out + assert "CAUTION" in out + assert entry.caution.split(".")[0][:40] in out + + +def test_init_tells_the_adopter_the_policy_is_unowned(tmp_path, capsys): + assert cli_main(["init", str(tmp_path), "--policy", IDS[0]]) == 0 + out = capsys.readouterr().out + assert "policy_owner" in out + + +def test_init_without_a_policy_still_writes_the_starter(tmp_path): + """The registry is additive — the bare `signetry init` path is unchanged.""" + assert cli_main(["init", str(tmp_path)]) == 0 + text = (tmp_path / ".signetry" / "admission.yaml").read_text() + assert "task_type: dependency-remediation" in text + assert "@policy" not in text + + +def test_policy_respects_force_like_the_starter_does(tmp_path): + assert cli_main(["init", str(tmp_path), "--policy", IDS[0]]) == 0 + assert cli_main(["init", str(tmp_path), "--policy", IDS[-1]]) == 1 # refuses to clobber + assert cli_main(["init", str(tmp_path), "--policy", IDS[-1], "--force"]) == 0 + assert (tmp_path / ".signetry" / "admission.yaml").read_text() == load_policy(IDS[-1]).text