SLOPSHOPPER

harness-guard

Shadow or enforce the existing Harness PreToolUse guards as function hooks

newguardstatusprocess
v0.1.0no licenseupdated 2026-10-04myaji35/GH_Harness/mods/harness-guard
A shopper browsing a rack in a slop shop
README

harness-guard mod

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 hookPorted rule
freeze-guard.shOn 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.shBash 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.shOn 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.shAsk rtk hook claude for a rewrite of likely supported commands; pass through if RTK is absent or fails.
health-gate.shOn 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.

Configuration

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.

Verification

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.

Known differences

  • The classic sandbox hook expects a top-level 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.
  • The mod uses a 5-second sub-budget for health checks and 1.2-second per-process caps to keep the complete decision below 10 seconds. The shell worker allows 45 seconds overall and 20 seconds per check. A slow check can therefore be skipped or timed out and yield a different score.
  • The supplied Claude Code API type exposes $.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.
  • In enforce mode the mod calls 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.
  • Shell 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.
  • The post-tool secret warning is reproduced after a Write/Edit 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.

Source 1 files
hooks/register.ts 237 lines
1import 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