Skip to content

Policies ​

A rule written in a standard is read by the model, which may follow it. A policy is a rule a machine answers yes or no, at the moment of an action: a commit, a push, an edit. The standard explains and prevents; the policy refuses what escaped it. The same rule can live in both.

A policy acts at three points, from one file:

  1. Before generation. Each enabled policy is listed under ## Policies in the AGENTS.md block of the scope it applies to, so the agent is told.
  2. Before execution. The Claude Code guard refuses an edit that adds a refused line, and a command that would skip the git hooks.
  3. Before git records. Git hooks in every clone rness.json declares refuse the commit or the push, for any agent and for people.

A policy ​

A policy is a Markdown file of .rness/policy/: policy/<name>.md applies everywhere, policy/<scope>/<name>.md to one scope. The name is lowercase words joined by hyphens and is the policy's id in every message. The title is the # heading and the body is the reason, for the people who review it and the agents that read it. Policies have no number: rness doc new policy is refused.

md
---
status: Enabled
standard: standards/documentation.md
rules:
  - check: commit-message
    deny: '\u2014'
    fix: Write " - ", a comma, a colon or a period instead of the em dash.
  - check: added-lines
    deny: '\u2014'
    fix: Write " - ", a comma, a colon or a period instead of the em dash.
---

# No em dash

Text with an em dash reads as machine-written. Use " - ", a comma, a colon
or a period.
KeyRequiredValue
statusyesEnabled: applied. Disabled: listed and validated, applied nowhere.
rulesyesA non-empty list of rules.
standardnoThe path of a standard of the workspace that explains the rule. It must exist.
date, updatednoISO dates.

Any other key is refused. Each rule has three keys, all required: check, deny and fix. fix is one line: it is what the agent reads to correct itself.

A deny pattern is a JavaScript regular expression, compiled with the flags i (a trailer's case does not matter) and u (\u2014 names the em dash without writing it), and matched against one line at a time. ^ and $ are the line's. A scoped policy only adds to the global ones, and a name used both globally and in a scope is refused.

rness validate checks every policy, disabled ones included, and names the file and the line of what it refuses: an unknown key, a missing status or rules, a rule without check, deny or fix, a pattern that does not compile, a standard that does not exist, a name that is not lowercase words joined by hyphens. It also refuses YAML anchors, aliases, custom tags, merge keys and duplicate keys. The CLI package publishes a JSON Schema of the front matter.

The three checks ​

Rness provides a fixed set. A rule picks one; there is no condition and no expression language.

checkWhat it readsdenyRun by
commit-messageEvery line of the commit message, cut at git's scissors line only for a message edited in an editor (never under -m, -F or for a merge message). Comment lines are read too: under -m and -F git keeps them in the commit.A patternThe commit-msg hook
added-linesEach line a commit or an edit adds, never a line already there.A patternThe pre-commit hook, and the guard on Edit and Write
pushEach ref update of a push.non-fast-forward, the only valueThe pre-push hook
  • Added lines only. A file that already holds the pattern does not block a commit that does not touch that line.
  • Merges. A merge git concludes itself runs no pre-commit. One you conclude with git commit after a conflict does, and only the lines added against every parent are checked: your conflict resolutions and any new line. A line either branch brought passes, since it was checked when that branch committed it.
  • Rness's own outputs are not checked by added-lines: the files under .claude/skills/rness/ and the lines inside a rness block. Nobody can fix them where they land; their source is elsewhere.
  • Non-fast-forward means the remote object is not an ancestor of the local one. Whatever the flag (-f, --force-with-lease, +main, an alias), the push is judged on what it does. A new branch passes, and so does a deletion. A remote object missing locally reads as non-fast-forward, and the fix says to fetch first.
  • Several scopes in one clone (a monorepo): added-lines applies the policies of each file's own scope, the deepest one holding it; commit-message and push apply those of every scope of the clone.

The git hooks ​

rness create, rness add and rness sync write three hooks, pre-commit, commit-msg and pre-push, in the hooks directory of every clone under org/ that rness.json declares, as a repository or as the home of a scope. They are local to your machine and never committed to the project. They are written whatever the policies, and each passes at once while no enabled policy uses its check: a policy enabled in .rness/ applies as soon as the clone has pulled it, with no sync. .rness/ itself gets the same three through its own .githooks/.

A clone rness.json does not declare gets no hook, and no policy applies in a clone that holds no scope: nothing is checked there, by the hooks or by the guard. A clone's name is compared whatever its case, so org/Web is the declared web and its policies apply.

Each hook carries a marker line and runs the CLI pinned in .rness/package.json, by the workspace's absolute path. rness sync rewrites a rness hook that is missing or out of date. It reports two cases, once per clone until the cause changes, and writes nothing there:

  • a hook file without the marker is someone else's and is left as it is;
  • a clone with core.hooksPath set (husky, lefthook) is left alone.

Each report ends with the lines to add to that hook, or to each of the three hooks of your own hook manager, <hook> replaced by the hook's name. They find the workspace from the clone, two levels up, so your team can commit them; outside a work tree they do nothing, and they end the hook only to refuse, so your own lines after them still run:

sh
rness_dir=
if rness_top=$(git rev-parse --show-toplevel 2>/dev/null); then
  rness_dir=$(cd "$rness_top/../../.rness" 2>/dev/null && pwd -P) || rness_dir="$rness_top/../../.rness"
fi
rness_bin="$rness_dir/node_modules/@rness/cli/dist/bin/rness.js"
rness_why=
if [ -z "$rness_dir" ]; then
  :
elif ! command -v node >/dev/null 2>&1; then
  rness_why='node not found'
elif [ ! -f "$rness_bin" ]; then
  rness_why="$rness_bin not found"
elif [ ! -f "$rness_dir/rness.json" ] || [ ! -O "$rness_dir" ] || [ -n "$(find "$rness_dir" -prune -perm -0002)" ]; then
  echo "rness: policies not checked: $rness_dir is no workspace .rness of yours alone" >&2
else
  rness_status=0
  rness_out=$(node "$rness_bin" policy check <hook> "$@" 2>&1) || rness_status=$?
  if [ "$rness_status" -ne 0 ] && [ "$rness_status" -ne 20 ]; then
    rness_out="rness: policies not checked: the rness CLI exited $rness_status"
  fi
  if [ -n "$rness_out" ]; then
    printf '%s\n' "$rness_out" >&2
  fi
  if [ "$rness_status" -eq 20 ]; then
    exit 1
  fi
fi
if [ -n "$rness_why" ] && { [ ! -d "$rness_dir" ] || grep -rqsE "^status:[[:space:]]*[\"']?Enabled" "$rness_dir/policy"; }; then
  echo "rness: policies not checked: $rness_why" >&2
fi

The hooks fail open. A missing node, a pinned CLI not installed, a .rness/ not reachable (a standalone clone) or an error of Rness's own never blocks a commit: the hook prints one line, rness: policies not checked: <why>, and lets git go on. A missing node or CLI says nothing while no policy is enabled, since nothing would be checked. Only a refusal blocks: its message is shown, and the hook exits 1. Any other exit of the CLI is one line, rness: policies not checked: the rness CLI exited <n>, in place of what it printed.

A hook runs the CLI only from a .rness/ that holds its rness.json, is owned by you and is not writable by everyone. A .rness/ someone else could have placed above a clone (/tmp/.rness for a clone under /tmp) is never run: the hook says so in one line.

Removing the hooks

A rness hook is the one whose second line is the marker, # rness: policy hook. After moving the pin back to a version without policies, each commit says the rness CLI exited 2 until you remove them. In each clone:

sh
for hook in pre-commit commit-msg pre-push; do
  file="$(git rev-parse --git-path hooks)/$hook"
  if grep -qs '^# rness: policy hook' "$file"; then rm "$file"; fi
done

A hook without the marker is left as it is.

The refusal ​

The message is written for the agent that has to fix it alone. It names every failing rule of every policy, so one correction passes:

rness: policy no-agent-trailers refused this commit
  commit message, line 7: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  fix: Remove the agent's Co-Authored-By trailer, then commit again.
  policy: .rness/policy/no-agent-trailers.md

Each failing rule shows where (the message line, path:line for an added line, or the ref for a push), the offending line cut to 120 characters, its fix, and the standard when the policy names one. A git hook prints it on stderr and exits 1, which the agent sees as its command failing; the guard prints the same text and refuses the tool call. There is no retry limit.

Where refusals show ​

Each refusal is recorded as one line in rness/refusals.jsonl, in the git directory of the clone or of .rness/: never committed, never pushed, and pruned to 30 days by rness sync.

  • The session summary. With a board that keeps a journal, the summary of a session gains a line such as Policies: 3 refusals (no-em-dash 2, no-agent-trailers 1), and is posted when the session has at least one commit, note or refusal. Two sessions in the same clone at the same time both count the same window.
  • rness status. A Policies tab lists every policy with its scope and status, then the refusals of the last 7 days across the clones, grouped by policy with the count and the last time, in UTC. /rness:status shows the same.

Turn on the example ​

A new workspace has no enabled policy. It ships one example, disabled: policy/no-force-push.md, which refuses a push that rewrites a remote branch. To enable it, change one line in .rness/policy/no-force-push.md:

diff
-status: Disabled
+status: Enabled

Commit it to .rness/ and merge it as any change. Each clone applies it once it has pulled .rness/; rness sync then lists the policy in the blocks. Write your own the same way: a file, its rules, its reason.

What enforces it ​

Dated 2026-11-03. Informed means the block of AGENTS.md tells the agent.

WhoInformedBlocked before the actionNot covered
Claude Code, localYesThe guard: the lines an edit adds, a bypass of the hooks. The git hooks: commit, push.A write made through Bash (sed -i) until it is committed; a hook that errors (it fails open); the cases listed below.
Codex CLI, Cursor, localYes, through AGENTS.mdThe git hooks: commit, push.--no-verify; a write until it is committed.
Cloud agents (Codex cloud, Cursor background agents, Claude Code on the web)If they read AGENTS.mdNothing: no Rness hook in their clone.Everything else.
A personNoThe git hooks.--no-verify.
A clone using husky or lefthookAs its harnessNothing until the team adds the lines rness sync prints.Everything else.

For Codex CLI and Cursor, the git hooks apply by inference: their documentation says nothing of git hooks (verified 2026-11-03), and a shell they start runs the clone's hooks as any shell does. A pull request description and a comment on the host never pass through git: no policy reaches them.

What the Claude Code guard does not see ​

On Bash, the guard reads every command and refuses a bypass of the git hooks while an enabled policy applies: git commit --no-verify or -n, git push --no-verify, git merge or git pull --no-verify, git -c core.hooksPath=..., an alias that skips hooks, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, GIT_CONFIG, and a write of core.hooksPath or of an include. When a git command holds a hook-skipping word it cannot read, it refuses as a backstop, so it can refuse a command that was harmless (GIT_CONFIG_GLOBAL=/dev/null git commit, git -c "$KV" ..., ls *.md commit -n). The git hooks still run in every case below except the last two groups, which change or route around the hooks themselves.

The cases it does not catch
  • Scripts it cannot see. A shell reading its script from stdin or a file (bash <<EOF, ... | sh, sh < file, bash script.sh, source file, . file); shell functions and aliases; other interpreters (python -c, node -e, perl -e), make, npm run, any script that runs git.
  • Configuration it cannot see. An alias value is read as a shell reads it, not with git's own splitting. Git aliases already in a configuration file or in the hook's own environment; another global configuration through HOME= or XDG_CONFIG_HOME=; GIT_EXEC_PATH or --exec-path= pointing git at other commands.
  • Values it cannot read. An unknown expansion among the arguments of commit, push, merge or pull that no option takes (git commit $(printf -- --no-verify) -m x) is skipped; only the backstop, for a command not read to the end, counts it.
  • Options of another git. An option the tested git (2.56.0) does not know, combined with -n rather than a literal --no-verify.
  • Wrappers. -n behind a wrapper the parser does not know, unless it follows commit (parallel git commit -n).
  • Places. A command started outside any workspace whose git target cannot be placed (cd "$X" && git commit -n from /tmp).
  • Shell readings. Only bash 3.2 and zsh were run against the reader. Some zsh patterns, a brace expression whose separator comes from an expansion, and bash 5, dash or fish where they differ from both.
  • False positives. The backstop fails closed, so it can refuse a harmless command: ls *.md commit -n, git commit -m x * (refused as "may", since the shell could expand * to an option).
  • Policies. An extends cycle makes the Bash check fail open, as the git hooks do; rness validate reports it.
  • Commits that run none of these hooks by design. git commit-tree with git update-ref, git am, git rebase, git cherry-pick, git revert, git stash; a merge git concludes itself runs no pre-commit (one you conclude with git commit after a conflict does).
  • The hooks themselves. rm, chmod -x or an edit of .git/hooks/*, an edit of .git/config (the edit tools or echo >>), git config --unset of a team hook manager's path.
  • Git's own state written by hand. A .git/MERGE_HEAD naming a commit that holds a refused line: at the merge conclusion, that line passes as a branch's own.

Released under the MIT License. Analytics without cookies, by PostHog.