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
2 changes: 1 addition & 1 deletion harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ so install here unless you also repoint the references.
| `mcp.json` | `./.mcp.json` | Team MCP servers (context7, sequential-thinking, playwright); packs merge additions (dotnet → microsoft-learn, github → github, azure-devops → azure-devops). npx-launched servers are version-pinned; the HTTP-hosted ones (context7, microsoft-learn, github) run server-side and cannot be pinned. No secrets — auth is always per-developer. Each developer approves the set once on first open. |
| `HARNESS.md` | `./docs/harness.md` | The developer-facing tour: what each installed piece does and why, per layer. Point new team members here first. |
| `ONBOARDING.md` | `./ONBOARDING.md` | The day-1 checklist: tools, auth, MCP approval, executable bits, repo secrets, unfilled setup tokens. Repo root, because that is where a new developer looks. `/sdlc-doctor` checks everything on it and prints the fix. |
| `hooks/*` | `./.claude/hooks/` | `stop-gate`, `review-gate`, `save-review-receipt` (`.ps1` + `.sh`). |
| `hooks/*` | `./.claude/hooks/` | `stop-gate`, `review-gate`, `save-review-receipt` (`.ps1` + `.sh`), plus `sensitive-edit-nudge` — an advisory (non-blocking) example, installed but not registered. |
| `agents/*` | `./.claude/agents/` | `planner`, `architect`, `grader`, `security-reviewer`, `build-error-resolver`, `debugger` — model-tiered; see `agents/README.md`. |
| `skills/*` | `./.claude/skills/` | `spec-writer`, `test-writer`, `api-pattern`, `pr-writer`, `eval-builder`, `diagnose`. |
| `workflows/{ci,grader,correctness,security,deploy-dev,eval-regression,eval-suite}.yml` | `./.github/workflows/` | The five rails + the two eval workflows. |
Expand Down
75 changes: 75 additions & 0 deletions harness/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Copy into the target repo so the layout is:
<repo>/.claude/hooks/review-gate.sh
<repo>/.claude/hooks/save-review-receipt.ps1
<repo>/.claude/hooks/save-review-receipt.sh
<repo>/.claude/hooks/sensitive-edit-nudge.ps1 <- example nudge: installed, not registered
<repo>/.claude/hooks/sensitive-edit-nudge.sh
```

Add this to the repo `.gitignore` (receipts are per-clone evidence, never committed):
Expand All @@ -37,6 +39,7 @@ evidence exists."
| `stop-gate.{ps1,sh}` | `Stop` | The build **and tests** are green before a turn ends (tests opt-out: `RAILS_STOP_RUN_TESTS=0`). |
| `review-gate.{ps1,sh}` | `PreToolUse` (Bash) | `git push` / `gh pr create` is blocked until per-commit review receipts exist. |
| `save-review-receipt.{ps1,sh}` | (manual) | Writes a commit-bound receipt the review gate looks for. |
| `sensitive-edit-nudge.{ps1,sh}` | `PreToolUse` (Edit/Write) | **Nothing.** It advises rather than refuses — a worked example of the nudge pattern below. Ships **unregistered**. |

## Hook contracts (verified — do not "improve" these)

Expand All @@ -52,8 +55,76 @@ These are subtle and easy to get wrong. They are the load-bearing part of the ki
- **PreToolUse block:** write
`{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}`
to stdout, then `exit 0`. (`hookSpecificOutput` *is* valid here — the opposite of Stop.)
- **PreToolUse advisory (a *nudge*):** emit
`{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"..."}}` with **no
`permissionDecision` field at all**, then `exit 0`. The call proceeds through the normal
permission flow and the string is injected as a system reminder next to the tool result — Claude
reads it, the user never sees a transcript entry. (Verified by probe, 2026-07-31: the tool ran
*and* the reminder arrived. The reference documents the field but not the omit-the-decision case,
so it was tested rather than inferred.) `additionalContext` is **ignored** when
`permissionDecision` is `"defer"`. `PostToolUse` accepts the same field if you want the reminder
*after* the fact instead.
- **Allow (either event):** emit nothing and `exit 0`.

## Advisory nudges (the non-blocking variant)

Every other hook in this directory **refuses**. A nudge doesn't: it fires on the same
events, returns no decision, and drops a sentence into the agent's context at the moment
the action is about to happen.

The argument for it is the one already made above — a written instruction is the class of
thing an agent forgets — but the conclusion is different. Not every rule earns a hard
stop. A gate has to be *certain*, because a false positive wedges the session and teaches
the team to reach for the bypass. Plenty of real guidance can't meet that bar: the
condition isn't mechanically decidable, or the rule is right most of the time rather than
always. Without a middle option those rules go in `CLAUDE.md` and get forgotten. A nudge
delivers them at the point of use instead — a sticky note that appears when the agent
reaches for the thing.

**Gate when** the rule is absolute *and* the violation is mechanically provable: the build
is red, the receipt is missing. **Nudge when** the judgement belongs to the agent, or when
a wrong block would cost more than a missed reminder.

Two shapes, and the choice is just *who filters*:

- **Hook-filtered** — the script decides. `sensitive-edit-nudge` reads the target path and
stays silent unless it matches a security-sensitive tree. Use this whenever the trigger
is something a script can actually evaluate.
- **Model-filtered** — the hook fires every time and the *message* carries the condition
("…skip this if the edit is docs or comments"). Use it when the hook can't tell — it
cannot know whether an edit touches a real symbol — and hand that judgement to the
agent rather than guessing at it.

**A nudge is not free.** Every fire spends context, on every matching tool call, for the
rest of the session. Keep the message to a sentence or two, and push the condition into
the hook whenever the hook can express it — a chatty unconditional nudge is a tax on the
whole session and gets tuned out exactly like a noisy alert.

### The shipped example

`sensitive-edit-nudge.{ps1,sh}` fires when the agent is about to edit an auth, identity,
security, migrations, or `infra/` path, and reminds it that the change is `risk:high` and
owes a security-reviewer pass before the named human sign-off. It is **installed but not
registered** — a nudge encodes a specific team's rule, so the kit ships the mechanism and
leaves the policy to you. Retune `RAILS_NUDGE_PATH_REGEX` / `RAILS_NUDGE_MESSAGE`, or copy
the script as a starting point, then register it:

```json
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "pwsh",
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/sensitive-edit-nudge.ps1"] }
]
}
]
```

It fails open like everything else here: unparseable payload, missing `jq`, no
`file_path` — it stays silent and exits 0 rather than interrupting the turn.

## Permissions model (settings.json)

Claude Code evaluates permissions in the order **deny → ask → allow**, and **`deny`
Expand Down Expand Up @@ -108,6 +179,8 @@ All optional. Defaults assume a .NET solution under `src/`.
| `RAILS_REVIEW_SRC_REGEX` | review-gate | `^src/.*\.(cs\|csproj\|…\|ts\|html)$` | Which changed paths count as gated source. |
| `RAILS_REVIEW_KINDS` | review-gate, save-review-receipt | `code-review,simplify` | Which reviews must have receipts. |
| `RAILS_SKIP_REVIEW_GATE` | review-gate | unset | `1` = documented, auditable emergency bypass. |
| `RAILS_NUDGE_PATH_REGEX` | sensitive-edit-nudge | `Auth`/`Identity`/`Security`/`SecurityAttributes`/`Migrations` segments, plus `infra/` | Which edited paths trigger the nudge. Matched against the path with `\` normalized to `/`. |
| `RAILS_NUDGE_MESSAGE` | sensitive-edit-nudge | the security sign-off reminder | The reminder text injected into the agent's context. |

`CLAUDE_PROJECT_DIR` is set by Claude Code and used to locate the repo root; scripts fall
back to the current directory if it is absent.
Expand Down Expand Up @@ -145,6 +218,8 @@ shell features, or with the `.sh` twins as shown above.
| `stop-gate` scripts | **Stable** | Logic depends only on git + dotnet. |
| `review-gate` scripts | **Stable machinery, policy-coupled** | Assumes the review workflow is `/code-review` + `/simplify` and that receipts come from `save-review-receipt`. Retune `RAILS_REVIEW_KINDS` for a different workflow. |
| `save-review-receipt` scripts | **Stable** | Binds + timestamps a receipt; it cannot judge review quality — that is on the reviewer. |
| PreToolUse advisory contract | **Stable** | Probe-verified; the omit-the-decision case is not spelled out in the reference, so re-check it if hook behavior ever surprises you. |
| `sensitive-edit-nudge` scripts | **Template** | The mechanism is stable; the regex and the message are one team's policy and are meant to be replaced. Not registered by default. |
| Permission globs | **Template** | The deny/ask/allow *shape* is stable; the specific paths are placeholders to confirm per repo. |

## Coverage boundary (read this)
Expand Down
50 changes: 50 additions & 0 deletions harness/hooks/sensitive-edit-nudge.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#Requires -Version 7
<#
Advisory PreToolUse nudge — the non-blocking counterpart to the gates in this directory.

Fires when the agent is about to edit a security-sensitive path and reminds it what the
standard requires there. It never blocks: it emits `additionalContext` with no
`permissionDecision`, so the tool call proceeds through the normal permission flow and the
string arrives as a system reminder next to the tool result.

This one is shipped as a worked example and is deliberately NOT registered in settings.json.
Copy it, retune the regex and the message for your repo, then register it (see README.md,
"Advisory nudges").
#>

$ErrorActionPreference = 'Stop'

# Paths the standard treats as security-sensitive. Same trees settings.json gates with `ask`.
$defaultPattern = '(^|/)(Auth|Identity|Security|SecurityAttributes|Migrations)(/|$)|(^|/)infra/'
$pattern = if ($env:RAILS_NUDGE_PATH_REGEX) { $env:RAILS_NUDGE_PATH_REGEX } else { $defaultPattern }

# ASCII only, deliberately: this file is copied into client repos and must emit the same bytes
# under both runtimes. A non-ASCII literal here decodes differently depending on how the host
# reads the script, and the bash twin would stop being faithful.
$defaultMessage = 'Delivery-standard nudge: this path is security-sensitive. Treat the change as risk:high, and run the security-reviewer agent over it before you finish: the security pass precedes the named human sign-off, and `security-review` is a required merge check. Disregard if this edit is only comments, docs, or test fixtures.'
$message = if ($env:RAILS_NUDGE_MESSAGE) { $env:RAILS_NUDGE_MESSAGE } else { $defaultMessage }

try {
$raw = [Console]::In.ReadToEnd()
if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 }

$path = ($raw | ConvertFrom-Json).tool_input.file_path
if ([string]::IsNullOrWhiteSpace($path)) { exit 0 }

if (($path -replace '\\', '/') -notmatch $pattern) { exit 0 }

# [ordered] matters: a plain hashtable emits keys in an arbitrary order, so the twins would
# disagree byte-for-byte from run to run and no test could pin either one down.
[ordered]@{
hookSpecificOutput = [ordered]@{
hookEventName = 'PreToolUse'
additionalContext = $message
}
} | ConvertTo-Json -Compress -Depth 5
}
catch {
# Fail open, per this kit's rule: a hook that cannot compute an answer stays silent
# rather than wedging the session. A missed nudge costs a reminder; a wedged hook costs the turn.
}

exit 0
35 changes: 35 additions & 0 deletions harness/hooks/sensitive-edit-nudge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Faithful bash twin of sensitive-edit-nudge.ps1 — identical logic and contract.
#
# Advisory PreToolUse nudge: reminds the agent what the standard requires when it is about to
# edit a security-sensitive path. Never blocks — emits `additionalContext` with no
# `permissionDecision`, so the tool call proceeds and the string arrives as a system reminder.
#
# Shipped as a worked example, deliberately NOT registered in settings.json. See README.md,
# "Advisory nudges".

set -uo pipefail

# Paths the standard treats as security-sensitive. Same trees settings.json gates with `ask`.
DEFAULT_PATTERN='(^|/)(Auth|Identity|Security|SecurityAttributes|Migrations)(/|$)|(^|/)infra/'
PATTERN="${RAILS_NUDGE_PATH_REGEX:-$DEFAULT_PATTERN}"

# ASCII only, deliberately: must match the .ps1 twin byte-for-byte under both runtimes.
DEFAULT_MESSAGE='Delivery-standard nudge: this path is security-sensitive. Treat the change as risk:high, and run the security-reviewer agent over it before you finish: the security pass precedes the named human sign-off, and `security-review` is a required merge check. Disregard if this edit is only comments, docs, or test fixtures.'
MESSAGE="${RAILS_NUDGE_MESSAGE:-$DEFAULT_MESSAGE}"

# Fail open when the tooling isn't there, same as the gates.
command -v jq >/dev/null 2>&1 || exit 0

payload="$(cat)"
[ -n "$payload" ] || exit 0

path="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" || exit 0
[ -n "$path" ] || exit 0

printf '%s' "${path//\\//}" | grep -qE "$PATTERN" || exit 0

jq -nc --arg msg "$MESSAGE" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'

exit 0
2 changes: 2 additions & 0 deletions scripts/tests/golden/enterprise-tree.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
.claude/hooks/review-gate.sh
.claude/hooks/save-review-receipt.ps1
.claude/hooks/save-review-receipt.sh
.claude/hooks/sensitive-edit-nudge.ps1
.claude/hooks/sensitive-edit-nudge.sh
.claude/hooks/stop-gate.ps1
.claude/hooks/stop-gate.sh
.claude/rules/clean-architecture.md
Expand Down