oh-my-harness 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,8 @@ That is the whole setup. Your agent now hits a wall when it tries to:
28
28
  | run `rm -rf /`, `chmod -R 777`, or any pattern you list | ⛔ **Blocked** |
29
29
  | write into `node_modules/`, `.next/`, `dist/` | ⛔ **Blocked** |
30
30
  | commit on a branch already merged to main | ⛔ **Blocked** |
31
+ | `git commit --no-verify`, `git push --force origin main` | ⛔ **Blocked** |
32
+ | edit its own hooks or `.claude/settings.json` to switch the guardrails off | ⛔ **Blocked** |
31
33
  | save a file | ✅ auto-lint |
32
34
  | push a branch | ✅ auto-PR |
33
35
 
@@ -72,6 +74,9 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
72
74
  | 🧪 `commit-test-gate` | quality | Runs tests before git commit |
73
75
  | 🔍 `commit-typecheck-gate` | quality | Runs typecheck before git commit |
74
76
  | 🔒 `command-guard` | security | Blocks dangerous shell commands |
77
+ | 🪝 `harness-guard` | security | Blocks shell writes to the harness's own hooks and config (always on) |
78
+ | 🚫 `no-verify-guard` | git | Blocks `--no-verify` / `-n` / hooksPath overrides on commit and push (always on) |
79
+ | 💥 `force-push-guard` | git | Blocks force pushes to protected branches (always on) |
75
80
  | 📁 `path-guard` | file-protection | Blocks writes to protected paths |
76
81
  | 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
77
82
  | 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
@@ -28,7 +28,7 @@ set -euo pipefail
28
28
  INPUT=$(cat)
29
29
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
30
30
  [[ -z "$COMMAND" ]] && exit 0
31
- if ! echo "$COMMAND" | grep -q "git push"; then
31
+ if ! _omh_cmd_matches "$COMMAND" git push; then
32
32
  exit 0
33
33
  fi
34
34
  if ! command -v gh >/dev/null 2>&1; then
@@ -14,7 +14,7 @@ export const branchGuard = {
14
14
  set -euo pipefail
15
15
  INPUT=$(cat)
16
16
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
- if echo "$COMMAND" | grep -qE "git commit|git push"; then
17
+ if _omh_cmd_matches "$COMMAND" git commit || _omh_cmd_matches "$COMMAND" git push; then
18
18
  BRANCH=$(git branch --show-current 2>/dev/null)
19
19
  [[ -z "$BRANCH" ]] && exit 0
20
20
  MAIN='{{mainBranch}}'
@@ -21,10 +21,9 @@ set -euo pipefail
21
21
  INPUT=$(cat)
22
22
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
23
23
  [[ -z "$COMMAND" ]] && exit 0
24
- NORMALIZED_CMD=$(printf '%s' "$COMMAND" | tr '[:space:]' ' ' | tr -s ' ')
25
24
  PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
25
  for PATTERN in "\${PATTERNS[@]}"; do
27
- if echo "$NORMALIZED_CMD" | grep -qF -- "$PATTERN"; then
26
+ if _omh_cmd_has_pattern "$COMMAND" "$PATTERN"; then
28
27
  REASON="oh-my-harness: command matches blocked pattern: $PATTERN"
29
28
  _log_event "block" "$REASON"
30
29
  _emit_decision "block" "$REASON"
@@ -8,13 +8,26 @@ export const commitTestGate = {
8
8
  canBlock: true,
9
9
  params: [
10
10
  { name: "testCommand", type: "string", description: "Test command to run before commit", required: true },
11
+ {
12
+ name: "cacheTtlSeconds",
13
+ type: "number",
14
+ description: "Skip the run when the working tree is unchanged since the last pass and that pass is younger than this many seconds (0 disables)",
15
+ default: 600,
16
+ required: false,
17
+ },
11
18
  ],
12
19
  tags: ["git", "test", "quality", "guard"],
13
20
  template: `#!/bin/bash
14
21
  set -euo pipefail
15
22
  INPUT=$(cat)
16
23
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
- if echo "$COMMAND" | grep -qE "git commit"; then
24
+ if _omh_cmd_matches "$COMMAND" git commit; then
25
+ FP=$(_omh_tree_fingerprint)
26
+ if _omh_gate_cached commit-test-gate {{cacheTtlSeconds}} "$FP"; then
27
+ echo "oh-my-harness: tests already passed on this exact tree; skipping" >&2
28
+ _log_event "allow" "cached: tree unchanged since last passing run"
29
+ exit 0
30
+ fi
18
31
  echo "oh-my-harness: Running {{{testCommand}}} before commit..." >&2
19
32
  if ! {{{testCommand}}} >&2 2>&1; then
20
33
  REASON="oh-my-harness: pre-commit check failed"
@@ -22,6 +35,7 @@ if echo "$COMMAND" | grep -qE "git commit"; then
22
35
  _emit_decision "block" "$REASON"
23
36
  exit 0
24
37
  fi
38
+ _omh_gate_record commit-test-gate "$FP"
25
39
  fi
26
40
  exit 0`,
27
41
  };
@@ -8,13 +8,26 @@ export const commitTypecheckGate = {
8
8
  canBlock: true,
9
9
  params: [
10
10
  { name: "typecheckCommand", type: "string", description: "Typecheck command to run before commit", required: true },
11
+ {
12
+ name: "cacheTtlSeconds",
13
+ type: "number",
14
+ description: "Skip the run when the working tree is unchanged since the last pass and that pass is younger than this many seconds (0 disables)",
15
+ default: 600,
16
+ required: false,
17
+ },
11
18
  ],
12
19
  tags: ["git", "typecheck", "quality", "guard"],
13
20
  template: `#!/bin/bash
14
21
  set -euo pipefail
15
22
  INPUT=$(cat)
16
23
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
17
- if echo "$COMMAND" | grep -qE "git commit"; then
24
+ if _omh_cmd_matches "$COMMAND" git commit; then
25
+ FP=$(_omh_tree_fingerprint)
26
+ if _omh_gate_cached commit-typecheck-gate {{cacheTtlSeconds}} "$FP"; then
27
+ echo "oh-my-harness: typecheck already passed on this exact tree; skipping" >&2
28
+ _log_event "allow" "cached: tree unchanged since last passing run"
29
+ exit 0
30
+ fi
18
31
  echo "oh-my-harness: Running {{{typecheckCommand}}} before commit..." >&2
19
32
  if ! {{{typecheckCommand}}} >&2 2>&1; then
20
33
  REASON="oh-my-harness: pre-commit check failed"
@@ -22,6 +35,7 @@ if echo "$COMMAND" | grep -qE "git commit"; then
22
35
  _emit_decision "block" "$REASON"
23
36
  exit 0
24
37
  fi
38
+ _omh_gate_record commit-typecheck-gate "$FP"
25
39
  fi
26
40
  exit 0`,
27
41
  };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const forcePushGuard: BuildingBlock;
@@ -0,0 +1,86 @@
1
+ // #99: a force push to a shared branch rewrites history for everyone. Block
2
+ // `git push` when the destination is a protected branch and the push is
3
+ // forced: --force / -f / a "+refspec". The destination
4
+ // is the refspec's target (`origin main`, `origin HEAD:main`,
5
+ // `origin feat:refs/heads/main`, `origin +main`); with no refspec it is the
6
+ // current branch, and if that cannot be determined the push is blocked rather
7
+ // than guessed. --force-with-lease is allowed by default (it refuses to
8
+ // clobber unseen commits); set allowLease: false to block it too.
9
+ export const forcePushGuard = {
10
+ id: "force-push-guard",
11
+ name: "Force Push Guard",
12
+ description: "Blocks git push --force / -f / +refspec to protected branches",
13
+ category: "git",
14
+ event: "PreToolUse",
15
+ matcher: "Bash",
16
+ canBlock: true,
17
+ params: [
18
+ {
19
+ name: "protected",
20
+ type: "string[]",
21
+ description: "Branches that must never be force-pushed",
22
+ default: ["main", "master"],
23
+ required: false,
24
+ },
25
+ {
26
+ name: "allowLease",
27
+ type: "boolean",
28
+ description: "Allow --force-with-lease (safe force) to protected branches",
29
+ default: true,
30
+ required: false,
31
+ },
32
+ ],
33
+ tags: ["git", "push", "guard", "force"],
34
+ template: `#!/bin/bash
35
+ set -euo pipefail
36
+ INPUT=$(cat)
37
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
38
+ [[ -z "$COMMAND" ]] && exit 0
39
+ _omh_cmd_matches "$COMMAND" git push || exit 0
40
+
41
+ PROTECTED=({{#each protected}}"{{{this}}}" {{/each}})
42
+ CURRENT=$(git branch --show-current 2>/dev/null || echo "")
43
+
44
+ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
45
+ -v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v current="$CURRENT" -v allow_lease="{{allowLease}}" '
46
+ BEGIN { np = split(protected, P, "|"); for (k = 1; k <= np; k++) isp[P[k]] = 1 }
47
+ found != "" { next }
48
+ {
49
+ i = 1
50
+ while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
51
+ if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
52
+ if (i > NF || $i != "git") next
53
+ j = i + 1
54
+ while (j <= NF && $j ~ /^-/) { if ($j == "-c" || $j == "-C") j++; j++ }
55
+ if (j > NF || $j != "push") next
56
+ force = 0; lease = 0; remote = ""; refspec = ""; plus = 0
57
+ for (k = j + 1; k <= NF; k++) {
58
+ t = $k
59
+ if (t == "--") { for (m = k + 1; m <= NF; m++) { if (remote == "") remote = $m; else if (refspec == "") refspec = $m }; break }
60
+ if (t == "--force" || t == "-f" || t ~ /^-[A-Za-z]*f[A-Za-z]*$/) { force = 1; continue }
61
+ if (t == "--force-if-includes") continue # only modifies --force-with-lease; not a force on its own
62
+ if (t ~ /^--force-with-lease/) { lease = 1; continue }
63
+ if (t ~ /^--repo=/) { remote = substr(t, 8); continue }
64
+ if (t == "--repo" || t == "-o" || t == "--push-option" || t == "--receive-pack" || t == "--exec") { k++; continue }
65
+ if (t ~ /^-/) continue
66
+ if (remote == "") remote = t; else if (refspec == "") refspec = t
67
+ }
68
+ dst = refspec
69
+ if (substr(dst, 1, 1) == "+") { plus = 1; dst = substr(dst, 2) } # "+refspec" forces without --force
70
+ if (index(dst, ":") > 0) dst = substr(dst, index(dst, ":") + 1)
71
+ sub(/^refs\\/heads\\//, "", dst)
72
+ if (!force && !plus && !(lease && allow_lease == "false")) next
73
+ if (dst == "HEAD" || dst == "") dst = current
74
+ if (dst == "") { found = "force push with an undeterminable target branch"; next }
75
+ if (isp[dst]) found = "force push to " dst
76
+ }
77
+ END { if (found != "") print found }')
78
+
79
+ if [[ -n "$HIT" ]]; then
80
+ REASON="oh-my-harness: $HIT is blocked: rewriting a protected branch's history is not allowed. Push to a feature branch and open a PR."
81
+ _log_event "block" "$REASON"
82
+ _emit_decision "block" "$REASON"
83
+ exit 0
84
+ fi
85
+ exit 0`,
86
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const harnessGuard: BuildingBlock;
@@ -0,0 +1,88 @@
1
+ // Closes the Bash side of harness self-protection (#113). path-guard's
2
+ // protectHarness stops Edit/Write on the hook files; this block stops shell
3
+ // commands that would rewrite, delete or neuter them: file tools with a
4
+ // protected path as an argument, output redirections into a protected path,
5
+ // and git checkout/restore/clean/rm/mv on one. Read-only commands (cat, grep,
6
+ // ls, diff) stay allowed so the agent can inspect its own rules, and `omh` is
7
+ // never a writer here, so `omh sync` / `omh hook add` remain the sanctioned
8
+ // way to change the harness.
9
+ // ponytail: harness.yaml itself is deliberately not protected (it is the
10
+ // user's knob); per-hook `locked: true` is tracked in #118. `rm -rf .` / `*`
11
+ // from the project root are left to command-guard patterns.
12
+ export const harnessGuard = {
13
+ id: "harness-guard",
14
+ name: "Harness Guard",
15
+ description: "Blocks shell commands that would modify or delete the harness's own hooks and runtime config (.omh/, .claude/settings.json, .codex/, .pi/)",
16
+ category: "security",
17
+ event: "PreToolUse",
18
+ matcher: "Bash",
19
+ canBlock: true,
20
+ params: [
21
+ {
22
+ name: "extraPaths",
23
+ type: "string[]",
24
+ description: "Additional paths (relative to the project root) to protect from shell writes",
25
+ default: [],
26
+ required: false,
27
+ },
28
+ ],
29
+ tags: ["security", "bash", "guard", "harness", "self-protection"],
30
+ template: `#!/bin/bash
31
+ set -euo pipefail
32
+ INPUT=$(cat)
33
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
34
+ # Codex apply_patch carries a diff, not a shell command; path-guard covers it.
35
+ [[ "$TOOL_NAME" == "apply_patch" ]] && exit 0
36
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
37
+ [[ -z "$COMMAND" ]] && exit 0
38
+
39
+ PROTECTED=(".omh" ".claude/settings.json" ".codex/hooks.json" ".codex/config.toml" ".pi/extensions/omh-harness.ts" {{#each extraPaths}}"{{{this}}}" {{/each}})
40
+ WRITERS="rm mv cp sed tee chmod chown chgrp truncate dd ln touch install rsync shred unlink perl"
41
+ GIT_WRITERS="checkout restore clean rm mv"
42
+
43
+ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
44
+ -v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v writers="$WRITERS" -v gitw="$GIT_WRITERS" '
45
+ BEGIN {
46
+ np = split(protected, P, "|")
47
+ nw = split(writers, W, " "); for (k = 1; k <= nw; k++) isw[W[k]] = 1
48
+ ng = split(gitw, G, " "); for (k = 1; k <= ng; k++) isg[G[k]] = 1
49
+ }
50
+ function hits(t, k, p, L) {
51
+ sub(/^\\.\\//, "", t)
52
+ for (k = 1; k <= np; k++) {
53
+ p = P[k]; if (p == "") continue
54
+ L = length(p)
55
+ if (t == p) return p
56
+ if (substr(t, 1, L + 1) == p "/") return p
57
+ if (length(t) > L && substr(t, length(t) - L, L + 1) == "/" p) return p
58
+ if (index(t, "/" p "/") > 0) return p
59
+ }
60
+ return ""
61
+ }
62
+ found != "" { next }
63
+ {
64
+ if ($1 == "__omh_redirect__") { p = hits($2); if (p != "") found = "redirect into " p; next }
65
+ i = 1
66
+ while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
67
+ if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
68
+ if (i > NF) next
69
+ a0 = $i
70
+ write = 0
71
+ if (isw[a0]) write = 1
72
+ else if (a0 == "git") {
73
+ j = i + 1; while (j <= NF && $j ~ /^-/) { if ($j == "-c" || $j == "-C") j++; j++ }
74
+ if (j <= NF && isg[$j]) write = 1
75
+ }
76
+ else if (a0 == "find") { for (k = i; k <= NF; k++) if ($k == "-delete" || $k == "-exec" || $k == "-execdir") write = 1 }
77
+ if (!write) next
78
+ for (k = i + 1; k <= NF; k++) { p = hits($k); if (p != "") { found = a0 " on " p; next } }
79
+ }
80
+ END { if (found != "") print found }')
81
+ if [[ -n "$HIT" ]]; then
82
+ REASON="oh-my-harness: $HIT is blocked: the harness protects its own hooks and runtime config. Edit harness.yaml and run \\\`omh sync\\\` instead."
83
+ _log_event "block" "$REASON"
84
+ _emit_decision "block" "$REASON"
85
+ exit 0
86
+ fi
87
+ exit 0`,
88
+ };
@@ -16,6 +16,9 @@ import { configAudit } from "./config-audit.js";
16
16
  import { compactContext } from "./compact-context.js";
17
17
  import { worktreeSetup } from "./worktree-setup.js";
18
18
  import { loopGuard } from "./loop-guard.js";
19
+ import { harnessGuard } from "./harness-guard.js";
20
+ import { noVerifyGuard } from "./no-verify-guard.js";
21
+ import { forcePushGuard } from "./force-push-guard.js";
19
22
  export const builtinBlocks = [
20
23
  branchGuard,
21
24
  commitTestGate,
@@ -35,4 +38,7 @@ export const builtinBlocks = [
35
38
  compactContext,
36
39
  worktreeSetup,
37
40
  loopGuard,
41
+ harnessGuard,
42
+ noVerifyGuard,
43
+ forcePushGuard,
38
44
  ];
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const noVerifyGuard: BuildingBlock;
@@ -0,0 +1,61 @@
1
+ // Closes the hook-bypass hole (#98, first reported in #27): commit-test-gate
2
+ // and friends are PreToolUse hooks, but git's own pre-commit / pre-push hooks
3
+ // are a second line teams rely on, and an agent that learns "--no-verify"
4
+ // makes them vanish. Blocked forms, on any simple command that is `git commit`
5
+ // or `git push` (through env/sudo/sh -c, see #109/#110):
6
+ // git commit --no-verify | -n | any short cluster containing n (-anm, -qn)
7
+ // git push --no-verify
8
+ // git -c core.hooksPath=... <anything> (points hooks at an empty dir)
9
+ // `git push -n` is dry-run, not no-verify, so it stays allowed.
10
+ export const noVerifyGuard = {
11
+ id: "no-verify-guard",
12
+ name: "No-Verify Guard",
13
+ description: "Blocks git commit/push that bypass git hooks (--no-verify, -n, core.hooksPath override)",
14
+ category: "git",
15
+ event: "PreToolUse",
16
+ matcher: "Bash",
17
+ canBlock: true,
18
+ params: [],
19
+ tags: ["git", "hooks", "guard", "bypass"],
20
+ template: `#!/bin/bash
21
+ set -euo pipefail
22
+ INPUT=$(cat)
23
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
24
+ [[ -z "$COMMAND" ]] && exit 0
25
+
26
+ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' '
27
+ found != "" { next }
28
+ {
29
+ i = 1
30
+ while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
31
+ if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
32
+ if (i > NF || $i != "git") next
33
+ # global options before the subcommand; -c core.hooksPath=... is itself a bypass
34
+ j = i + 1
35
+ while (j <= NF && $j ~ /^-/) {
36
+ if ($j == "-c" || $j == "-C") {
37
+ if ($j == "-c" && j + 1 <= NF && $(j + 1) ~ /^core\\.hooksPath=/) { found = "git -c core.hooksPath override"; next }
38
+ j++
39
+ }
40
+ j++
41
+ }
42
+ if (j > NF) next
43
+ sub_cmd = $j
44
+ if (sub_cmd != "commit" && sub_cmd != "push") next
45
+ for (k = j + 1; k <= NF; k++) {
46
+ if ($k == "--") break
47
+ if ($k == "--no-verify") { found = "git " sub_cmd " --no-verify"; next }
48
+ if (sub_cmd == "commit" && $k ~ /^-[A-Za-z]*n[A-Za-z]*$/) { found = "git commit -n (no-verify)"; next }
49
+ if (sub_cmd == "commit" && $k == "-m") k++
50
+ }
51
+ }
52
+ END { if (found != "") print found }')
53
+
54
+ if [[ -n "$HIT" ]]; then
55
+ REASON="oh-my-harness: $HIT is blocked: git hooks are part of the harness. Fix what the hook reports instead of skipping it."
56
+ _log_event "block" "$REASON"
57
+ _emit_decision "block" "$REASON"
58
+ exit 0
59
+ fi
60
+ exit 0`,
61
+ };
@@ -13,6 +13,13 @@ export const pathGuard = {
13
13
  description: "Paths or directory prefixes to block (e.g. dist/, node_modules/)",
14
14
  required: true,
15
15
  },
16
+ {
17
+ name: "protectHarness",
18
+ type: "boolean",
19
+ description: "Also block edits to the harness's own hook scripts and runtime wiring (.omh/hooks/, .claude/settings.json, .codex/hooks.json, .codex/config.toml, .pi/extensions/omh-harness.ts) so the agent cannot disable its guardrails",
20
+ default: true,
21
+ required: false,
22
+ },
16
23
  ],
17
24
  tags: ["security", "file", "path", "guard"],
18
25
  template: `#!/bin/bash
@@ -39,6 +46,10 @@ fi
39
46
  [[ \${#FILE_PATHS[@]} -eq 0 ]] && exit 0
40
47
 
41
48
  BLOCKED_PATHS=({{#each blockedPaths}}"{{{this}}}" {{/each}})
49
+ {{#if protectHarness}}
50
+ # Guardrails guard themselves: the agent must not edit the hooks or the runtime config that wires them in.
51
+ BLOCKED_PATHS+=(".omh/hooks/" ".claude/settings.json" ".codex/hooks.json" ".codex/config.toml" ".pi/extensions/omh-harness.ts")
52
+ {{/if}}
42
53
  for FILE_PATH in "\${FILE_PATHS[@]}"; do
43
54
  # Normalize each path to prevent directory traversal attacks (e.g., ./foo/../dist/secret.js -> dist/secret.js)
44
55
  if command -v python3 >/dev/null 2>&1; then
@@ -21,7 +21,7 @@ set -euo pipefail
21
21
  INPUT=$(cat)
22
22
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
23
23
  [[ -z "$COMMAND" ]] && exit 0
24
- COMMAND_LOWER=$(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')
24
+ COMMAND_LOWER=$(_omh_simple_commands "$COMMAND" | tr '[:upper:]' '[:lower:]')
25
25
  PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
26
  for PATTERN in "\${PATTERNS[@]}"; do
27
27
  PATTERN_LOWER=$(echo "$PATTERN" | tr '[:upper:]' '[:lower:]')
@@ -12,6 +12,9 @@ export function formatCategoryName(category) {
12
12
  const names = {
13
13
  "path-guard": "File guards",
14
14
  "command-guard": "Command guards",
15
+ "harness-guard": "Harness self-protection",
16
+ "no-verify-guard": "Git hook bypass guard",
17
+ "force-push-guard": "Force push guard",
15
18
  "branch-guard": "Branch guard",
16
19
  "lockfile-guard": "Lockfile guard",
17
20
  "secret-file-guard": "Secret file guard",
@@ -178,6 +178,57 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
178
178
  });
179
179
  break;
180
180
  }
181
+ case "harness-guard": {
182
+ cases.push({
183
+ name: '"rm -rf .omh" → BLOCKED',
184
+ category: "harness-guard",
185
+ hookScript,
186
+ input: { tool_name: "Bash", tool_input: { command: "rm -rf .omh" } },
187
+ expectation: "block",
188
+ });
189
+ cases.push({
190
+ name: '"cat .omh/hooks/catalog-tdd-guard.sh" → ALLOWED',
191
+ category: "harness-guard",
192
+ hookScript,
193
+ input: { tool_name: "Bash", tool_input: { command: "cat .omh/hooks/catalog-tdd-guard.sh" } },
194
+ expectation: "allow",
195
+ });
196
+ break;
197
+ }
198
+ case "no-verify-guard": {
199
+ cases.push({
200
+ name: '"git commit --no-verify" → BLOCKED',
201
+ category: "no-verify-guard",
202
+ hookScript,
203
+ input: { tool_name: "Bash", tool_input: { command: "git commit -m x --no-verify" } },
204
+ expectation: "block",
205
+ });
206
+ cases.push({
207
+ name: '"git commit -m x" → ALLOWED',
208
+ category: "no-verify-guard",
209
+ hookScript,
210
+ input: { tool_name: "Bash", tool_input: { command: "git commit -m x" } },
211
+ expectation: "allow",
212
+ });
213
+ break;
214
+ }
215
+ case "force-push-guard": {
216
+ cases.push({
217
+ name: '"git push --force origin main" → BLOCKED',
218
+ category: "force-push-guard",
219
+ hookScript,
220
+ input: { tool_name: "Bash", tool_input: { command: "git push --force origin main" } },
221
+ expectation: "block",
222
+ });
223
+ cases.push({
224
+ name: '"git push origin main" → ALLOWED',
225
+ category: "force-push-guard",
226
+ hookScript,
227
+ input: { tool_name: "Bash", tool_input: { command: "git push origin main" } },
228
+ expectation: "allow",
229
+ });
230
+ break;
231
+ }
181
232
  case "branch-guard": {
182
233
  const isProtected = currentBranch === "main" || currentBranch === "master";
183
234
  cases.push({
@@ -95,6 +95,18 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
95
95
  }
96
96
  // Resolve registry — use provided one or create the default
97
97
  const resolvedRegistry = registry ?? (await createDefaultRegistry());
98
+ // A harness that enforces anything also guards itself: without this an
99
+ // agent could delete or rewrite the hooks through Bash (#113). An explicit
100
+ // entry may change its mode or add paths. Skipped for custom registries
101
+ // that do not ship the block.
102
+ // Same rule for the git-safety pair (#114): a harness that enforces anything
103
+ // gets no-verify-guard and force-push-guard on every runtime, instead of the
104
+ // Claude-only permissions.deny list the NL prompt sometimes emitted.
105
+ for (const id of ["harness-guard", "no-verify-guard", "force-push-guard"]) {
106
+ if (resolvedRegistry.has(id) && !allHookEntries.some((h) => h.block === id)) {
107
+ allHookEntries.push({ block: id, params: {}, mode: "block" });
108
+ }
109
+ }
98
110
  const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
99
111
  // Convert hooksConfig entries from catalog into HookDefinition format.
100
112
  // Errors are reported as warnings but don't block valid hooks.
@@ -8,6 +8,237 @@ import { OMH_VERSION } from "../utils/version.js";
8
8
  function shellSingleQuote(value) {
9
9
  return `'${value.replace(/'/g, `'\\''`)}'`;
10
10
  }
11
+ // Shell-token helpers shared by every generated hook (#109). Guards must not
12
+ // grep the raw command string: text inside quotes or heredocs is data, not a
13
+ // command, and a pattern like "rm -rf /" must not match "rm -rf /tmp". The
14
+ // tokenizer is a single-pass awk state machine so it runs anywhere bash does.
15
+ // _omh_simple_commands "<cmd>" one simple command per line, TAB-separated tokens, quotes resolved
16
+ // _omh_cmd_matches "<cmd>" argv0 [sub] 0 when some simple command is `argv0 [opts] sub`
17
+ // _omh_cmd_has_pattern "<cmd>" "<pat>" 0 when pat's tokens appear contiguously in some simple command
18
+ // ponytail: backticks and process substitution <(...) are treated as plain text;
19
+ // $(...) is parsed as a nested command stream. Wrapper unwrapping (sh -c, eval) is #110.
20
+ const OMH_CMD_TOKENIZER_AWK = String.raw `
21
+ { buf = buf $0 "\n" }
22
+ END {
23
+ n = length(buf); d = 0; q[0] = 0; tok[0] = ""; cmd[0] = ""; nhd[0] = 0
24
+ i = 1
25
+ while (i <= n) {
26
+ c = substr(buf, i, 1); c2 = substr(buf, i, 2)
27
+ if (q[d] == 1) { # inside single quotes
28
+ if (c == "\047") q[d] = 0; else tok[d] = tok[d] c
29
+ i++; continue
30
+ }
31
+ if (q[d] == 2) { # inside double quotes
32
+ if (c == "\"") { q[d] = 0; i++; continue }
33
+ if (c == "\\" && i < n) { tok[d] = tok[d] substr(buf, i+1, 1); i += 2; continue }
34
+ if (c2 == "$(") { d++; q[d] = 0; tok[d] = ""; cmd[d] = ""; nhd[d] = 0; i += 2; continue }
35
+ tok[d] = tok[d] c; i++; continue
36
+ }
37
+ if (c == "\047") { q[d] = 1; i++; continue }
38
+ if (c == "\"") { q[d] = 2; i++; continue }
39
+ if (c == "\\") { if (substr(buf, i+1, 1) != "\n") tok[d] = tok[d] substr(buf, i+1, 1); i += 2; continue }
40
+ if (c == "#" && tok[d] == "") { while (i <= n && substr(buf, i, 1) != "\n") i++; continue }
41
+ if (c2 == "$(") { d++; q[d] = 0; tok[d] = ""; cmd[d] = ""; nhd[d] = 0; i += 2; continue }
42
+ if (substr(buf, i, 3) == "<<<") { i += 3; skipnext[d] = 1; continue } # here-string: drop operator and word
43
+ if (c2 == "<<") { # heredoc: remember the delimiter, drop the operator
44
+ i += 2; strip[d, nhd[d] + 1] = 0; if (substr(buf, i, 1) == "-") { i++; strip[d, nhd[d] + 1] = 1 }
45
+ while (substr(buf, i, 1) == " ") i++
46
+ delim = ""
47
+ while (i <= n) { h = substr(buf, i, 1); if (h ~ /[ \t\n;|&)]/) break; if (h != "\047" && h != "\"") delim = delim h; i++ }
48
+ nhd[d]++; hd[d, nhd[d]] = delim; continue
49
+ }
50
+ if (c == ")" && d > 0) { # end of $( ... )
51
+ flush(d)
52
+ if (cmd[d] != "") print cmd[d]
53
+ d--; tok[d] = tok[d] "$(...)"; i++; continue
54
+ }
55
+ if (c ~ /[ \t]/) { flush(d); i++; continue }
56
+ if (c == "&" && tok[d] ~ />$/) { tok[d] = tok[d] "&"; i++; continue } # 2>&1, >&2
57
+ if (c2 == "&>") { flush(d); tok[d] = "&"; i++; continue } # &>log, &>>log
58
+ if (c ~ /[;|&()\n]/) { # command separator
59
+ flush(d)
60
+ if (cmd[d] != "") print cmd[d]; cmd[d] = ""; skipnext[d] = 0
61
+ i++
62
+ if (c == "\n" && nhd[d] > 0) { # skip heredoc bodies that start on the next line
63
+ for (k = 1; k <= nhd[d]; k++) {
64
+ while (i <= n) {
65
+ j = index(substr(buf, i), "\n"); line = (j ? substr(buf, i, j-1) : substr(buf, i))
66
+ i = (j ? i + j : n + 1); if (strip[d, k]) sub(/^\t+/, "", line)
67
+ if (line == hd[d, k]) break
68
+ }
69
+ }
70
+ nhd[d] = 0
71
+ }
72
+ continue
73
+ }
74
+ tok[d] = tok[d] c; i++
75
+ }
76
+ while (d >= 0) {
77
+ flush(d)
78
+ if (cmd[d] != "") print cmd[d]
79
+ d--
80
+ }
81
+ }
82
+ # Append the pending token to the current simple command, unless it is a
83
+ # redirection (2>/dev/null, >file, <in, 2>&1, &>log) or the word a bare
84
+ # redirection operator (2>, >, <, >>) applies to. Output-redirection targets
85
+ # are emitted as their own "__omh_redirect__<TAB>target" line so guards that
86
+ # care about writes (harness-guard) can see them; argv0 matchers never match
87
+ # that pseudo-command.
88
+ function flush(d, t) {
89
+ if (tok[d] == "") return
90
+ if (skipnext[d]) {
91
+ # the word an output redirection applies to: report it as a write target
92
+ if (skipnext[d] == 2) print "__omh_redirect__\t" tok[d]
93
+ skipnext[d] = 0; tok[d] = ""; return
94
+ }
95
+ if (tok[d] ~ /^[0-9]*(>>?|<|&>>?|>&)/) {
96
+ if (tok[d] ~ /^[0-9]*(>>?|&>>?)$/) skipnext[d] = 2 # bare "> " / "2> " / "&> ": next word is written
97
+ else if (tok[d] ~ /^<$/) skipnext[d] = 1 # bare "< ": next word is read
98
+ else if (tok[d] ~ /^[0-9]*(>>?|&>>?)[^&]/) { # attached ">file" (not the ">&2" dup form)
99
+ t = tok[d]; sub(/^[0-9]*(>>?|&>>?)/, "", t); print "__omh_redirect__\t" t
100
+ }
101
+ tok[d] = ""; return
102
+ }
103
+ cmd[d] = cmd[d] (cmd[d] == "" ? "" : "\t") tok[d]; tok[d] = ""
104
+ }`;
105
+ // Wrapper unwrapping (#110). Runs on each TAB-separated simple command the
106
+ // tokenizer emits. Leading wrappers that only change *how* a command runs
107
+ // (env, timeout, nohup, nice, xargs, command, exec, builtin, time) are
108
+ // stripped so the real argv0 is matched (sudo/doas are kept as tokens so user
109
+ // patterns like "sudo rm" still work; _omh_cmd_matches skips them). `sh -c <string>` and `eval <words>`
110
+ // carry the real command as data, so those are printed as "S<TAB><string>"
111
+ // for the bash side to re-tokenize; everything else is "R<TAB><tokens>".
112
+ // ponytail: bounded to 4 levels; deeper nesting is left unparsed rather than
113
+ // blocked, so a pathological command cannot make every guard fire at once.
114
+ const OMH_CMD_UNWRAP_AWK = String.raw `
115
+ BEGIN { FS = "\t" }
116
+ {
117
+ i = 1
118
+ while (i <= NF) {
119
+ while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
120
+ if (i > NF) break
121
+ f = $i
122
+ if (f == "env") {
123
+ i++; while (i <= NF && $i ~ /^-/) { if ($i ~ /^-(u|C|S)$/) i++; i++ }; continue
124
+ }
125
+ if (f == "nohup" || f == "command" || f == "exec" || f == "builtin" || f == "time") {
126
+ i++; while (i <= NF && $i ~ /^-/) i++; continue
127
+ }
128
+ if (f == "nice") {
129
+ i++; while (i <= NF && $i ~ /^-/) { if ($i == "-n") i++; i++ }; continue
130
+ }
131
+ if (f == "timeout") {
132
+ i++; while (i <= NF && $i ~ /^-/) { if ($i ~ /^-(s|k)$/) i++; i++ }; i++; continue
133
+ }
134
+ if (f == "xargs") {
135
+ i++; while (i <= NF && $i ~ /^-/) { if ($i ~ /^-(I|n|L|P|s|d|E|a)$/) i++; i++ }; continue
136
+ }
137
+ if (f == "sh" || f == "bash" || f == "zsh" || f == "dash" || f == "ksh") {
138
+ j = i + 1
139
+ while (j <= NF && $j ~ /^-/) {
140
+ if ($j ~ /^-[A-Za-z]*c$/) { if (j + 1 <= NF) { print "S\t" $(j + 1); next }; break }
141
+ j++
142
+ }
143
+ break
144
+ }
145
+ if (f == "eval") {
146
+ out = ""; for (k = i + 1; k <= NF; k++) out = out (k > i + 1 ? " " : "") $k
147
+ print "S\t" out; next
148
+ }
149
+ break
150
+ }
151
+ if (i > NF) next
152
+ out = ""; for (k = i; k <= NF; k++) out = out (k > i ? "\t" : "") $k
153
+ print "R\t" out
154
+ }`;
155
+ // Working-tree fingerprint for gates that want to skip a re-run when nothing
156
+ // changed since the last pass (#112): HEAD plus the tree hash of a temporary
157
+ // index with everything (tracked + untracked, .gitignore respected) added.
158
+ // Prints "none" outside a git repo so callers can refuse to cache.
159
+ const OMH_TREE_FINGERPRINT = `_omh_tree_fingerprint() {
160
+ local root head idx tree
161
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo none; return 0; }
162
+ head=$(git -C "$root" rev-parse HEAD 2>/dev/null || echo empty)
163
+ # Start from an EMPTY temporary index, never a copy of the real one: git
164
+ # trusts cached stat data, so a file rewritten within the same second at the
165
+ # same size would be reported unchanged (seen on Linux CI). An empty index
166
+ # forces every file to be hashed. The real index is never touched.
167
+ idx=$(mktemp) || { echo none; return 0; }
168
+ rm -f "$idx"
169
+ # Always from the repository root, whatever directory the hook runs in.
170
+ # .omh/state is hook-owned scratch (events.jsonl grows on every hook run) and
171
+ # must never count as a change, whether or not the user gitignored it.
172
+ tree=$(GIT_INDEX_FILE="$idx" git -C "$root" add -A -- . ':(exclude).omh/state' >/dev/null 2>&1 && GIT_INDEX_FILE="$idx" git -C "$root" write-tree 2>/dev/null) || tree=none
173
+ rm -f "$idx"
174
+ [ "$tree" = "none" ] && { echo none; return 0; }
175
+ printf '%s:%s\\n' "$head" "$tree"
176
+ }
177
+ # Gate cache. Capture the fingerprint BEFORE running the gate command and pass
178
+ # it to both helpers, so what gets recorded is the tree the command actually
179
+ # checked, not whatever it left behind (snapshot updates, formatters).
180
+ # _omh_gate_cached <name> <ttl-seconds> <fp> -> 0 when the last recorded pass
181
+ # for <name> is <fp> and younger than ttl.
182
+ # _omh_gate_record <name> <fp> -> remember <fp> as passed.
183
+ _omh_gate_cached() {
184
+ local name="$1" ttl="\${2:-0}" fp="\${3:-none}" file now cached_fp cached_ts
185
+ [ "$ttl" -gt 0 ] 2>/dev/null || return 1
186
+ [ "$fp" = "none" ] && return 1
187
+ file="\${_OMH_STATE_DIR:-.omh/state}/gate-$name.fp"
188
+ [ -f "$file" ] || return 1
189
+ read -r cached_fp cached_ts < "$file" || return 1
190
+ now=$(date +%s)
191
+ [ "$cached_fp" = "$fp" ] && [ $((now - cached_ts)) -le "$ttl" ]
192
+ }
193
+ _omh_gate_record() {
194
+ local name="$1" fp="\${2:-none}" file
195
+ [ "$fp" = "none" ] && return 0
196
+ file="\${_OMH_STATE_DIR:-.omh/state}/gate-$name.fp"
197
+ mkdir -p "$(dirname "$file")" 2>/dev/null || true
198
+ printf '%s %s\\n' "$fp" "$(date +%s)" > "$file"
199
+ }`;
200
+ const OMH_CMD_HELPERS = `${OMH_TREE_FINGERPRINT}
201
+ _omh_tokenize() {
202
+ printf '%s\\n' "\${1:-}" | awk '${OMH_CMD_TOKENIZER_AWK}'
203
+ }
204
+ _omh_simple_commands() {
205
+ local depth="\${2:-0}" kind rest
206
+ _omh_tokenize "\${1:-}" | while IFS= read -r line; do
207
+ printf '%s\\n' "$line" | awk '${OMH_CMD_UNWRAP_AWK}' | while IFS=$'\\t' read -r kind rest; do
208
+ if [ "$kind" = "S" ] && [ "$depth" -lt 4 ]; then
209
+ _omh_simple_commands "$rest" $((depth + 1))
210
+ elif [ "$kind" = "S" ]; then
211
+ printf '%s\\n' "$line"
212
+ else
213
+ printf '%s\\n' "$rest"
214
+ fi
215
+ done
216
+ done
217
+ }
218
+ _omh_cmd_matches() {
219
+ local a0="\${2:-}" sc="\${3:-}"
220
+ _omh_simple_commands "\${1:-}" | awk -F '\t' -v a0="$a0" -v sc="$sc" '
221
+ { i = 1
222
+ while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) i++
223
+ if (i <= NF && ($i == "sudo" || $i == "doas")) {
224
+ i++; while (i <= NF && $i ~ /^-/) { if ($i ~ /^-(u|g|C|D|h|p|r|t|T|U)$/) i++; i++ }
225
+ }
226
+ if (i > NF || $i != a0) next
227
+ if (sc == "") { found = 1; next }
228
+ i++
229
+ while (i <= NF && $i ~ /^-/) { if ($i == "-c" || $i == "-C") i++; i++ }
230
+ if (i <= NF && $i == sc) { found = 1; next } }
231
+ END { exit found ? 0 : 1 }'
232
+ }
233
+ _omh_cmd_has_pattern() {
234
+ _omh_simple_commands "\${1:-}" | awk -F '\t' -v pat="\${2:-}" '
235
+ BEGIN { n = split(pat, p, " ") }
236
+ { for (s = 1; s + n - 1 <= NF; s++) {
237
+ ok = 1
238
+ for (k = 1; k <= n; k++) if ($(s + k - 1) != p[k]) { ok = 0; break }
239
+ if (ok) { found = 1; next } } }
240
+ END { exit found ? 0 : 1 }'
241
+ }`;
11
242
  function buildLoggerSnippet(event, projectDir, mode = "block") {
12
243
  const stateDir = projectDir
13
244
  ? `${projectDir}/${OMH_STATE_DIR}`
@@ -75,21 +306,22 @@ _emit_decision() {
75
306
  jq -cn --arg decision "$decision" --arg reason "$reason" \\
76
307
  '{decision:$decision,reason:$reason}'
77
308
  }
309
+ ${OMH_CMD_HELPERS}
78
310
  trap '_OMH_EXIT_CODE=$?; if [ "$_OMH_LOGGED" -eq 0 ]; then if [ "$_OMH_EXIT_CODE" -ne 0 ]; then _log_event "error" "hook exited with code $_OMH_EXIT_CODE"; else _log_event "allow"; fi; fi' EXIT
79
311
  # --- end logger ---`;
80
312
  }
81
313
  export function wrapWithLogger(script, event = "unknown", projectDir, mode = "block") {
82
314
  const snippet = buildLoggerSnippet(event, projectDir, mode);
83
315
  if (script.includes("INPUT=$(cat)")) {
84
- return script.replace("INPUT=$(cat)", `INPUT=$(cat)\n\n${snippet}`);
316
+ return script.replace("INPUT=$(cat)", () => `INPUT=$(cat)\n\n${snippet}`);
85
317
  }
86
318
  if (script.includes("set -euo pipefail")) {
87
- return script.replace("set -euo pipefail", `set -euo pipefail\n\n${snippet}`);
319
+ return script.replace("set -euo pipefail", () => `set -euo pipefail\n\n${snippet}`);
88
320
  }
89
321
  // shebang 패턴: #!/bin/bash, #!/usr/bin/env bash, #!/bin/sh 등
90
322
  const shebangMatch = script.match(/^#!.+$/m);
91
323
  if (shebangMatch) {
92
- return script.replace(shebangMatch[0], `${shebangMatch[0]}\n\n${snippet}`);
324
+ return script.replace(shebangMatch[0], () => `${shebangMatch[0]}\n\n${snippet}`);
93
325
  }
94
326
  return `${snippet}\n${script}`;
95
327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,18 @@
34
34
  "claude-code",
35
35
  "tdd",
36
36
  "hooks",
37
- "cli"
37
+ "cli",
38
+ "codex",
39
+ "openai-codex",
40
+ "agents-md",
41
+ "cursorrules",
42
+ "ai-coding-agent",
43
+ "guardrails",
44
+ "tdd-guard",
45
+ "pretooluse",
46
+ "coding-agent",
47
+ "claude-md",
48
+ "cli-tool"
38
49
  ],
39
50
  "author": "kyu1204",
40
51
  "license": "MIT",