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
252 changes: 215 additions & 37 deletions .github/workflows/pr-approval-agent.yml

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions .stamphog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# .stamphog

Declarative policy for the stamphog PR-approval merge gate (`tools/pr-approval-agent/`).
The engine loads these files from the checked-out working tree at run time.
Engine and policy are vendored into other repos (see the note in `tools/pr-approval-agent/README.md`), so format changes here need those copies re-synced too.

## What lives here

- `policy.yml` - the global machine policy: deny categories, allow-list, size gate, tier thresholds, dismiss-time triviality rules, the folder delegation contract, and the ownership source (the `hogli-resolver` input that feeds the reviewer's advisory team context via the shared hogli resolver). Trusted data. Each rule's `rationale` records why the rule became what it is (which false positives drove an exclusion, and when) - historical justification like a commit message, not a claim about the present.
- `review-guidance.md` - the trusted review-norms prose injected into the reviewer's system prompt. Ordinary repo-formatted markdown. Editing it changes the production prompt directly, so update deliberately - the `stamphog_policy` deny guarantees a human reviews every change.

## Proposing a policy change

Open a PR that edits these files.
Stamphog can never auto-approve it: the `stamphog_policy` deny category matches `.stamphog/**`, any `AGENT_APPROVALS.md`, and `tools/pr-approval-agent/**`, so every change to the gate's own policy or engine routes to a human reviewer.
The loader also hard-fails if that self-governance entry is ever missing, so it cannot be dropped silently.

## Per-folder overrides (`AGENT_APPROVALS.md`)

A folder may carry an `AGENT_APPROVALS.md` with a `stamphog:` frontmatter block plus advisory prose.
Resolution:

- Every `AGENT_APPROVALS.md` at or above a changed file governs it: guidance accumulates outermost first, and a child file adds to its ancestors rather than replacing them.
- For the delegated `size_gate.max_files`, the nearest file on the chain with a valid grant wins for its files (within the contract ceiling); files whose chain grants nothing belong to the global pool.
- The frontmatter is a positive allow-list: only keys named in the `overrides` contract in `policy.yml` are read, within their ceilings. Anything else (unknown key, out-of-bounds value, unparseable frontmatter) invalidates the whole file - frontmatter and prose. An invalid file contributes nothing itself, but it does not cancel its ancestors: files under it still ride an ancestor's grant, or fall to the global pool if the chain grants nothing. Rationale: an author who can write an invalid file could equally delete it, so treating invalid as absent grants no extra power, and every `AGENT_APPROVALS.md` edit is human-reviewed via the `stamphog_policy` deny anyway.
- The prose is untrusted advisory guidance. It is sanitized, length-capped, and injected inside the reviewer prompt's untrusted region; it can never override the deny rules or the refusal criteria.

### Mixed PRs get mixed leniency

Each scope's files are counted against that scope's own file ceiling, so a grant covers exactly the files that resolve to it (the nearest valid grant on their chain) and nothing else.
Example: a PR changing 30 files under `products/visual_review/` (ceiling 50) plus 19 files elsewhere (global ceiling 20) passes, because each budget fits.
Add a 21st global file and the PR is denied for the global budget, no matter how much headroom the folder still has.
Files whose chain grants no valid `max_files` (no folder file, prose-only, or only invalid grants) count against the global budget, so splitting files across pseudo-scopes can never inflate the allowance.
The line ceiling stays a single global total; it is not delegable.

## Delegation contract

The set of keys a folder file may override lives under `overrides` in `policy.yml` (currently just `size_gate.max_files`, ceiling 50).
deny, allow, dismiss, tiers, and `size_gate.max_lines` are non-delegable by construction - they are absent from the contract and cannot be granted from a folder file.
217 changes: 217 additions & 0 deletions .stamphog/policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# Stamphog merge-gate policy - see .stamphog/README.md for how changes are
# proposed and reviewed. Keep pattern scalars single-quoted so regex
# backslashes survive YAML parsing.
version: 1
deny:
auth:
description: 'Authentication and authorization surfaces.'
rationale: "Only file paths hard-deny; titles surface as scrutiny flags. Past participles (authenticated/authorized) live in titles-only because as path patterns they hard-deny the wrong things (web analytics' authorized_urls.py is domain config, not the auth system). session/token/permission are path-only with tighter compounds because they match too broadly in titles and non-auth paths (SessionAnalysisWarning, tokenizer, permission helpers)."
match:
any:
- 'auth'
- 'login'
- 'signup'
- 'oauth'
- 'saml'
- 'sso'
- 'oidc'
- 'credential'
- 'password'
- '2fa'
- 'mfa'
- 'authentication'
- 'authenticate'
- 'authorize'
- 'authorization'
- 'two[_-]?factor'
titles:
- 'authenticated'
- 'authorized'
paths:
- 'session_auth'
- 'session_token'
- 'auth/session'
- 'auth/token'
- 'permission'
exempt_path_prefixes:
- 'products/warehouse_sources/backend/temporal/data_imports/sources/'
crypto_secrets:
description: 'Cryptography, secrets, and key material.'
rationale: 'key/secret/cert/signing are too broad for titles (key matches keyboard, hotkey, localStorage key), so they are path-only with compound patterns.'
match:
any:
- 'crypto'
- 'encrypt'
- 'decrypt'
- 'vault'
paths:
- 'secret'
- 'api[_-]?key'
- 'secret[_-]?key'
- 'private[_-]?key'
- 'signing[_-]?key'
- 'certificate'
- '\.env'
- '\.pem'
migrations:
description: 'Database and schema migrations.'
rationale: 'The migrations/ substring is load-bearing - it also catches rust *_migrations/ dirs applied by sqlx at deploy.'
match:
paths:
- 'migrations/'
- 'schema_change'
infra_cicd:
description: 'Infrastructure, CI, and deployment artifacts.'
rationale: 'routing and bare deploy are excluded on purpose: every historical match was app-level (DRF routers, message-routing tests, deploy-timing docs), never infrastructure. Narrow deploy literals (bin/deploy, deploy.sh, .github/pr-deploy) cover real deployment artifacts without the false positives.'
match:
any:
- 'terraform'
- 'kubernetes'
- 'helm'
paths:
- 'k8s'
- 'dockerfile'
- 'docker-compose'
- '\.github/workflows'
- '\.github/pr-deploy'
- 'iam'
- 'cloudflare'
- 'cdn'
- 'waf'
- '(?:^|/)bin/deploy'
- 'deploy\.sh'
billing:
description: 'Payments and billing.'
rationale: 'subscription is excluded on purpose: in this repo it means scheduled insight/report deliveries, not payments. Real billing surfaces still match via the other words.'
match:
any:
- 'billing'
- 'payment'
- 'stripe'
- 'invoice'
- 'pricing'
exempt_path_prefixes:
- 'products/warehouse_sources/backend/temporal/data_imports/sources/'
public_api:
description: 'Public API contracts and schemas.'
rationale: 'Changes to the published API surface need human review.'
match:
any:
- 'openapi'
- 'api_schema'
- 'swagger'
- 'public_api'
deps_toolchain:
description: 'Dependency lockfiles and toolchain/build files.'
rationale: "All path-only literal filenames. Manifests deliberately don't hard-deny: without a lockfile change they cannot pull in third-party code, and manifest scripts/hooks are guarded by the reviewer prompt and kept out of the T0 fast path. requirements.txt stays (pins installed code directly). .nvmrc/.tool-versions stay (they change the runtime for every CI job). Makefile/Dockerfile stay (they execute)."
# NOTE: partially code-sourced. The lockfile-name path patterns are derived
# in the loader from DEPENDENCY_ECOSYSTEMS (re.escape over every ecosystem's
# lockfiles) and spliced in ahead of the literals below - do not copy them
# here. Only the non-derived literal patterns live in this file.
match:
paths:
- 'requirements[-\w]*\.(txt|in)'
- 'Makefile'
- 'Dockerfile'
- '\.tool-versions'
- '\.nvmrc'
stamphog_policy:
description: "Stamphog's own policy files, engine, and gate inputs."
rationale: "Changes to the merge gate's own policy or engine always require human review - the gate cannot be trusted to approve edits to itself. Ownership sources (CODEOWNERS, owners.yaml, product.yaml owners) are gate inputs: an ownership edit changes which team the reviewer treats as owning future PRs, and owners.yaml/product.yaml would otherwise ride the .yaml allow-list to a T0 auto-approve (Jul 2026 review finding)."
match:
paths:
- '\.stamphog/'
- 'AGENT_APPROVALS\.md'
- 'tools/pr-approval-agent/'
- 'products/stamphog/backend/logic/policy_defaults/'
- 'tools/owners/'
- 'CODEOWNERS'
- 'owners\.yaml'
- 'product\.yaml'
allow:
path_patterns:
- 'docs/'
- 'README'
- 'CHANGELOG'
- 'LICENSE'
- 'CONTRIBUTING'
- '.github/CODEOWNERS'
- '.gitignore'
- '.editorconfig'
- 'generated/'
- '__snapshots__/'
extensions_only:
- '.cfg'
- '.csv'
- '.gif'
- '.ico'
- '.ini'
- '.jpeg'
- '.jpg'
- '.json'
- '.lock'
- '.md'
- '.mdx'
- '.png'
- '.rst'
- '.snap'
- '.svg'
- '.toml'
- '.txt'
- '.webp'
- '.yaml'
- '.yml'
size_gate:
max_lines: 1200
max_files: 30
tiers:
t1_subclasses:
T1a-trivial:
max_lines: 20
max_files: 3
breadth: 'single-area'
T1b-small:
max_lines: 100
max_files: 5
breadth: 'not-cross-cutting'
T1c-medium:
max_lines: 300
max_files: 15
breadth: 'not-cross-cutting'
dismiss:
trivial_extensions:
- '.md'
- '.mdx'
trivial_name_prefixes:
- 'readme'
- 'changelog'
test_regex: '(?:^|/)(?:__tests__|tests?|fixtures)/|(?:^|/)test_[^/]+\.py$|_test\.(py|go)$|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|(?:^|/)conftest\.py$'
generated_regex: '(?:^|/)generated/.*\.(ts|tsx|js|jsx|json|md|snap|pyi|txt)$|\.gen\.(ts|tsx|js|jsx)$|\.generated\.(ts|tsx|js|jsx)$|^frontend/src/queries/schema/'
overrides:
'size_gate.max_files':
ceiling: 50
# Author-familiarity signal - judgment layer only, never a gate. Non-delegable
# (absent from `overrides`). STRONG = blame overlap ≥ min_blame_overlap_pct;
# MODERATE = both of its keys satisfied; else NONE.
# Rationale: calibrated against 120 days of refused/escalated verdict outcomes
# (Jul 2026 backtest). Blame overlap was the only metric that rose monotonically
# with the human rubber-stamp rate; prior-PR counts, previously-modified-file
# fractions, and recency were flat at every cutoff, so STRONG is blame-only and
# MODERATE is advisory. Tuning these is a YAML-only diff.
familiarity:
strong:
min_blame_overlap_pct: 70
moderate:
min_prior_prs: 5
max_days_since_touch: 180
# Ownership sources feeding the reviewer's team context - advisory only, never a
# gate. The hogli-resolver source delegates to the shared hogli OwnersResolver,
# which walks the distributed owners.yaml / product.yaml files from the repo root
# (the `path` locator) and merges nearest-file-wins, so stamphog reads the same
# view the reviewer auto-assigner builds. Sources are read from the master
# checkout (the workflow pins ref: master), so a PR editing them cannot influence
# its own review. Locators must stay repo-relative (no absolute paths, no '..').
ownership:
sources:
- path: .
format: hogli-resolver
Loading
Loading