This directory contains a hook script that fires during Claude Code sessions in this workspace. The main hook event fires automatically — no action needed once the workspace is cloned and Claude Code is started in it. It is registered in ../settings.json under hooks.PreToolUse and is meant to make agent behavior more predictable and to teach safer command patterns by giving immediate corrective feedback.
Codex uses focused bridges, not this monolith. Its Kubernetes bridge reuses the shared guard, and its redirect bridge consumes only [redirect-commands] from hook-rules. The remaining allow, ask, composition, adapter, scratch, PowerShell, and PermissionRequest behavior stays Claude-specific until another focused bridge or an evidence-backed shared evaluator is warranted. The Codex project configuration covers trust and troubleshooting.
New here? docs/gdd/agent-training.md is the user-friendly companion that covers why you'll see deny output early in a session, why the discipline doesn't double API cost, and how to handle the "this legit command got denied" case. This README is the technical spec — what each tier checks, the audit log format, registration, and troubleshooting.
Fires before every Bash tool call. Five general decision tiers plus a Kubernetes write safety floor, then a passthrough:
- Deny shell composition (
&&,||,;, pipes, command substitution, redirects) with a corrective message that tells the agent how to retry. Trains the agent to use separate tool calls and nativewsflags (--limit,--compact,--output) instead of shell composition. - Deny raw
git commit/git push/gh pr create(and any other entry in the[redirect-commands]section ofhook-rules) with a corrective message pointing at the rightwssubcommand. A session-scoped bypass marker — written byws hook-bypass <slug>after a human-approved ask prompt — overrides the deny for that slug. See Redirect tier and bypass below. - Classify Kubernetes writes before scope and allowlist handling. With no scope, a read passes onward and a write emits
ask; a blanket localBash(kubectl:*)allow therefore cannot suppress confirmation. With a scope, the existing redirect tier adds context and namespace enforcement. Common transparent forms such asenv KUBECONFIG=… kubectl,/usr/bin/kubectl,bash -x script.sh, relative executable scripts, and literalbash -ckubectl calls are normalized before evaluation. A matching auditedk8sbypass marker lifts both the unscoped write floor and raw-command redirect for that session, while an armedws k8swrapper remains scope-bounded. - Ask (force a permission prompt) for anything matching a glob in the
[ask-commands]section ofhook-rules(committed baseline) orhook-rules.local(per-machine). The hook emitspermissionDecision: "ask", which surfaces a human-facing prompt regardless of the session permission mode — includingacceptEditsandbypassPermissions. The command is NOT blocked; once the human approves it runs normally. This tier exists specifically to intercept destructive commands likerm -rfand arbitrary-execution escape hatches likews execbefore they can auto-run silently. - Allow anything matching
permissions.allowpatterns in.claude/settings.json— the hook normalizes both the command and the pattern so barews statusand verbosebash scripts/ws statusboth match a single pattern in either style. - Allow anything matching a glob in the
[allow-extras]section ofhook-rules.local. Per-machine personal extras for tools you trust on your laptop without committing them to the project config. - Pass everything else goes to default behavior based on other config.
Logs allow/deny/ask decisions to ~/.claude/hook-audit.log with timestamps so you can review what the hook is doing. Passthroughs are not logged.
The tool-permission hook also understands the PermissionRequest event and the Edit / Write tools — those code paths are dormant under the default registration and activate only when you wire them up. See Optional: PermissionRequest hook below.
The hook can classify only tool events registered in .claude/settings.json. The default configuration sends Bash, PowerShell, Edit, and Write through PreToolUse; the optional configuration below can also send selected permission requests through the same evaluator. Harness-native task lifecycle tools such as TaskCreate, TaskOutput, and TaskStop never enter this hook, so their absence from the audit log is expected rather than an allow decision.
That boundary does not create a shell escape hatch. TaskStop addresses a background task created by the current harness session; raw process-control commands such as kill or taskkill still arrive through the registered shell tool and receive the normal composition, ask, allow, or passthrough classification.
Forward-slash paths are the workspace convention in shell commands — git and the MSYS userland accept them everywhere, and they never collide with escape syntax. Backslash paths are tolerated, not preferred, when the complete drive-letter-rooted token is wrapped in single or double quotes ("D:\Dev\file"). The quotes make the backslashes path data through Bash parsing, so the hook can normalize that token to forward slashes for classification and let the command reach the allowlist normally instead of forcing an ask.
Every ambiguous backslash still requires human approval: bare backslash paths, escaped quotes (\"), trailing backslashes, doubled backslashes, backslash-escaped spaces, and backslashes in tokens that are not drive-letter-rooted. A quoted path containing spaces also still asks — the classifier deliberately word-splits before inspecting tokens rather than re-implementing shell quoting, so a spaced path never reaches the rewrite; spell such paths with forward slashes instead. Quoting changes whether those backslashes are syntax or data, and a transformed match could otherwise reach the wrong permission tier. The audit log records the normalized (forward-slash) command form used for classification.
Two files drive the hook's allow/ask/deny decisions beyond the committed settings.json:
| File | Tracked in git? | Purpose |
|---|---|---|
.claude/hooks/hook-rules |
Yes | Committed baseline — transparent project policy, including shared [redirect-commands] rules |
.claude/hooks/hook-rules.local |
No (gitignored) | Per-machine overrides — copy from hook-rules.local.example |
The format is flat sectioned text: [section] headers, one entry per line, # comments, blank lines ignored.
[scratch-dirs] — workspace-relative paths under which Edit/Write tool calls auto-allow. Keeps in lockstep with the "Workspace-local scratch" section of .gitignore. Entries in hook-rules.local add to the baseline; they never replace it. Security-sensitive workspace state is carved out of the auto-allow and asks instead, even inside a scratch dir: .tmp/hook-bypass/, .tmp/gdd-agent-sessions/, .claude/, .env, and ecosystem.local.yaml (checked against both the literal path and its symlink-resolved ancestor). The Edit and Write matchers in the committed settings.json route those tools through this hook alongside Bash. All policy anchors to the harness project root (CLAUDE_PROJECT_DIR) — nested .claude/ trees inside components, realms, or hoards are content, not authorities — and externally-supplied paths are normalized to one form (via cygpath on Git Bash) before any comparison, so Windows path-form differences can't silently defeat a prefix check.
[ask-commands] — glob patterns for destructive or arbitrary-execution Bash commands that should always produce a permission prompt, regardless of session mode. A match in either file triggers Tier 4 (ask) not a deny — the human approves and the command runs. hook-rules.local entries are additive-only: you can make more commands prompt, but you cannot remove a pattern committed in hook-rules. This is intentional — per-machine config can tighten the safety floor, never loosen it.
[redirect-commands] — platform-neutral workflow redirects shared by the Claude hook and the focused Codex redirect bridge. Keep Bash-glob semantics and suggestion text usable in both harnesses. A valid session bypass explicitly allows in Claude and defers to normal sandbox/approval routing in Codex.
[adapter-redirect-commands] — Tier 3 patterns for raw test/lint/build runners. The hook resolves the component from $cwd and the active realm's adapter file; wired adapters get a deny-with-bypass, missing adapters get a one-line stderr nudge and fall through. See [adapter-redirect-commands] in hook-rules for the format.
[allow-extras] — personal Bash glob patterns auto-allowed on this machine without prompting (Tier 6). Only valid in hook-rules.local, never in the committed baseline. An entry here will not win over an "ask command" entry.
cp .claude/hooks/hook-rules.local.example .claude/hooks/hook-rules.localThen edit hook-rules.local. The example file is annotated with common entries.
Important: the safety reasoning of every [allow-extras] pattern depends on the hook's Tier 1 still being active. Removing the hook OR disabling Tier 1 would change the calculus of every entry. Treat changes to hook-rules.local with care.
The Tier 2 redirect-deny channels three raw commands toward the workspace's ws wrappers:
| Slug | Pattern | Use this instead |
|---|---|---|
git-commit |
git commit* |
ws commit <comp> <bodyfile> — bodyfile-driven, attaches the Co-Authored-By trailer |
git-push |
git push* |
ws push <comp> [branch] — picks the fork remote from identity.forkRemote, sets upstream on first push |
gh-pr-create |
gh pr create* |
ws cr <comp> <title> <bodyfile> — bodyfile-driven, applies identity substitutions |
A deny here is a training signal, not a safety floor (that's Tier 4 ask). The hook trusts the workspace's own ws wrappers to do the right thing — attribution, remote selection, the right token. When a legitimate edge case exists (ws doesn't yet support what you need), the agent can request a bypass:
- Agent hits the deny; corrective message names
ws hook-bypass <slug>as the escape hatch. - Agent runs
ws hook-bypass <slug> --reason "<why>". The subcommand is on the ask-list, so the human gets a permission prompt — tailored to name the slug being bypassed and surface the--reason, so the human sees what they're approving rather than a generic "destructive command" line. - Human approves; the script writes
.tmp/hook-bypass/<slug>.bypasskeyed to the Claude Code session id ($CLAUDE_CODE_SESSION_ID). - Agent retries the raw command; the hook finds the marker, matches session_id, and emits an allow with audit
BYPASS-ALLOW [<slug>] reason="<text>" [PreToolUse]: <cmd>. - The marker is honored for the rest of the session. Next session's
CLAUDE_CODE_SESSION_IDdiffers, so the marker is stale;ws cleansweeps.tmp/whenever you want a clean slate.
The recurring-bypass pattern — same slug bypassed every session — is a signal that the corresponding ws subcommand needs to grow that capability. Periodic grep BYPASS-ALLOW ~/.claude/hook-audit.log surfaces it.
Adding a new redirect. Append a row to the [redirect-commands] section of .claude/hooks/hook-rules: <slug> | <pattern> | <suggestion>. The slug must match ^[a-z][a-z0-9-]*$ (start with a letter so the ws hook-bypass [a-z]* ask-pattern always catches a slug invocation). The pattern is a bash glob. The suggestion is free text (column 3, may contain pipes — parsing splits on the first two | only). This section is shared by Claude and Codex, so keep its pattern and guidance platform-neutral. The new slug is automatically bypassable via ws hook-bypass <new-slug>; no script change needed.
The hook also registers on the PowerShell tool (see settings.json) and denies every invocation rather than porting the Bash tiers to PowerShell grammar. The rationale: the ws CLI + Bash tool are the sanctioned surface, agents observably drift into PowerShell once it starts "working," and a granular PowerShell tier would need its own grammar — PS 5.1 has no &&/||, so ; is its only statement separator, and a naive port of the Tier 1 composition deny would make the tool unusable rather than safe.
Component tests, including Windows wrappers around containerized tools, use ws test <component> [test-name]. That route uses workspace target resolution and, when it selects an adapter, the active realm trust check; invoking test.ps1 directly does not create a second provenance path inside the permission hook.
One exception remains:
ws hook-bypass powershellgrants a session-scoped raw-PowerShell bypass through the same human-gated ask flow as the Tier 2/3 slugs (it's a built-in slug inws-hook-bypass.sh, not ahook-rulesrow). The intended use is the rare case where PowerShell is genuinely the right tool — e.g. piping test payloads into this hook while debugging it, which Bash Tier 1 redirection rules block by design.
WS_HOOK_DISABLE=1 disables this branch along with everything else.
Power-user opt-in. Wires the same gdd-permission-hook.sh script to a second hook event — PermissionRequest — and broadens its matcher to also cover the Edit and Write tools. Net effect:
- Scratch-dir writes stop prompting. Edits and writes specifically under
.tmp/,.commits/,.crs/,.issues/, and.outputs/(the "Workspace-local scratch" section of.gitignore) auto-allow. Useful when you're drafting commit bodies, CR templates, and capture files all day — those routine flows otherwise can generate a steady drip of approve prompts. - Bash allowlist matches double-cover at the prompt layer. Anything your
settings.jsonalloworhook-rules.local[allow-extras]would have allowed atPreToolUsealso gets approved atPermissionRequest, which can help in some configurations — largely intended to let the GDD extensions be good citizens in otherwise constrained settings.
This isn't enabled by default because (a) PermissionRequest is a different threat-model surface than PreToolUse — auto-allowing writes into a directory list is a stronger trust grant than auto-allowing read-shaped Bash patterns, and (b) the value is mostly ergonomic, so it's better off opt-in than imposed.
Interaction with the redirect bypass. When this optional hook is enabled, .tmp/ is among the auto-allowed scratch dirs — so an agent could in principle write a bypass marker (.tmp/hook-bypass/<slug>.bypass) with the Write tool directly, skipping ws hook-bypass and therefore the ask-prompt. That sidesteps the human gate for the Tier 2 redirect deny. This is consistent with the redirect tier's stated threat model (agent drift, not an adversarial agent deliberately crafting marker files) — and an agent in acceptEdits can already write into .tmp/ regardless of this hook — but operators who enable the PermissionRequest extension and want the bypass to remain strictly human-gated should be aware of it.
It still includes the sometimes more severe blocks that GDD implements to teach the agent not to let commands be chained at all, favoring deterministic scripts and temporary files that are more auditable than massive commands dumping to a simple point-in-time approve prompt.
Security-sensitive paths never inherit the scratch allow. Guard state and configuration inside the scratch tree (.tmp/hook-bypass/, .tmp/gdd-agent-sessions/), .claude/, .env, ecosystem.local.yaml, and the shared Kubernetes guard helper ask instead of auto-allowing. One carve-out keeps sub-agent dispatch usable: a full-file Write that creates a new <name>.env under .tmp/gdd-agent-sessions/ (the sub-agent identity-file birth), or replaces the current session's own <sid>.env, rides the scratch allow so long as the complete content carries no guard-scope key (GDD_K8S_* stays a ws k8s ceremony). Partial Edit operations, overwriting another session's existing file, guard keys, and non-.env names all still ask.
Add this block to your .claude/settings.local.json (per-user, gitignored) alongside any existing permissions / enabledMcpjsonServers entries:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/gdd-permission-hook.sh\""
}
]
}
]
}
}You may also want a hook-rules.local of your own (copy from hook-rules.local.example) — the PermissionRequest hook reads the same rules files the PreToolUse hook does.
Ask your agent to run any safe scratch-dir write and check ~/.claude/hook-audit.log. A scratch-dir hit logs as e.g. ALLOW [PreToolUse] (scratch-dir: .tmp/): Write .../marker.txt — the [PreToolUse] / [PermissionRequest] tag in the entry tells you which event fired.
The dir list lives in the [scratch-dirs] section of .claude/hooks/hook-rules (committed baseline) and optionally hook-rules.local (per-machine additions). Keep it in lockstep with the "Workspace-local scratch" section of .gitignore — anything gitignored as scratch should be safe to auto-allow, and vice versa. Additions to the committed hook-rules belong in the same PR that adds the directory to .gitignore.
If a single session needs the hook off, set WS_HOOK_DISABLE=1 in your shell, .env, or shell profile. The hook reads the variable on every invocation and exits as a passthrough when it's set.
export WS_HOOK_DISABLE=1This bypass is per-user / per-machine and doesn't require editing the committed settings.json. To re-enable later, unset the variable.
Earlier versions of this hook had an infinite-loop bug on Windows-style paths (the upward-walk for .claude/settings.json didn't terminate when dirname started returning . repeatedly). The current script has a prev == dir guard that ensures the loop exits at the filesystem root regardless of platform. Both tests/hook/gdd-permission-hook.bats and tests/ws-smoke/read-only.bats include timeout assertions that fail loudly if a regression introduces a hang.
If you encounter a stall anyway:
- Set
WS_HOOK_DISABLE=1to unblock yourself - Capture the audit log around the stall (
~/.claude/hook-audit.log) and file a yggdrasil issue - As a workaround until fixed, remove the
hooksblock from your local.claude/settings.local.json(overrides the committedsettings.jsonfor your machine)
~/.claude/hook-audit.log records every allow/deny with a timestamp and the reason (which tier / which pattern fired). Worth a periodic skim — anything you didn't expect to be auto-approved is a pattern to narrow; anything you keep getting prompted for despite expecting auto-approval is a missing entry to add.
The log is append-only and per-user. The hook doesn't rotate it; if it grows, truncate -s 0 ~/.claude/hook-audit.log resets it.