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 +5 -0
- package/dist/catalog/blocks/auto-pr.js +1 -1
- package/dist/catalog/blocks/branch-guard.js +1 -1
- package/dist/catalog/blocks/command-guard.js +1 -2
- package/dist/catalog/blocks/commit-test-gate.js +15 -1
- package/dist/catalog/blocks/commit-typecheck-gate.js +15 -1
- package/dist/catalog/blocks/force-push-guard.d.ts +2 -0
- package/dist/catalog/blocks/force-push-guard.js +86 -0
- package/dist/catalog/blocks/harness-guard.d.ts +2 -0
- package/dist/catalog/blocks/harness-guard.js +88 -0
- package/dist/catalog/blocks/index.js +6 -0
- package/dist/catalog/blocks/no-verify-guard.d.ts +2 -0
- package/dist/catalog/blocks/no-verify-guard.js +61 -0
- package/dist/catalog/blocks/path-guard.js +11 -0
- package/dist/catalog/blocks/sql-guard.js +1 -1
- package/dist/cli/commands/test.js +3 -0
- package/dist/cli/harness-tester.js +51 -0
- package/dist/core/harness-converter-v2.js +12 -0
- package/dist/generators/hooks.js +235 -3
- package/package.json +13 -2
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 !
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,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,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,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=$(
|
|
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.
|
package/dist/generators/hooks.js
CHANGED
|
@@ -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.
|
|
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",
|