Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`**. 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)

Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
114 changes: 114 additions & 0 deletions docs/site/policy-registry.md
Original file line number Diff line number Diff line change
@@ -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/<your-id>.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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions signetry_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
evaluate_gates,
evaluate_passport,
gate_pr,
is_policy_placeholder,
issue_passport,
load_contract,
guard,
Expand Down Expand Up @@ -99,6 +100,7 @@
"Finding",
"FindingsReport",
"semgrep_available",
"is_policy_placeholder",
"load_contract",
"guard",
"GuardDecision",
Expand Down
101 changes: 95 additions & 6 deletions signetry_core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
signetry comment <report.json> # render the canonical PR comment
signetry admit-extension <skill-or-mcp-dir> # 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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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 <id>\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() {{
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions signetry_core/pipeline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
contract_from_dict,
default_contract,
evaluate_contract,
is_policy_placeholder,
load_contract,
)
from .gates import Gate, GateSummary, evaluate_gates
Expand Down Expand Up @@ -92,6 +93,7 @@
"contract_from_dict",
"default_contract",
"evaluate_contract",
"is_policy_placeholder",
"load_contract",
"guard",
"guard_path",
Expand Down
Loading
Loading