Shadow or enforce the existing Harness PreToolUse guards as function hooks

Claude Code 2.1.289 function-hook version of five existing PreToolUse shell hooks. It registers one tool.call handler. The default shadow mode evaluates each call, writes its verdict, reasons, and elapsed milliseconds to ~/.claude/harness-core/logs/mod-guard-shadow.jsonl, then calls next(e). enforce returns { deny: message } for deny decisions and otherwise calls next(e); RTK rewrites are passed to next in that mode.
| Existing hook | Ported rule |
|---|---|
freeze-guard.sh | On Write/Edit, block a path outside FREEZE_DIR while the freeze file exists. A freeze with an issue in the registry is active only for IN_PROGRESS or BACKGROUND_RUNNING. |
sandbox-enforce.sh | Bash block patterns for dangerous deletion, destructive device writes, unsafe chmod, pipe-to-shell, and sudo rm; warning/T2 patterns for force push, hard reset, cleanup, SQL deletion, deployment deletion, and publishing. HARNESS_SANDBOX_BYPASS=1/2 keeps the shell semantics. |
secret-guard.sh | On Bash commands containing git commit or git push, inspect staged filenames first, then added diff lines, excluding test/spec paths and placeholder values. After Write/Edit completes, warn about a secret in the edited file. |
rtk-guard.sh | Ask rtk hook claude for a rewrite of likely supported commands; pass through if RTK is absent or fails. |
health-gate.sh | On commit with a registry, run available typecheck, tsc, lint, Rubocop, and TODO checks, record score history, and warn only if the score drops at least five points. |
Load with claude --plugin-dir mods/harness-guard. The default is shadow. To enforce, put this in user settings (~/.claude/settings.json), or choose Guard mode → enforce in /config:
{"pluginConfigs":{"harness-guard":{"options":{"mode":"enforce"}}}}
Keep the existing shell hooks installed while comparing shadow logs. Shadow health scores and regression comparisons use .claude/knowledge-db/health-history.mod-shadow.jsonl; the shell health-gate continues using .claude/knowledge-db/health-history.jsonl. When switching to enforce, disable the shell health-gate.sh hook at the same time, since enforce mode uses the shared history file. To roll back, disable this mod or remove its --plugin-dir, then re-enable the shell health-gate if it was disabled.
test/capture-expected.sh creates a temporary git repository, executes each real shell hook with stdin JSON, and records 38 fixture verdicts in test/fixtures.json (source: shell). test/fixtures.ts is generated from the same capture for the Claude Code test runner, which cannot import JSON modules. The test compares every fixture with the mod's corresponding decision function. ._* AppleDouble files are ignored and removed before test commands because the CLI otherwise mistakes them for tests.
command field while the other hooks read a tool_input envelope. Sandbox fixtures use the script's top-level shape. The mod sees e.command directly, so it detects dangerous commands consistently; a classic call with an envelope can miss them.$.fs.read and $.fs.write, but no append method. Shadow logs and health history are read and rewritten with $.fs.write; simultaneous Claude sessions can lose an appended line. Shell append is atomic at the file descriptor level. Shadow health history is separate from the shell file; only enforce mode reads and writes the shell file.request-user-confirm.sh for WARN+T2 before denying. The shell sandbox also calls decision-trace.sh for block/warn events; the mod uses its JSONL decision record instead, so it does not add a legacy trace line. While both shell and mod are enabled, the T2 helper can be called twice.source accepts arbitrary code in freeze files. The mod reads literal FREEZE_DIR and FREEZE_ISSUE assignments, which covers generated freeze files without executing arbitrary shell code.tool.call completes. While the existing PostToolUse shell hook remains enabled, the warning can appear twice.An internal error is recorded as a warning and passed through. The original freeze, secret, and health hooks also pass on parse/check failures; sandbox's set -e can exit nonzero for some host command failures. The mod's narrow filesystem/process calls catch expected missing-file or missing-command errors and do not block on those failures.
hooks/register.ts 237 lines1import type { Register, EngineInterface, ToolCallInput } from 'claude-code'
2
3type Verdict = 'allow' | 'deny' | 'warn'
4type Decision = { verdict: Verdict; reason: string; hook: string; message?: string; rewrite?: string }
5const allow = (hook: string, reason = 'no matching rule'): Decision => ({ verdict: 'allow', reason, hook })
6const deny = (hook: string, reason: string, message: string): Decision => ({ verdict: 'deny', reason, message, hook })
7const warn = (hook: string, reason: string): Decision => ({ verdict: 'warn', reason, hook })
8const HOME_LOG = '/.claude/harness-core/logs/mod-guard-shadow.jsonl'
9let logQueue: Promise<void> = Promise.resolve()
10const commandPatterns = [
11 ['rm[\\s]+-[rRf]{1,3}[\\s]+/([\\s]|$)', /rm\s+-[rRf]{1,3}\s+\/(\s|$)/],
12 ['rm[\\s]+-[rRf]{1,3}[\\s]+~([\\s]|$|/)', /rm\s+-[rRf]{1,3}\s+~(\s|$|\/)/],
13 ['rm[\\s]+-[rRf]{1,3}[\\s]+\\*', /rm\s+-[rRf]{1,3}\s+\*/],
14 [':[\\s]*\\(\\)[\\s]*\\{', /:\s*\(\)\s*\{/],
15 ['mkfs\\.', /mkfs\./], ['dd[[:space:]]+.*of=/dev/', /dd\s+.*of=\/dev\//],
16 ['chmod[[:space:]]+-R[[:space:]]+777[[:space:]]+/', /chmod\s+-R\s+777\s+\//],
17 ['curl[[:space:]].*\\|[[:space:]]*(sh|bash|zsh)([[:space:]]|$)', /curl\s+.*\|\s*(sh|bash|zsh)(\s|$)/],
18 ['wget[[:space:]].*\\|[[:space:]]*(sh|bash|zsh)([[:space:]]|$)', /wget\s+.*\|\s*(sh|bash|zsh)(\s|$)/],
19 ['sudo[[:space:]]+rm', /sudo\s+rm/],
20] as const
21const warnPatterns = [
22 [/git\s+push\s+(-f|--force)/, 'EXTERNAL', 'git push -f 히스토리 덮어쓰기'],
23 [/git\s+reset\s+--hard/, 'EXTERNAL', '로컬 변경 소실 위험'],
24 [/git\s+clean\s+-f/, 'EXTERNAL', '추적되지 않은 파일 삭제'],
25 [/DROP\s+TABLE/, 'SECURITY', 'DB 테이블 파괴'],
26 [/DROP\s+DATABASE/, 'SECURITY', 'DB 삭제'],
27 [/TRUNCATE\s+/, 'SECURITY', '데이터 전량 삭제'],
28 [/kubectl\s+delete/, 'EXTERNAL', '쿠버네티스 리소스 삭제'],
29 [/kamal\s+(app\s+)?remove/, 'EXTERNAL', '배포 삭제'],
30 [/npm\s+publish/, 'EXTERNAL', 'npm 레지스트리 배포'],
31 [/pip\s+upload/, 'EXTERNAL', 'pip 레지스트리 배포'],
32] as const
33const filenameSecret = /(^|\/)\.deploy_credentials$|(^|\/)\.env$|(^|\/)\.env\.(production|prod|local|staging)$|(^|\/)SECURITY_CREDENTIALS\.md$|(^|\/)(config\/)?master\.key$|(^|\/)credentials\.json$|\.pem$|\.p12$|(^|\/)id_(rsa|ed25519)$|(^|\/)service-account.*\.json$|\.gcp-key\.json$/
34const secret = /AIza[0-9A-Za-z_-]{35}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|EAA[A-Za-z0-9]{50,}|(api[_-]?key|secret|token|password|passwd|pwd|ssh_pass[a-z_]*)["' ]*[=:]\s*["'][^\s"']{8,}|sshpass\s+-p\s*["'][^"']{6,}["']|(로그인|계정|비밀번호|접속)[^\n]{0,12}[`"'][^`"' ]{3,}[`"']\s*\/\s*[`"'][^`"' ]{6,}[`"']/gi
35const placeholder = /your_|<.*>|\$\{|\$\(|\$[a-z_]|xxxx|example|placeholder|changeme|redacted|_here/i
36function hits(text: string): string[] {
37 return [...text.matchAll(secret)].map(m => m[0]).filter(s => /^EAA[A-Za-z0-9]{50,}$/i.test(s) || !placeholder.test(s))
38}
39const limit = (s: string) => s.slice(0, 200)
40async function run($: EngineInterface, argv: string[], timeoutMs = 1200) {
41 return $.process.run(argv, { timeoutMs })
42}
43export async function sandbox($: EngineInterface, command: string): Promise<Decision> {
44 const bypass = await $.env.get('HARNESS_SANDBOX_BYPASS')
45 if (bypass === '2') return allow('sandbox', 'bypass=2')
46 for (const [label, pattern] of commandPatterns) if (pattern.test(command)) {
47 if (bypass === '1') return warn('sandbox', `bypass=1: ${label}`)
48 return deny('sandbox', label, `🛑 [Sandbox BLOCK] 명령 거부\n 패턴: ${label}\n 명령: ${limit(command)}\n 정책: GH_Harness/global/policy/SANDBOX_POLICY.md\n 우회: 필요 시 HARNESS_SANDBOX_BYPASS=1 후 재실행 (위험)`)
49 }
50 for (const [pattern, category, reason] of warnPatterns) if (pattern.test(command)) {
51 const confirm = await $.fs.stat('/Users/gangseungsig/.claude/harness-core/hooks/request-user-confirm.sh').catch(() => undefined)
52 if (confirm) return deny('sandbox', `${category}: ${reason}; T2 confirmation required`, `⚠️ [Sandbox WARN] ${category} — ${reason}\n 명령: ${limit(command)}\n → T2 컨펌 요청 (request-user-confirm.sh)`)
53 return warn('sandbox', `${category}: ${reason}; T2 hook absent`)
54 }
55 return allow('sandbox')
56}
57export async function freeze($: EngineInterface, filePath: string): Promise<Decision> {
58 const cwd = await $.session.cwd()
59 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(cwd))
60 const key = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 12)
61 const freezeFile = `/tmp/harness-freeze-${key}.env`
62 if (!(await $.fs.exists(freezeFile))) return allow('freeze', 'no freeze file')
63 const contents = await $.fs.read(freezeFile)
64 const dir = contents.match(/^\s*(?:export\s+)?FREEZE_DIR=(.*)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, '') || ''
65 const issue = contents.match(/^\s*(?:export\s+)?FREEZE_ISSUE=(.*)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, '') || ''
66 if (!dir || !filePath) return allow('freeze', 'empty freeze dir or file path')
67 if (issue && await $.fs.exists('.claude/issue-db/registry.json')) {
68 try {
69 const registry = JSON.parse(await $.fs.read('.claude/issue-db/registry.json')) as { issues?: {id?: string; status?: string}[] }
70 const status = registry.issues?.find(i => i.id === issue)?.status
71 if (status !== 'IN_PROGRESS' && status !== 'BACKGROUND_RUNNING') return allow('freeze', 'stale freeze')
72 } catch { return allow('freeze', 'registry unreadable, shell hook treats as stale') }
73 }
74 const absolute = dir.startsWith('/') ? dir : `${cwd}/${dir}`
75 const resolved = (await $.fs.stat(absolute, { resolve: true }).catch(() => undefined))?.realPath || absolute
76 if (filePath.startsWith(`${resolved}/`) || filePath.startsWith(`${dir}/`)) return allow('freeze', 'within freeze directory')
77 return deny('freeze', 'outside freeze directory', `🔒 [Harness Freeze] 편집 차단: ${filePath}\n 허용 디렉터리: ${resolved}\n 해제: rm ${freezeFile}`)
78}
79export async function secretGuard($: EngineInterface, command: string): Promise<Decision> {
80 if (!command.includes('git commit') && !command.includes('git push')) return allow('secret', 'not commit/push')
81 const names = await run($, ['git', 'diff', '--cached', '--name-only'])
82 const blocked = names.stdout.split('\n').filter(n => filenameSecret.test(n) && !/example|template|sample|\.enc$/i.test(n))
83 if (blocked.length) return deny('secret', 'staged secret filename', `🚨 [Harness Secret-Guard] 커밋 차단: 시크릿 파일이 staged 되었습니다\n${blocked.map(x => ` - ${x}`).join('\n')}\n\n 조치: git rm --cached <파일> 로 추적 해제 후 .gitignore에 등록하세요.\n 근거: 2026-07-15 .deploy_credentials 공개 유출 (Vultr root 비번 5개월 노출).`)
84 const diff = await run($, ['git', 'diff', '--cached', '--', '.', ':(exclude)tests/*', ':(exclude)test/*', ':(exclude)spec/*', ':(exclude)*_test.sh', ':(exclude)*_test.rb', ':(exclude)*_spec.rb', ':(exclude)*.test.ts', ':(exclude)*.spec.ts'])
85 const added = diff.stdout.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n')
86 if (hits(added).length) return deny('secret', 'staged secret value', '🚨 [Harness Secret-Guard] 커밋 차단: staged 변경에 시크릿(API 키) 평문 감지\n 조치: 키를 환경변수/kamal secrets로 옮기고 .gitignore 처리 후 재커밋하세요.')
87 return allow('secret', 'no staged secret')
88}
89export async function secretPost($: EngineInterface, filePath: string): Promise<Decision> {
90 if (!filePath || !(await $.fs.exists(filePath))) return allow('secret-post', 'file absent')
91 try {
92 if (hits(await $.fs.read(filePath)).length) return warn('secret-post', `⚠️ [Harness Secret-Guard] ${filePath} 에 시크릿(API 키) 평문 감지 → 환경변수로 옮기세요. 커밋 시 secret-guard가 차단합니다.`)
93 } catch { /* shell file read failure is nonblocking */ }
94 return allow('secret-post', 'no secret in edited file')
95}
96export async function rtk($: EngineInterface, command: string): Promise<Decision> {
97 if (!/^(?:ls|cat|grep|rg|git|find|head|tail|wc|sed|awk|du|df|ps|docker|kubectl)(?:\s|$)/.test(command.trim())) return allow('rtk', 'not an RTK target command')
98 try {
99 const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command } })
100 const result = await $.process.run(['rtk', 'hook', 'claude'], { stdin: input, timeoutMs: 1000 })
101 if (result.exitCode !== 0) return allow('rtk', 'rtk failed; shell hook exits without rewrite')
102 const rewrite = (JSON.parse(result.stdout) as {hookSpecificOutput?: {updatedInput?: {command?: string}}}).hookSpecificOutput?.updatedInput?.command
103 return rewrite ? { ...warn('rtk', 'RTK auto-rewrite'), rewrite } : allow('rtk', 'no rewrite')
104 } catch { return allow('rtk', 'rtk absent or invalid output') }
105}
106export async function health($: EngineInterface, command: string, mode: 'shadow' | 'enforce'): Promise<Decision> {
107 if (!command.includes('git commit') || !(await $.fs.exists('.claude/issue-db/registry.json'))) return allow('health', 'no commit or registry')
108 let score = 100
109 const checks: Record<string, { penalty: number; detail: string }> = {}
110 const skipped: string[] = []
111 let ran = 0
112 let truncated = false
113 const started = await $.clock.now()
114 const check = async (name: string, argv: string[], penaltyOf: (output: string, exit: number) => [number, string]) => {
115 const remaining = 5000 - ((await $.clock.now()) - started)
116 if (remaining < 700) { skipped.push(`${name} (시간 예산 초과)`); truncated = true; return }
117 try {
118 const result = await $.process.run(argv, { timeoutMs: Math.min(1200, remaining - 150) })
119 const [penalty, detail] = penaltyOf(result.stdout + result.stderr, result.exitCode)
120 checks[name] = { penalty, detail }
121 score -= penalty
122 ran++
123 } catch { skipped.push(name) }
124 }
125 if (await $.fs.exists('package.json')) {
126 try {
127 const pkg = JSON.parse(await $.fs.read('package.json')) as { scripts?: Record<string, string> }
128 const script = pkg.scripts?.typecheck ? 'typecheck' : pkg.scripts?.['type-check'] ? 'type-check' : ''
129 if (script) await check('typecheck', ['npm', 'run', script], (_out, exit) => [exit === 0 ? 0 : 30, `exit=${exit}`])
130 else skipped.push('typecheck')
131 } catch { skipped.push('typecheck') }
132 } else skipped.push('typecheck')
133 if (await $.fs.exists('tsconfig.json')) {
134 await check('tsc', ['npx', '--no-install', 'tsc', '--noEmit'], (output, exit) => {
135 const errors = Math.min(30, (output.match(/error TS[0-9]+:/gi) || []).length)
136 return [errors, `errors=${errors},exit=${exit}`]
137 })
138 } else skipped.push('tsc')
139 // A missing executable is skipped by process.run, like command -v in the shell hook.
140 const lintBefore = ran
141 await check('ruff', ['ruff', 'check', '.', '--output-format', 'concise'], (output, exit) => {
142 const warnings = (output.match(/:[0-9]+:[0-9]+:/g) || []).length
143 return [Math.min(20, Math.floor(warnings / 5) * 2), `warnings=${warnings},exit=${exit}`]
144 })
145 if (ran === lintBefore) await check('eslint', ['eslint', '.'], (output, exit) => {
146 const warnings = (output.match(/warning|problems? \(/gi) || []).length
147 return [Math.min(20, Math.floor(warnings / 5) * 2), `warnings=${warnings},exit=${exit}`]
148 })
149 if (await $.fs.exists('Gemfile')) await check('rubocop', ['bundle', 'exec', 'rubocop', '--format', 'simple'], (output, exit) => {
150 const offenses = Number([...output.matchAll(/([0-9]+) offenses? detected/g)].at(-1)?.[1] || 0)
151 return [Math.min(20, Math.floor(offenses / 5) * 2), `offenses=${offenses},exit=${exit}`]
152 })
153 else skipped.push('rubocop')
154 await check('todo_markers', ['rg', '-n', '--hidden', '--glob', '!.git/**', '--glob', '!node_modules/**', '--glob', '!.venv/**', '--glob', '!venv/**', '--glob', '!vendor/**', '--glob', '!dist/**', '--glob', '!build/**', '--glob', '!.next/**', '--glob', '!target/**', '--glob', '!__pycache__/**', '--glob', '!*.min.js', '--glob', '!*.lock', 'TODO|FIXME|HACK', '.'], output => {
155 const count = output ? output.trimEnd().split('\n').length : 0
156 return [Math.min(10, Math.floor(count / 10)), `count=${count}`]
157 })
158 if (!ran) return allow('health', 'no available checks')
159 score = Math.max(0, score)
160 const path = mode === 'shadow'
161 ? '.claude/knowledge-db/health-history.mod-shadow.jsonl'
162 : '.claude/knowledge-db/health-history.jsonl'
163 const prior = await $.fs.read(path).catch(() => '')
164 let previous: number | undefined
165 for (const line of prior.split('\n')) {
166 try { const record = JSON.parse(line) as { score?: number; truncated?: boolean }; if (!record.truncated && typeof record.score === 'number') previous = record.score } catch { /* bad history line */ }
167 }
168 await $.fs.write(path, prior + JSON.stringify({ at: new Date().toISOString(), score, checks, skipped, truncated }) + '\n').catch(() => {})
169 const drop = previous === undefined ? 0 : previous - score
170 if (drop >= 5) {
171 const items = Object.entries(checks).filter(([, item]) => item.penalty > 0).map(([name, item]) => `${name}(-${item.penalty})`).join(', ') || '상세 검사 결과'
172 return warn('health', `⚠️ [health-gate] 점수 하락: 이전 ${previous} → 현재 ${score} (-${drop}). 감점 항목: ${items} / 회귀 가능성을 확인하고 커밋하라.`)
173 }
174 return allow('health', `score ${score}; no regression of 5 or more`)
175}
176
177async function logShadow($: EngineInterface, record: unknown): Promise<void> {
178 const home = await $.env.get('HOME')
179 if (!home) return
180 const path = home + HOME_LOG
181 logQueue = logQueue.catch(() => {}).then(async () => {
182 const prior = await $.fs.read(path).catch(() => '')
183 await $.fs.write(path, prior + JSON.stringify(record) + '\n')
184 })
185 await logQueue
186}
187async function decide($: EngineInterface, e: ToolCallInput, mode: 'shadow' | 'enforce'): Promise<Decision[]> {
188 if (e.tool === 'Write' || e.tool === 'Edit') return [await freeze($, e.file_path)]
189 if (e.tool !== 'Bash') return []
190 const results: Decision[] = []
191 for (const check of [() => sandbox($, e.command), () => secretGuard($, e.command), () => rtk($, e.command), () => health($, e.command, mode)]) {
192 const result = await check()
193 results.push(result)
194 if (result.verdict === 'deny') break
195 }
196 return results
197}
198export const register: Register = (on, options) => {
199 on('tool.call', async ($, e, next) => {
200 const started = await $.clock.now()
201 let decisions: Decision[]
202 try { decisions = await decide($, e, options.mode === 'enforce' ? 'enforce' : 'shadow') }
203 catch (error) {
204 // A failed classic hook is ignored by the host unless it explicitly exits nonzero.
205 const message = error instanceof Error ? error.message : String(error)
206 decisions = [warn('internal', `guard error: ${message}`)]
207 }
208 const selected = decisions.find(d => d.verdict === 'deny')
209 const preElapsedMs = (await $.clock.now()) - started
210 if (options.mode !== 'enforce') {
211 const ran = await next(e)
212 let postElapsedMs = 0
213 if ((e.tool === 'Write' || e.tool === 'Edit') && ran.deny === undefined) {
214 const postStarted = await $.clock.now()
215 decisions.push(await secretPost($, e.file_path).catch(() => allow('secret-post', 'scan failed')))
216 postElapsedMs = (await $.clock.now()) - postStarted
217 }
218 await logShadow($, { at: new Date().toISOString(), tool: e.tool, toolUseId: e.tool_use_id, verdict: selected ? 'deny' : decisions.some(d => d.verdict === 'warn') ? 'warn' : 'allow', reasons: decisions.map(d => ({ hook: d.hook, verdict: d.verdict, reason: d.reason })), elapsedMs: preElapsedMs + postElapsedMs }).catch(() => {})
219 return ran
220 }
221 if (selected?.hook === 'sandbox' && selected.reason.includes('T2 confirmation required')) {
222 const category = selected.reason.split(':', 1)[0]
223 await $.process.run(['bash', '/Users/gangseungsig/.claude/harness-core/hooks/request-user-confirm.sh', '-', category, `명령 실행 승인 필요: ${selected.reason}`], { timeoutMs: 1000 }).catch(() => {})
224 }
225 if (selected) return { deny: selected.message || selected.reason }
226 const warnings = decisions.filter(d => d.verdict === 'warn').map(d => d.reason)
227 if (warnings.length) $.ui.status(warnings.join('; '))
228 const rewrite = decisions.find(d => d.rewrite)?.rewrite
229 const ran = e.tool === 'Bash' && rewrite ? await next({ ...e, command: rewrite }) : await next(e)
230 if ((e.tool === 'Write' || e.tool === 'Edit') && ran.deny === undefined) {
231 const post = await secretPost($, e.file_path).catch(() => allow('secret-post', 'scan failed'))
232 if (post.verdict === 'warn') $.ui.status(post.reason)
233 }
234 return ran
235 })
236}
237