The five-layer governance stack from CLAUDE.md Is Not Enough: The Governance Stack for Agentic Development, as a drop-in starter kit. Copy the templates below into your project, customize the placeholders, and you have the document foundation, the runtime-enforcement hook, and the external-validation CI workflow described in the article. These are starting points, not policy — read them, edit them, and treat the placeholders as prompts for project-specific decisions.
Install order
CONSTITUTION.md— Defines the project's governing principles and the decision order that resolves conflicts between them.DIRECTIVES.md— Converts the constitution's principles into enforceable rules at three severity levels: Critical, Important, Recommended.SECURITY.md— Defines vulnerability scope, reporting, severity, and response targets — including agent-specific concerns like prompt injection and hook bypass.AGENTS.md— Open-standard project orientation for AI coding agents — file map, commands, working rules, precedence.CLAUDE.md— Claude-specific orientation that supplements AGENTS..claude/hooks/pre-tool-use.js— PreToolUse:Bash hook that blocks direct commits and pushes to the protected branch, including nested shell bypasses..claude/settings.json— Registers the pre-tool-use hook against PreToolUse:Bash and declares the project's Bash permission allow/deny rules..githooks/pre-push— Git pre-push hook — defense-in-depth at the git boundary for cases the PreToolUse hook cannot reliably model from the Bash payload (detached HEAD, `git rev-parse` failures, payloads run outside the working tree).tests/test_pre_tool_use_hook.py— Python standard-library `unittest` regression suite that exercises the hook end-to-end..github/workflows/governance.yml— Four-job CI workflow — lint and test, static analysis, dependency scan, and hook regression — with every third-party action pinned to a commit SHA to defend against mutable-tag supply-chain risk.agentic-governance-stack/README.md— Step-by-step install instructions written for a coding agent.
Quick start: hand this prompt to your agent
Paste the prompt below into a fresh Claude Code (or compatible agent) session inside the project you want to govern. The agent will fetch this page, walk through the templates in order, customize the placeholders with you, and open a pull request with the stack installed.
You are installing the EthereaLogic Agentic Governance Stack into the current project. Templates and instructions live at https://etherealogic.ai/agentic-governance-stack-templates/
Steps:
1. Read the article first: https://etherealogic.ai/claude-md-is-not-enough-the-governance-stack-for-agentic-development/
2. Open https://etherealogic.ai/agentic-governance-stack-templates/. Each template on that page is wrapped in <figure class="codeblock"> with data-template-path indicating where it belongs in the project tree.
3. Locate the section heading 'The templates' on the page. Every <figure class="codeblock"> AFTER that heading is a project file; copy the exact contents of its inner <pre><code> element into a file at the path given by data-template-path, relative to the project root. Mark .claude/hooks/pre-tool-use.js and .githooks/pre-push as executable. Do NOT install the agent-install-prompt.txt block above 'The templates' — that block is the prompt you are currently following, not a project file.
4. Wire the git pre-push hook:
git config --local core.hooksPath .githooks
5. Customize the placeholders in CONSTITUTION.md, DIRECTIVES.md, SECURITY.md, AGENTS.md, and CLAUDE.md for this project. Do not leave the contact email or stack tables as-is.
6. Replace the SHA placeholders in .github/workflows/governance.yml with current SHAs from each action's release page.
7. Verify the Claude hook is wired by running:
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
| node .claude/hooks/pre-tool-use.js
The expected exit code is 2 with a BLOCKED message on stderr.
8. Run the hook regression suite:
python3 -m unittest tests/test_pre_tool_use_hook.py -v
All tests must report OK. The first two assert the empty-stub and missing-registration failure modes are closed; the remaining tests exercise the hook end-to-end against real temporary git repositories. The suite uses only the Python standard library. A failure here is a CI-blocking event.
9. Verify the git pre-push hook by running:
printf 'refs/heads/feature abc123 refs/heads/main 000\n' \
| bash .githooks/pre-push
The expected exit code is 1 with a BLOCKED message on stderr.
10. Confirm with the operator that server-side branch protection is configured on the remote (e.g., GitHub branch protection on main).
11. Open a pull request with the templates installed. Do not push directly to main; the hooks you just installed will block it, and branch protection will reject it on the remote regardless.
12. Report back to the operator with the PR URL and a one-paragraph summary of what was installed and what was customized.
One-shot installation prompt. Paste into a fresh agent session inside your project root and the agent will install the full stack from the templates below.
The templates
Each template below is a self-contained file. The figure caption shows the destination path relative to your project root. The Copy button copies the body to your clipboard; Download saves it as a file with the correct extension. Both pull from the same source — what you see is exactly what your agent or you will install.
CONSTITUTION.md
# Constitution
The constitution defines the governing principles of this project and the
**decision order** in which conflicts between them are resolved.
When two principles disagree, the lower-numbered one wins. The list
below is the priority order; principle 1 dominates principle 2,
principle 2 dominates principle 3, and so on.
## Article I — Decision Order
When principles conflict, the agent and the team apply them in this order,
top to bottom:
1. **Safety.** No action that risks data loss, credential exposure, or
destructive change to shared systems is acceptable, regardless of
downstream cost.
2. **Evidence traceability.** A claim must be verifiable from artifacts the
reviewer can re-run. Speed gains do not justify unverified claims.
3. **Correctness.** A working incorrect answer is worse than a slower correct
one.
4. **Determinism.** Repeated runs from the same starting state should
produce the same result.
5. **Throughput.** Where the four principles above are satisfied, prefer the
shortest path to a working change.
6. **Aesthetics.** Naming, formatting, and layout matter, but not at the cost
of any principle above.
## Article II — Principles
The decision order operates on the following principles. Each principle is
brief on purpose; specific enforcement lives in `DIRECTIVES.md`.
- **Reproducibility.** Every result a contributor reports must be reproducible
from the repository state at that moment, by another contributor or by CI,
without privileged access.
- **Evidence over assertion.** A test report that says "PASS" without machine-
verifiable output and a human-readable artifact does not satisfy any review
requirement of this project.
- **Production-first.** The default state of any file shipped from this
repository is production-ready. Placeholder content, mocked values, and
TODOs are not acceptable in the production scan root.
- **Least privilege.** Tools and humans operate with the minimum permissions
required for the task.
- **Reviewability.** Changes are introduced in increments small enough to be
reviewed end-to-end by another contributor.
- **Documented intent.** A change that is not obvious from the diff is
explained in the commit message, the PR description, or both.
## Article III — Scope
This constitution governs work performed inside this repository, by humans,
agents, automated tooling, and CI. It does not bind external systems or
upstream dependencies.
## Article IV — Amendment
The constitution may be amended only by a pull request that:
1. Modifies this file.
2. Includes a written rationale in the PR description.
3. Receives review from at least one human maintainer.
Agents may propose amendments. Agents may not merge them.
---
*Adapted from the Agentic Governance Stack templates published at
[etherealogic.ai](https://etherealogic.ai/). Customize for your project's
constraints — the decision order matters more than the specific principles.*
Defines the project's governing principles and the decision order that resolves conflicts between them.
DIRECTIVES.md
# Directives
Directives convert the principles in `CONSTITUTION.md` into enforceable rules
at three severity levels. The constitution states what we believe; this file
states what we will and will not do.
Severity:
- **Critical** — blocking. Violations stop the change from landing. No bypass
without an explicit waiver recorded in the PR.
- **Important** — requires written justification to bypass, in the PR
description.
- **Recommended** — good practice. Skipping is allowed without justification
but should be rare.
Each directive carries an ID. Cite the ID in commit messages or PR comments
when a directive is involved.
## Critical
- **CRIT-001 — No direct commits or pushes to `main` / `master`.** All
changes land via pull request. Enforced by **three independent layers**,
none of which is sufficient on its own:
1. The Claude Code `PreToolUse:Bash` hook
(`.claude/hooks/pre-tool-use.js`) catches Bash invocations that
name a protected branch directly, that use broad-mode flags
(`--all` / `--mirror`), and — by resolving the working-tree
branch via `git rev-parse --abbrev-ref HEAD` — bare `git push`
and commit-producing subcommands (`commit`, `merge`, `rebase`,
`cherry-pick`, `revert`, `am`, `pull`) while `HEAD` is on a
protected branch. Nested shell wrappers
(`bash -c "git push origin main"`) and compound commands are
also recognized. It is advisory for the harness, not a security
boundary.
2. The local git pre-push hook (`.githooks/pre-push`, wired with
`git config --local core.hooksPath .githooks`) is the
defense-in-depth layer. It validates every push at the git
boundary against the remote refs, covering edge cases the
PreToolUse hook cannot reliably model from the Bash payload
alone (e.g., detached HEAD, `git rev-parse` failures, payloads
run from outside the working tree).
3. Server-side branch protection rules on the hosted remote
(e.g., GitHub branch protection on `main`) is the final
authoritative gate. Configure it on the remote and require pull
requests for the protected branch.
- **CRIT-002 — No fabricated metrics.** A reported number must trace to a
reproducible artifact in the repository or in CI. "Approximately X"
language is not a substitute for measurement.
- **CRIT-003 — No placeholder content in production paths.** Files in the
production scan root must not contain `TODO`, `FIXME`, `XXX`, lorem-ipsum
text, or stubbed function bodies that return constants.
- **CRIT-004 — Dual evidence for PASS claims.** Any test or check claimed as
passing must produce both a machine-verifiable artifact (exit code, JSON
report, log file) and a human-readable summary. One without the other does
not satisfy this directive.
- **CRIT-005 — No secrets in the repository.** Credentials, tokens, API
keys, and private keys are not committed. Detected secrets must be
rotated, not just removed from history.
- **CRIT-006 — No bypassing pre-commit / pre-push hooks.** `--no-verify`,
`--no-gpg-sign`, and equivalent flags are not used unless the operator has
explicitly authorized it for a single commit, with the rationale recorded
in the commit message.
## Important
- **IMP-001 — Conventional commit format.** Commit subjects use the
`type(scope): summary` form. Types: `feat`, `fix`, `chore`, `docs`,
`test`, `refactor`, `perf`, `build`, `ci`.
- **IMP-002 — Tests live alongside the code they validate.** Each new
module ships with at least one test exercising its public surface.
- **IMP-003 — Dependencies are pinned.** Lockfiles are committed. Floating
version specifiers (`^`, `~`, `*`) require justification.
- **IMP-004 — GitHub Actions are pinned to a commit SHA.** Version tags are
mutable and a documented supply-chain attack surface. See
`.github/workflows/governance.yml` for the pinning convention.
- **IMP-005 — Public-facing code passes static analysis.** Lint, type
check, and security scan must pass on every PR.
## Recommended
- **REC-001 — Prefer pure functions and explicit dependencies.** Side effects
are isolated at boundaries.
- **REC-002 — Document the WHY in commit messages, not the WHAT.** The diff
shows what changed; the message explains why.
- **REC-003 — Small, focused PRs.** A single PR addresses a single concern.
- **REC-004 — Update documentation in the same PR as the change.** Stale
docs are worse than no docs.
- **REC-005 — Annotate non-obvious code with one-line comments only when
the WHY is non-obvious.** Avoid restating the WHAT.
## Bypass and waiver
A directive may be waived for a single change by:
1. Citing the directive ID in the PR description.
2. Stating the rationale.
3. Receiving review from a human maintainer who explicitly approves the
waiver.
Waivers are not retroactive. A change that violates a directive without a
recorded waiver is reverted, not patched.
---
*Adapted from the Agentic Governance Stack templates published at
[etherealogic.ai](https://etherealogic.ai/). Tune severity to your context —
some teams promote `CRIT-006` to a hard policy enforced by branch
protection, others keep it advisory.*
Converts the constitution's principles into enforceable rules at three severity levels: Critical, Important, Recommended.
SECURITY.md
# Security Policy
This file defines what counts as a security vulnerability in this project,
how to report one, how the project will respond, and what is out of scope.
## Reporting a vulnerability
Email **security@example.com** with:
1. A clear description of the issue.
2. Reproduction steps or a proof of concept.
3. The affected version, branch, or commit.
4. Whether the issue is currently being exploited.
Do not file public issues for vulnerabilities. We will acknowledge receipt
within two business days and provide a status update within five.
If you prefer encrypted communication, our PGP key is at:
**https://example.com/.well-known/security.pgp**
## What is in scope
The following are treated as vulnerabilities subject to this policy:
- Remote code execution against the build, deploy, or runtime surface.
- Credential leakage — committed secrets, secrets exposed via logs, or
secrets exfiltrable via crafted input.
- Authentication or authorization bypass.
- Injection (SQL, command, prompt, template) into any code path that
reaches a privileged operation.
- Supply-chain compromise — including dependency tampering, registry
takeover, and unverified action invocation.
- Denial of service against shared infrastructure operated by the project.
- **Prompt injection that causes an agent to violate a CRIT-level
directive.** Agentic projects treat prompt-injection attacks that bypass
governance as in-scope security issues, not feature requests.
- **Hook bypass.** Any input or shell construction that allows a tool call
to land despite a registered enforcement hook (e.g., `PreToolUse:Bash`)
is in scope.
## What is out of scope
- Theoretical issues without a working proof of concept.
- Findings against unsupported or end-of-life versions.
- Self-XSS that requires the victim to paste the payload into their own
console.
- Rate-limiting and brute-force issues against public, unauthenticated
endpoints (these are addressed at infrastructure layers).
- Issues in third-party dependencies that have not yet been published. We
monitor dependency advisories and will respond once an upstream fix is
available.
## Severity
Severity is assigned at triage by the maintainer who acknowledges the
report. Targets below are best-effort — high-volume periods or upstream
dependencies may extend them.
| Severity | Definition | Acknowledgement | Initial response | Patched in supported branches |
|------------|--------------------------------------------------------|-----------------|------------------|-------------------------------|
| Critical | Active exploitation possible against production users | 24 hours | 72 hours | 7 days |
| High | Exploitable with low complexity, no active exploitation| 48 hours | 7 days | 30 days |
| Medium | Exploitable with elevated complexity or preconditions | 5 days | 14 days | 60 days |
| Low | Limited impact, defense-in-depth improvement | 5 days | 30 days | next release |
## Disclosure
We coordinate disclosure with the reporter. The default is:
1. Reporter notifies the project privately.
2. Project triages, assigns a severity, and produces a fix.
3. Fix is released; an advisory is published referencing the reporter (with
their consent) and the affected versions.
4. Public disclosure occurs after the advisory, typically 7 to 30 days
after the fix lands, depending on severity.
If the reporter requires a faster public timeline, we will negotiate in
good faith. We do not pursue legal action against good-faith researchers
who comply with this policy.
## Hall of fame
Researchers whose reports lead to a published advisory are credited in the
project's `THANKS.md` file (with their consent). This project does not
operate a paid bounty program at this time.
---
*Adapted from the Agentic Governance Stack templates published at
[etherealogic.ai](https://etherealogic.ai/). Replace the contact, response
targets, and the supported-version table with values that match your
team's actual capacity.*
Defines vulnerability scope, reporting, severity, and response targets — including agent-specific concerns like prompt injection and hook bypass.
AGENTS.md
# AGENTS.md
This file is the project orientation layer for AI coding agents. It covers
where things live, what commands run, and how the project expects agents to
behave. It is the open-standard counterpart to `CLAUDE.md`.
## Read order
Before substantive work, read in this order:
1. `CONSTITUTION.md` — governing principles and decision order.
2. `DIRECTIVES.md` — enforceable rules at three severity levels.
3. `SECURITY.md` — what counts as a vulnerability and how to report one.
4. This file — orientation, commands, conventions.
5. `CLAUDE.md` (Claude only) — Claude-specific notes that supplement this
file rather than replace it.
When this file and a higher-priority file disagree, the higher-priority
file wins.
## File map
| Path | Purpose |
|-----------------------------|--------------------------------------------------|
| `src/` | Production source code |
| `tests/` | Test suite |
| `docs/` | Documentation, including ADRs |
| `scripts/` | Build, verify, and deploy scripts |
| `.claude/` | Claude-specific configuration (if applicable) |
| `.github/workflows/` | CI workflows |
| `CONSTITUTION.md` | Governing principles |
| `DIRECTIVES.md` | Enforceable rules |
| `SECURITY.md` | Vulnerability policy |
| `AGENTS.md` | This file |
| `CLAUDE.md` | Claude-specific orientation |
## Tech stack
Replace this section with your project's stack. Keep it brief — agents need
the headline, not a tutorial.
| Layer | Technology |
|-------------|----------------|
| Runtime | (e.g., Node 20)|
| Language | (e.g., TS 5.4) |
| Framework | (e.g., Next 15)|
| Test runner | (e.g., Vitest) |
| Lint | (e.g., ESLint) |
## Commands
| Command | Purpose |
|-------------------|----------------------------------------|
| `make install` | Install dependencies (or `npm ci`) |
| `make test` | Run the full test suite |
| `make lint` | Lint and type-check |
| `make build` | Produce a deployable artifact |
| `make verify` | Run the local guardrails check |
Replace these with the actual commands for your project. Agents will assume
the entries here are authoritative.
## Working rules
These rules apply to every change, by humans and agents alike.
- Edit existing files in preference to creating new ones.
- Do not introduce a new dependency without justifying it in the PR.
- Do not regenerate lockfiles unless the dependency change is intentional.
- All PASS claims require dual evidence (`DIRECTIVES.md` CRIT-004).
- All commits use conventional commit format (`DIRECTIVES.md` IMP-001).
- Run `make verify` before submitting a PR.
## Agent specialization
If the project uses scoped sub-agents (e.g., a test automator, a security
reviewer, a UX specialist), document each one's scope and evidence
requirements here. The default is to use the general-purpose agent.
## Precedence
When patterns conflict, resolve in this order:
1. `CONSTITUTION.md`
2. `DIRECTIVES.md`
3. `SECURITY.md`
4. This file
5. `CLAUDE.md` and other agent-specific files
6. Inline code comments
7. Commit history
## Ground rules for agents
- An agent may propose changes to any file in this list. An agent may not
merge changes to `CONSTITUTION.md`, `DIRECTIVES.md`, or `SECURITY.md`
without human review.
- An agent that produces an output it cannot verify must say so explicitly
rather than claiming success.
- An agent that hits a runtime barrier (a hook exit, a CI failure, a
permission denial) investigates the underlying cause rather than working
around the barrier.
---
*Adapted from the Agentic Governance Stack templates published at
[etherealogic.ai](https://etherealogic.ai/). The AGENTS.md format is an
open standard governed by the Linux Foundation's Agentic AI Foundation;
this file is compatible with that standard.*
Open-standard project orientation for AI coding agents — file map, commands, working rules, precedence.
CLAUDE.md
# CLAUDE.md
This file is the Claude-specific orientation layer. It supplements
`AGENTS.md` with Claude-specific notes — slash commands, hook reference,
sub-agent definitions — without replacing the open-standard content there.
If a Claude session sees both files, read `AGENTS.md` first, then this one.
When the two disagree, `AGENTS.md` wins.
## Slash command catalog
The project ships the following slash commands under `.claude/commands/`.
Each is a policy-encoded workflow, not a shortcut.
| Command | Purpose |
|-----------------|-----------------------------------------------------------------|
| `/prime` | Read the governance files and orient before substantive work |
| `/implement` | Implement a specified change with required evidence |
| `/review` | Review a change against the constitution and directives |
| `/verify` | Run the full verification suite and report dual evidence |
| `/audit` | Independent compliance audit of the most recent change |
| `/commit` | Stage and commit using the project's conventional format |
| `/pull-request` | Open a PR with the structured description the project requires |
## Hook reference
The project registers the following hooks in `.claude/settings.json`. Each
hook either blocks an action with a non-zero exit, or passes the action
through unchanged.
| Hook event | Script | Scope |
|----------------------|-------------------------------------|-----------------------------------------------------------|
| `PreToolUse:Bash` | `.claude/hooks/pre-tool-use.js` | Catch Bash calls that explicitly name a protected branch |
A blocked tool call exits with status 2 and prints a one-line explanation.
The agent does not retry the same call after a hook block — it investigates
the cause.
The `PreToolUse:Bash` hook covers explicit-refspec pushes
(`git push origin main`), broad-mode flags (`git push --all` / `--mirror`),
nested shell wrappers (`bash -c "git push origin main"`), and — by
resolving the working-tree branch via `git rev-parse --abbrev-ref HEAD`
— bare `git push` and commit-producing subcommands (`commit`, `merge`,
`rebase`, `cherry-pick`, `revert`, `am`, `pull`) while `HEAD` is on a
protected branch. The companion `.githooks/pre-push` git-side hook is
the defense-in-depth layer for edge cases the Bash payload alone cannot
model (detached HEAD, `git rev-parse` failures, payloads run from
outside the working tree). See `DIRECTIVES.md` CRIT-001 for the full
three-layer enforcement contract.
## Sub-agents
Replace this section with your project's sub-agent definitions. The
default Claude Code installation does not require any.
If you use sub-agents, document each one's:
- **Scope** — what work it owns.
- **Evidence requirement** — what artifacts it must produce.
- **Forbidden actions** — what it cannot do regardless of prompt.
## Context conventions
- The `.claude/` directory is Claude-specific. Agents that are not Claude
should ignore it.
- The `.claude/agents/`, `.claude/commands/`, and `.claude/hooks/`
subdirectories are read by Claude Code automatically. Do not move or
rename them.
- The `.claude/settings.json` file is the single source of truth for which
hooks and permissions are active. Hooks present in `.claude/hooks/` but
not registered in `settings.json` do **not** run.
## When in doubt
A Claude session that finds itself uncertain should:
1. Stop and read `CONSTITUTION.md` again.
2. Identify which decision-order principle is in tension.
3. Apply the higher-priority principle.
4. Document the resolution in the commit message or PR description.
Asking the human operator is preferable to guessing in any case where the
constitution does not produce a clear answer.
---
*Adapted from the Agentic Governance Stack templates published at
[etherealogic.ai](https://etherealogic.ai/). This file is intentionally
short — Claude-specific knowledge belongs here, project-wide knowledge
belongs in `AGENTS.md`.*
Claude-specific orientation that supplements AGENTS.md with the slash command catalog and hook reference.
.claude/hooks/pre-tool-use.js
#!/usr/bin/env node
// This is a Node.js script: `require` and `process` are runtime globals.
// Disable no-undef for this file because Codacy's flat-config eslint
// does not honor inline `global` directives reliably and the .codacy/
// config tree is gitignored.
/* eslint-disable no-undef */
/**
* PreToolUse:Bash guard
*
* Scope: catches Bash tool calls that would commit to, or push to, a
* protected branch under the Claude Code harness. The guard inspects the
* literal Bash payload, unwraps nested shell wrappers, splits chained
* command fragments, and checks each fragment against the protected-
* branch surface.
*
* blocks (exit 2):
* - git push origin main
* - git push origin HEAD:main
* - git push origin +refs/heads/master
* - git push --all (broad-mode flag)
* - git push --mirror (broad-mode flag)
* - git push (when HEAD is on a protected branch)
* - git commit -m "..." (when HEAD is on a protected branch)
* - git merge / rebase / cherry-pick / revert / am / pull
* (when HEAD is on a protected branch)
* - bash -c "git push origin main" (sh / zsh / dash / ash variants)
* - git status && git push origin main (chained forms)
* - git reset --hard origin/main
*
* does NOT defend against (this hook is advisory for the Claude harness,
* not a security boundary):
* - a human shelling out outside the harness
* - aliases that rename `git`, base64-encoded payloads, direct
* mutation of `.git/HEAD`, sibling clones with different worktrees
*
* Exits 2 when blocked, 0 when allowed. Reads the tool payload as JSON
* from stdin. The Claude Code runtime provides the payload in this shape:
*
* {
* "tool_name": "Bash",
* "tool_input": { "command": "<the shell string>", ... }
* }
*
* Companion: `.githooks/pre-push` provides defense-in-depth at the git
* boundary for cases the Bash payload alone cannot model. Install both
* for end-to-end coverage of `DIRECTIVES.md` CRIT-001.
*
* Adapted from the Agentic Governance Stack templates published at
* https://etherealogic.ai/agentic-governance-stack-templates/
*/
'use strict';
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const PROTECTED_BRANCHES = ['main', 'master'];
// Flags that update the remote without naming a branch. Both can land
// commits on a protected branch even when no protected ref is on the
// command line, so we block unconditionally.
const BROAD_PUSH_FLAGS = new Set(['--all', '--mirror']);
// Subcommands that land a new commit on HEAD without going through
// `git commit` directly. When HEAD is on a protected branch, every one
// of these is the same operation as a direct commit on main and must be
// blocked. `pull` is in the set because it is `fetch` followed by
// `merge` or `rebase` — the failure path the guard exists to close.
const COMMIT_PRODUCING_SUBCOMMANDS = new Set([
'commit', 'merge', 'rebase', 'cherry-pick', 'revert', 'am', 'pull',
]);
function readStdin() {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function block(reason) {
process.stderr.write(`[pre-tool-use] BLOCKED: ${reason}\n`);
process.exit(2);
}
function allow() {
process.exit(0);
}
/**
* Recursively unwrap nested shell invocations of the form
* bash -c "<inner>"
* sh -lc '<inner>'
* zsh -c $'<inner>'
* so we evaluate the actual command being run, not the wrapper. The
* second return value indicates whether the original command was wrapped,
* so the block reason can mark `(via nested shell)` and the model knows
* which layer caught it.
*/
function unwrapShellWrappers(command) {
let current = command;
let unwrapped = false;
for (let i = 0; i < 8; i++) {
const m = current.match(
/^\s*(?:bash|sh|zsh|dash|ash)\s+(?:-[a-z]*c|--command)\s+(?:"([\s\S]+)"|'([\s\S]+)'|\$'([\s\S]+)')\s*$/,
);
if (!m) return [current, unwrapped];
current = m[1] || m[2] || m[3];
unwrapped = true;
}
return [current, unwrapped];
}
// Returns 2 if the character pair is a two-char unquoted separator (`&&`
// or `||`), 1 if a single-char separator (`;`, `&`, `|`, newline), or 0
// if it is not a separator. Quoted-string state is decided by the caller.
function unquotedSeparatorWidth(ch, next) {
if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) return 2;
if (ch === ';' || ch === '\n' || ch === '&' || ch === '|') return 1;
return 0;
}
// Tracks quote/escape state while iterating a shell string. The reducer
// returns true when the caller should consume the character literally
// (it was a quote/escape transition or already inside quotes); false
// when the caller is free to interpret the character as a separator.
function updateQuoteState(state, ch) {
if (state.escaped) { state.escaped = false; return true; }
if (ch === '\\' && !state.inSingle) { state.escaped = true; return true; }
if (ch === '\'' && !state.inDouble) { state.inSingle = !state.inSingle; return true; }
if (ch === '"' && !state.inSingle) { state.inDouble = !state.inDouble; return true; }
return state.inSingle || state.inDouble;
}
function splitCompound(command) {
// Split on shell separators that introduce another command in the same
// invocation: ;, &&, ||, |, & (background), and newline. Respecting
// quoted strings keeps nested shell payloads intact so they can be
// unwrapped and inspected recursively.
const fragments = [];
const state = { inSingle: false, inDouble: false, escaped: false };
let current = '';
const pushCurrent = () => {
const fragment = current.trim();
if (fragment) fragments.push(fragment);
current = '';
};
for (let i = 0; i < command.length; i++) {
const ch = command[i];
if (updateQuoteState(state, ch)) {
current += ch;
continue;
}
const width = unquotedSeparatorWidth(ch, command[i + 1]);
if (width > 0) {
pushCurrent();
i += width - 1;
continue;
}
current += ch;
}
pushCurrent();
return fragments;
}
function tokenize(fragment) {
return fragment.match(/("[^"]*"|'[^']*'|\S+)/g) || [];
}
function stripOuterQuotes(token) {
if (!token) return token;
if ((token.startsWith('"') && token.endsWith('"'))
|| (token.startsWith('\'') && token.endsWith('\''))) {
return token.slice(1, -1);
}
return token;
}
function resolveCdTarget(fragment, workingDir) {
const tokens = tokenize(fragment);
if (tokens[0] !== 'cd') return workingDir;
let target = null;
for (const token of tokens.slice(1)) {
if (token === '--' || token === '-L' || token === '-P') continue;
target = stripOuterQuotes(token);
break;
}
if (!target) return os.homedir();
if (target === '-') return workingDir;
if (target === '~') return os.homedir();
if (target.startsWith('~/')) return path.join(os.homedir(), target.slice(2));
if (path.isAbsolute(target)) return path.normalize(target);
return path.resolve(workingDir, target);
}
function isProtectedRef(ref) {
if (!ref) return false;
const bare = ref
.replace(/^\+/, '')
.replace(/^origin\//, '')
.replace(/^refs\/heads\//, '');
return PROTECTED_BRANCHES.includes(bare);
}
function explicitPushTargets(refTokens) {
if (refTokens.length >= 2) return refTokens.slice(1);
if (refTokens.length === 1) {
const only = refTokens[0];
if (only.includes(':') || isProtectedRef(only)) return [only];
}
return [];
}
/**
* Resolve the current branch via `git rev-parse --abbrev-ref HEAD`. Used
* to catch commits / pulls / merges / etc. while HEAD is on a protected
* branch — cases where the command line itself does not name the branch.
* Returns the branch name, or `null` when we cannot determine it (no
* .git directory, detached HEAD, command timed out). A null result is
* treated as "unknown, allow" by callers — the conservative direction
* here is allow, because false positives on branch detection would block
* legitimate work everywhere outside the repo.
*/
function currentBranch(workingDir) {
try {
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: workingDir,
encoding: 'utf8',
timeout: 2000,
stdio: ['ignore', 'pipe', 'pipe'],
});
if (r.status !== 0) return null;
const name = (r.stdout || '').trim();
if (!name || name === 'HEAD') return null;
return name;
} catch (_err) {
return null;
}
}
// Strip wrappers that an agent may prepend to a git invocation:
// `command git push origin main` → drop `command`
// `env GIT_TRACE=1 X=y git push origin main` → drop `env` and KEY=VAL pairs
// Returns the unwrapped token list.
function stripCommandWrappers(tokens) {
while (tokens.length) {
const head = stripOuterQuotes(tokens[0]);
if (head === 'command') {
tokens = tokens.slice(1);
continue;
}
if (head === 'env') {
let i = 1;
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(stripOuterQuotes(tokens[i]))) {
i += 1;
}
tokens = tokens.slice(i);
continue;
}
break;
}
return tokens;
}
// Flags that route `git` to documentation or version output instead of
// running a subcommand. Their presence means whatever subcommand follows
// is NOT executed, so we must not block as if it were.
const DOC_ROUTING_FLAGS = new Set(['--help', '-h', '--version']);
// Flags that take a positional value as the next token.
const GIT_FLAGS_WITH_NEXT_VALUE = new Set([
'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix',
]);
// Consume git-level options that appear *before* the subcommand —
// `git -C path <sub>` → also re-bases cwd to <path>
// `git -c key=val <sub>` → drops `-c key=val`
// `git --no-pager <sub>` → drops `--no-pager`
// Returns [tokens, cwd, docRouting]. When docRouting is true the caller
// must abort the protected-branch check entirely — `git --help push`
// shows documentation, it does not run `push`.
function peelGitOptions(tokens, workingDir) {
if (tokens[0] !== 'git') return [tokens, workingDir, false];
const out = [tokens[0]];
let cwd = workingDir;
let docRouting = false;
let i = 1;
while (i < tokens.length) {
const tok = tokens[i];
if (DOC_ROUTING_FLAGS.has(tok)) {
docRouting = true;
i += 1;
continue;
}
if (tok === '-C' && i + 1 < tokens.length) {
const target = stripOuterQuotes(tokens[i + 1]);
// Multiple -C options chain: `git -C /a -C b ...` runs in `/a/b`,
// not `<original>/b`. Resolve each relative target against the
// *previously resolved* cwd so the chain matches git's semantics.
cwd = path.isAbsolute(target) ? path.normalize(target)
: path.resolve(cwd, target);
i += 2;
continue;
}
if (GIT_FLAGS_WITH_NEXT_VALUE.has(tok) && i + 1 < tokens.length) {
i += 2;
continue;
}
if (tok === '--no-pager' || tok === '-p' || tok === '--paginate'
|| tok === '--bare' || tok === '--no-replace-objects'
|| tok === '--literal-pathspecs' || tok === '--glob-pathspecs'
|| tok === '--noglob-pathspecs' || tok === '--icase-pathspecs') {
i += 1; continue;
}
if (/^(--git-dir|--work-tree|--namespace|--super-prefix|-c)=/.test(tok)) {
// `--key=value` or `-cKEY=VAL` shorthand — single token, no
// additional positional value to skip.
i += 1; continue;
}
break;
}
out.push(...tokens.slice(i));
return [out, cwd, docRouting];
}
function checkGitFragment(fragment, workingDir) {
let tokens = tokenize(fragment);
tokens = stripCommandWrappers(tokens);
let effectiveCwd;
let docRouting;
[tokens, effectiveCwd, docRouting] = peelGitOptions(tokens, workingDir);
if (tokens[0] !== 'git') return null;
// `git --help <sub>` / `git --version` route to documentation rather
// than executing the subcommand. Don't block them.
if (docRouting) return null;
workingDir = effectiveCwd;
const sub = tokens[1];
if (sub === 'push') {
// Broad-mode flags may update protected branches without naming one.
if (tokens.slice(2).some((t) => BROAD_PUSH_FLAGS.has(t))) {
return 'git push --all/--mirror may update protected branches — block.';
}
// Explicit refspec on the push line.
const refTokens = tokens.slice(2).filter((t) => !t.startsWith('-'));
for (const refSpec of explicitPushTargets(refTokens)) {
const stripped = refSpec.replace(/^\+/, '');
const dst = stripped.includes(':') ? stripped.split(':').pop() : stripped;
if (isProtectedRef(dst)) {
return `git push to protected branch "${dst}" — open a pull request instead.`;
}
}
if (explicitPushTargets(refTokens).length > 0) return null;
// Implicit refspec: check current branch.
const branch = currentBranch(workingDir);
if (branch && PROTECTED_BRANCHES.includes(branch)) {
return `git push from protected branch "${branch}" — open a pull request instead.`;
}
return null;
}
if (COMMIT_PRODUCING_SUBCOMMANDS.has(sub)) {
const branch = currentBranch(workingDir);
if (branch && PROTECTED_BRANCHES.includes(branch)) {
return `git ${sub} on protected branch "${branch}" — switch to a feature branch first.`;
}
return null;
}
if (sub === 'reset') {
if (
tokens.includes('--hard')
&& tokens.some((token) => token === 'origin/main' || token === 'origin/master')
) {
return 'destructive reset against protected branch — investigate before forcing.';
}
return null;
}
const lastToken = tokens[tokens.length - 1];
if (sub === 'checkout' && (lastToken === '--' || lastToken === '-')) {
return 'git checkout -- discards local changes; confirm intent.';
}
return null;
}
function inspectCommand(command, initialWorkingDir, viaNestedShell = false) {
let workingDir = initialWorkingDir;
for (const fragment of splitCompound(command)) {
const [inner, wrapped] = unwrapShellWrappers(fragment);
if (wrapped) {
const nestedReason = inspectCommand(inner, workingDir, true);
if (nestedReason) return nestedReason;
continue;
}
const reason = checkGitFragment(fragment, workingDir);
if (reason) {
return viaNestedShell ? `${reason} (via nested shell)` : reason;
}
workingDir = resolveCdTarget(fragment, workingDir);
}
return null;
}
async function main() {
const raw = await readStdin();
if (!raw) allow();
let payload;
try {
payload = JSON.parse(raw);
} catch (err) {
// Fail open on payload-parse error so a runtime contract change
// does not lock the agent out of every Bash call.
process.stderr.write(`[pre-tool-use] could not parse payload: ${err.message}\n`);
allow();
}
if (payload.tool_name !== 'Bash') allow();
const command = (payload.tool_input && payload.tool_input.command) || '';
if (!command) allow();
const reason = inspectCommand(command, process.cwd());
if (reason) block(reason);
allow();
}
main().catch((err) => {
process.stderr.write(`[pre-tool-use] hook error: ${err.message}\n`);
// Fail open on hook crash so a misconfigured hook does not lock the
// agent out of every Bash call.
process.exit(0);
});
PreToolUse:Bash hook that blocks direct commits and pushes to the protected branch, including nested shell bypasses. Exits with status 2 to halt the tool call before it lands.
.claude/settings.json
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(make:*)",
"Bash(npm:*)",
"Bash(pnpm:*)",
"Bash(git status*)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git branch*)"
],
"deny": [
"Bash(git push origin main*)",
"Bash(git push origin master*)",
"Bash(git push --force*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node .claude/hooks/pre-tool-use.js" }
]
}
]
}
}
Registers the pre-tool-use hook against PreToolUse:Bash and declares the project's Bash permission allow/deny rules. The hook is inert without this registration.
.githooks/pre-push
#!/usr/bin/env bash
# Git pre-push hook — protected-branch guard.
#
# Rejects any push that lands on a protected branch on the remote.
# Acts as defense-in-depth at the git boundary for the cases where the
# Claude Code PreToolUse:Bash hook cannot reliably model the operation
# from its payload alone — detached HEAD, `git rev-parse` failures, or
# payloads run outside the working tree.
#
# The git hook system passes the local and remote refs being pushed on
# stdin, one push per line:
#
# <local ref> <local sha> <remote ref> <remote sha>
#
# Exit non-zero to abort the push.
#
# Adapted from the Agentic Governance Stack templates published at
# https://etherealogic.ai/agentic-governance-stack-templates/
#
# Install:
# chmod +x .githooks/pre-push
# git config --local core.hooksPath .githooks
#
# Verify:
# git push origin HEAD:main # should be rejected
# git push origin HEAD:feature # should be allowed
set -euo pipefail
PROTECTED_REGEX='^refs/heads/(main|master)$'
while read -r local_ref local_sha remote_ref remote_sha; do
# Skip deletions — `git push --delete` arrives with a zero local sha.
# Deleting a protected branch is also a destructive op; block it too.
if [[ "$remote_ref" =~ $PROTECTED_REGEX ]]; then
branch="${remote_ref#refs/heads/}"
if [[ "$local_sha" =~ ^0+$ ]]; then
echo "[pre-push] BLOCKED: refusing to delete protected branch '$branch'." >&2
else
echo "[pre-push] BLOCKED: push to protected branch '$branch' refused." >&2
echo "[pre-push] Open a pull request instead." >&2
fi
exit 1
fi
done
exit 0
Git pre-push hook — defense-in-depth at the git boundary for cases the PreToolUse hook cannot reliably model from the Bash payload (detached HEAD, `git rev-parse` failures, payloads run outside the working tree). Wire with `git config –local core.hooksPath .githooks`.
tests/test_pre_tool_use_hook.py
"""Regression tests for the PreToolUse:Bash protected-branch guard.
This is the minimal suite that closes the empty-stub failure mode and
exercises the hook end-to-end. Two assertions cover the silent-degradation
class — hook file non-empty and registration intact — and three execution
tests confirm the guard actually blocks and allows the right inputs.
Run with: python3 -m unittest tests/test_pre_tool_use_hook.py -v
Requires: Node.js and git on PATH.
Adapted from the Agentic Governance Stack templates published at
Agentic Governance Stack — Templates
"""
import json
import shlex
import shutil
import subprocess # nosec B404
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HOOK = ROOT / ".claude/hooks/pre-tool-use.js"
SETTINGS = ROOT / ".claude/settings.json"
def _require_executable(name: str) -> str:
executable = shutil.which(name)
if executable is None:
raise unittest.SkipTest(f"{name} is required for this test")
return executable
def _run(cmd: str, *, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]:
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}})
node = _require_executable("node")
return subprocess.run( # nosec B603
[node, str(HOOK)],
cwd=cwd,
input=payload,
capture_output=True,
text=True,
timeout=10,
check=False,
)
def _git(repo: Path, *args: str) -> None:
git = _require_executable("git")
result = subprocess.run( # nosec B603
[git, *args],
cwd=repo,
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr or result.stdout)
def _make_repo(tmp_path: Path, name: str, branch: str) -> Path:
repo = tmp_path / name
repo.mkdir()
_git(repo, "init", "-b", "main")
_git(repo, "config", "user.name", "Test User")
_git(repo, "config", "user.email", "test@example.com")
(repo / "README.md").write_text("seed\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-m", "initial")
if branch != "main":
_git(repo, "checkout", "-b", branch)
return repo
class PreToolUseHookTests(unittest.TestCase):
def test_hook_file_is_not_an_empty_stub(self):
body = HOOK.read_text(encoding="utf-8")
self.assertTrue(body.strip(), "hook file is empty")
self.assertTrue("'use strict'" in body or '"use strict"' in body)
self.assertIn("process.exit(2)", body, "hook never blocks — empty-stub regression")
def test_hook_is_registered_in_settings(self):
cfg = json.loads(SETTINGS.read_text(encoding="utf-8"))
pre = cfg.get("hooks", {}).get("PreToolUse")
# Claude Code accepts two shapes for PreToolUse:
# list-of-entries form: [ { "matcher": "Bash", "hooks": [...] } ]
# dict-by-matcher form: { "Bash": [...] }
if isinstance(pre, list):
registered = any(entry.get("matcher") == "Bash" for entry in pre)
elif isinstance(pre, dict):
registered = bool(pre.get("Bash"))
else:
registered = False
self.assertTrue(registered, "PreToolUse:Bash registration missing — hook will never fire")
def test_blocks_explicit_push_to_main(self):
self.assertEqual(_run("git push origin main").returncode, 2)
def test_blocks_nested_shell_push_to_main(self):
self.assertEqual(_run('bash -c "git push origin main"').returncode, 2)
def test_allows_push_to_feature_branch(self):
self.assertEqual(_run("git push origin feature/example").returncode, 0)
def test_blocks_broad_push_flags(self):
for flag in ("--all", "--mirror"):
with self.subTest(flag=flag):
self.assertEqual(_run(f"git push {flag}").returncode, 2)
def test_blocks_single_token_protected_push_targets(self):
for command in ("git push main", "git push origin:main"):
with self.subTest(command=command):
self.assertEqual(_run(command).returncode, 2)
def test_blocks_commit_producing_commands_on_protected_branch(self):
with tempfile.TemporaryDirectory(prefix="hookcheck-") as tmpdir:
protected_repo = _make_repo(Path(tmpdir), "protected", "main")
for command in ('git commit -m "blocked"', "git merge feature/example", "git rebase feature/example"):
with self.subTest(command=command):
self.assertEqual(_run(command, cwd=protected_repo).returncode, 2)
def test_resolves_current_branch_from_effective_working_directory(self):
with tempfile.TemporaryDirectory(prefix="hookcheck-") as tmpdir:
tmp_path = Path(tmpdir)
protected_repo = _make_repo(tmp_path, "protected", "main")
feature_repo = _make_repo(tmp_path, "feature", "feature/example")
blocked = _run(
f'cd {shlex.quote(str(protected_repo))} && git commit -m "blocked"',
cwd=feature_repo,
)
allowed = _run(
f'cd {shlex.quote(str(feature_repo))} && git commit -m "allowed"',
cwd=protected_repo,
)
self.assertEqual(blocked.returncode, 2)
self.assertEqual(allowed.returncode, 0)
def test_blocks_nested_shell_inside_compound_chain(self):
with tempfile.TemporaryDirectory(prefix="hookcheck-") as tmpdir:
tmp_path = Path(tmpdir)
protected_repo = _make_repo(tmp_path, "protected", "main")
feature_repo = _make_repo(tmp_path, "feature", "feature/example")
command = (
"git status && bash -c "
+ shlex.quote(f"cd {shlex.quote(str(protected_repo))} && git push")
)
self.assertEqual(_run(command, cwd=feature_repo).returncode, 2)
def test_blocks_command_wrapper_prefix(self):
# `command` shell builtin bypasses aliases — make sure it doesn't
# bypass the hook.
self.assertEqual(_run("command git push origin main").returncode, 2)
def test_blocks_env_wrapper_prefix(self):
# `env [VAR=VAL ...] git ...` is a common way to set git's
# environment variables for one invocation.
for command in (
"env GIT_TRACE=1 git push origin main",
"env A=1 B=2 git push origin main",
):
with self.subTest(command=command):
self.assertEqual(_run(command).returncode, 2)
def test_blocks_git_dash_C_prefix(self):
# `git -C <path>` runs the subcommand as if invoked from <path>.
# The destination ref is still `main`, so the explicit-refspec
# check must fire regardless of the -C path.
self.assertEqual(_run("git -C /tmp push origin main").returncode, 2)
def test_allows_doc_routing_flags(self):
# `git --help <sub>` and `git --version` route to documentation
# and do NOT execute <sub>. The hook must not flag these as
# protected-branch operations even when <sub> happens to be one.
with tempfile.TemporaryDirectory(prefix="hookcheck-") as tmpdir:
protected_repo = _make_repo(Path(tmpdir), "protected", "main")
for command in (
"git --help push",
"git -h push",
"git --version",
"git --help commit",
):
with self.subTest(command=command):
self.assertEqual(_run(command, cwd=protected_repo).returncode, 0)
def test_chained_dash_C_resolves_relative_to_prior_cwd(self):
# `git -C /a -C b ...` runs in `/a/b` per git semantics. A naive
# implementation that re-bases each relative -C against the
# original cwd would let `git -C protected -C . push` slip
# through from a feature directory.
with tempfile.TemporaryDirectory(prefix="hookcheck-") as tmpdir:
tmp_path = Path(tmpdir)
protected_repo = _make_repo(tmp_path, "protected", "main")
feature_repo = _make_repo(tmp_path, "feature", "feature/example")
command = (
f"git -C {shlex.quote(str(protected_repo))} -C . push"
)
self.assertEqual(_run(command, cwd=feature_repo).returncode, 2)
def test_blocks_git_dash_c_config_prefix(self):
# `git -c key=val` injects a one-shot config and then runs the
# subcommand. Multiple -c flags must all be peeled away.
for command in (
"git -c core.sshCommand=ssh push origin main",
"git -c http.sslVerify=false -c user.email=x@y push origin main",
):
with self.subTest(command=command):
self.assertEqual(_run(command).returncode, 2)
if __name__ == "__main__":
unittest.main()
Python standard-library `unittest` regression suite that exercises the hook end-to-end. Catches the empty-stub failure mode (file present but no logic) and the missing-registration failure mode that together produced the GovForge incident the article describes. No third-party packages required.
.github/workflows/governance.yml
# Governance CI workflow
#
# Runs four independent jobs on every push and pull request:
# 1. lint-and-test — quality (lint + unit tests)
# 2. security — static analysis
# 3. dependencies — vulnerability scan against the lockfile
# 4. hook-test — exercise the PreToolUse:Bash protected-branch
# guard end-to-end so an empty-stub regression of
# `.claude/hooks/pre-tool-use.js` blocks merge.
#
# Every third-party action is pinned to a specific commit SHA, not a
# version tag, to defend against the mutable-tag supply-chain attack
# class. Replace each SHA below with the current pinned SHA from the
# action's release page when adopting this template.
#
# Adapted from the Agentic Governance Stack templates published at
# https://etherealogic.ai/agentic-governance-stack-templates/
name: governance
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
name: Lint and test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# actions/checkout@v4 — replace with the pinned SHA for v4
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# actions/setup-node@v4 — replace with the pinned SHA for v4
- name: Setup Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test -- --reporter=verbose
# codecov/codecov-action@v4 — replace with the pinned SHA for v4
- name: Upload coverage
if: success()
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238
with:
fail_ci_if_error: true
files: ./coverage/lcov.info
security:
name: Static analysis
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# github/codeql-action/init@v3 — replace with the pinned SHA for v3
- name: Initialize CodeQL
uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84
with:
languages: javascript-typescript
- name: Build
run: |
if [ -f package.json ]; then npm ci && npm run build --if-present; fi
# github/codeql-action/analyze@v3 — replace with the pinned SHA for v3
- name: Analyze
uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84
dependencies:
name: Dependency scan
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# snyk/actions/node@v1.0.0 — replace with the pinned SHA you intend to use
- name: Run Snyk
uses: snyk/actions/node@9adf32b1121593767fc3c057af55b55db032dc04
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
hook-test:
name: Hook regression
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# actions/setup-node@v4 — replace with the pinned SHA for v4
- name: Setup Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a
with:
node-version: '20'
# actions/setup-python@v5 — replace with the pinned SHA for v5
- name: Setup Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b
with:
python-version: '3.12'
- name: Run hook regression tests
run: python -m unittest tests/test_pre_tool_use_hook.py -v
Four-job CI workflow — lint and test, static analysis, dependency scan, and hook regression — with every third-party action pinned to a commit SHA to defend against mutable-tag supply-chain risk.
agentic-governance-stack/README.md
# Agentic Governance Stack — Templates
A drop-in starter kit for the five-layer governance pattern described in
[*CLAUDE.md Is Not Enough: The Governance Stack for Agentic
Development*](https://etherealogic.ai/claude-md-is-not-enough-the-governance-stack-for-agentic-development/).
These templates are starting points, not policy. Read them, customize them,
and treat the placeholders as prompts for project-specific decisions.
## What is in this directory
| File | Layer |
|-------------------------------------|-------------------------------|
| `CONSTITUTION.md` | 2 — Constitutional governance |
| `DIRECTIVES.md` | 2 — Constitutional governance |
| `SECURITY.md` | 2 — Constitutional governance |
| `AGENTS.md` | 1 — Navigation |
| `CLAUDE.md` | 1 — Navigation (Claude) |
| `.claude/hooks/pre-tool-use.js` | 4 — Runtime enforcement |
| `.claude/settings.json` | 4 — Runtime enforcement |
| `tests/test_pre_tool_use_hook.py` | 4 — Runtime enforcement (test)|
| `.githooks/pre-push` | 4 — Runtime enforcement (git) |
| `.github/workflows/governance.yml` | 5 — External validation |
## Install instructions for an AI coding agent
If you are an AI coding agent reading this README, your operator has asked
you to install this governance stack into the current project. Follow these
steps in order.
1. **Read the article first.** Open
<https://etherealogic.ai/claude-md-is-not-enough-the-governance-stack-for-agentic-development/>
and read it end to end before proceeding. The templates only make sense
in context.
2. **Confirm the project root.** Run `pwd` and verify with the operator
that you are in the correct directory before writing any files.
3. **Copy the document foundation** to the project root. Create or
overwrite, in this order:
- `CONSTITUTION.md`
- `DIRECTIVES.md`
- `SECURITY.md`
- `AGENTS.md`
- `CLAUDE.md`
If any of these already exist, do **not** silently overwrite. Surface
the conflict to the operator and ask which version to keep.
4. **Customize placeholders.** Replace, at minimum:
- The contact email in `SECURITY.md`.
- The tech-stack and command tables in `AGENTS.md`.
- The principle ordering in `CONSTITUTION.md` if it does not match your
project's actual priorities. Do not leave the ordering unread — the
order is the statement.
5. **Install the Claude runtime hook.** Create the directory and copy:
- `.claude/hooks/pre-tool-use.js`
- `.claude/settings.json` (merge with any existing `settings.json` —
do not overwrite a populated file)
6. **Install the git pre-push hook.** This is the defense-in-depth guard
that validates pushes at the git boundary, covering edge cases the
Claude PreToolUse hook cannot reliably model from the Bash payload
(detached HEAD, `git rev-parse` failures, payloads run from outside
the working tree). Copy
`.githooks/pre-push` into the project's `.githooks/` directory, mark
it executable, and point the local git config at it:
```sh
chmod +x .githooks/pre-push
git config --local core.hooksPath .githooks
```
7. **Install the CI workflow.** Copy
`.github/workflows/governance.yml` into the project's
`.github/workflows/` directory. Replace each pinned SHA with the
current pinned SHA for the matching action version. Run the workflow
on a draft PR before merging it to confirm it passes.
8. **Verify the Claude hook is wired.** From the project root:
```sh
jq '.hooks.PreToolUse[] | select(.matcher=="Bash") | .hooks' \
.claude/settings.json
```
The output must list `pre-tool-use.js`. If it does not (or jq prints
nothing), the hook is not active and CRIT-001's first layer is
unenforced. The `PreToolUse` value is an array of matcher entries
per Claude Code's current settings schema; the legacy
dict-by-matcher form (`.hooks.PreToolUse.Bash`) is no longer the
canonical shape.
9. **Test the Claude hook.** Attempt a dry run that should be blocked:
```sh
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
| node .claude/hooks/pre-tool-use.js
echo "exit=$?"
```
Expected output:
```text
[pre-tool-use] BLOCKED: git push to protected branch "main" — ...
exit=2
```
If you see anything else, the hook is misconfigured. Stop and surface
the failure to the operator.
10. **Run the hook regression suite.** Copy
`tests/test_pre_tool_use_hook.py` into the project's `tests/`
directory and run it. The first two assertions close the empty-stub
and missing-registration failure modes that produced the original
incident this article documents; the remaining tests exercise the
hook end-to-end against real temporary git repositories.
```sh
python3 -m unittest tests/test_pre_tool_use_hook.py -v
```
Expected output ends with `OK`. A failure here is a CI-blocking
event — treat it with the same severity as a failing unit test.
The suite uses only the Python standard library; no extra packages
need installing.
11. **Test the git pre-push hook.** Simulate a push payload to confirm
the hook rejects pushes to a protected branch. The git hook system
receives the local and remote refs on stdin:
```sh
printf 'refs/heads/feature abc123 refs/heads/main 000\n' \
| bash .githooks/pre-push
echo "exit=$?"
```
Expected output:
```text
[pre-push] BLOCKED: push to protected branch 'main' refused.
[pre-push] Open a pull request instead.
exit=1
```
12. **Commit the templates** as a single, focused commit:
```text
chore: install agentic governance stack templates
Adds CONSTITUTION, DIRECTIVES, SECURITY, AGENTS, CLAUDE, the
Claude PreToolUse:Bash hook and its regression suite, the git
pre-push companion, and the governance CI workflow.
Source: https://etherealogic.ai/agentic-governance-stack-templates/
```
Open a pull request rather than pushing directly to `main`. The
layered hooks will block a direct push regardless.
13. **Report dual evidence to the operator** once the PR opens:
- The PR URL (machine-verifiable artifact).
- A one-paragraph summary of what was installed and what was
customized (human-readable artifact).
## Install instructions for a human
If you are a human reading this:
1. Read the article: <https://etherealogic.ai/claude-md-is-not-enough-the-governance-stack-for-agentic-development/>.
2. Copy the files into your project root with the layout above.
3. Customize the placeholders. The decision order in `CONSTITUTION.md`
matters more than the specific principles.
4. Wire the git pre-push hook with
`git config --local core.hooksPath .githooks` and confirm it is
executable.
5. Pin the GitHub Actions in `governance.yml` to current SHAs from each
action's releases page.
6. Configure server-side branch protection on the remote (e.g., GitHub
branch protection rules requiring a pull request to `main`). The
hooks are best-effort local guards; branch protection is the
authoritative gate.
7. Open a draft PR with the templates and run CI before merging.
## Customizing the templates
The templates are intentionally generic. Common customizations:
- **Tighten or loosen severity in `DIRECTIVES.md`.** Some teams promote
`CRIT-006` (no `--no-verify`) to a hard branch-protection rule. Others
treat it as advisory.
- **Add domain-specific sub-agents in `CLAUDE.md`.** Teams shipping data
pipelines often add a data-quality reviewer. Teams shipping web apps
often add a UX reviewer.
- **Extend the protected-branch hook in `pre-tool-use.js`.** The default
guards `main` and `master`. Add your release branches if you have them.
- **Replace the CI scanner trio in `governance.yml`.** The template uses
Codecov, CodeQL, and Snyk as illustrative choices. Substitute the
scanners your organization already runs.
## License
These templates are released for unrestricted use. Attribution to
EthereaLogic is appreciated but not required.
Step-by-step install instructions written for a coding agent. Drop this into a vendor folder and point your agent at it to install the stack autonomously.
What to do once the templates are in place
The document foundation is necessary but not sufficient on its own. Once the files are in your repository, four checks move the stack from orientation to governance:
- Verify the hook is registered. The
pre-tool-use.jshook is inert unless registered in.claude/settings.jsonunderhooks.PreToolUseas a matcher entry whosematcherfield equalsBashand whosehooksarray references the script. Verify withjq '.hooks.PreToolUse[] | select(.matcher=="Bash") | .hooks' .claude/settings.json. Both files are above; install both. - Run the regression suite.
tests/test_pre_tool_use_hook.pyexercises the hook end-to-end and asserts the empty-stub failure mode is closed.python3 -m unittest tests/test_pre_tool_use_hook.py -vmust reportOK. The suite uses only the standard library; the CI workflow above runs it on every PR. - Pin every GitHub Action to a SHA. The CI workflow above ships with placeholder SHAs. Replace each one with the current pinned SHA from the corresponding action’s release page before merging.
- Open a draft PR. The hook will block any direct push to
main; that’s the point. Open a draft PR with the templates installed and let CI confirm the workflow runs cleanly.
Found a bug, a missing case, or a refinement worth contributing back? The article includes a follow-up series; updates land at https://etherealogic.ai/agentic-governance-stack-templates/.