navori 0.6.0 → 0.6.1

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.
Files changed (33) hide show
  1. package/dist/assets/core/core-assets/agents/auditor.md +1 -1
  2. package/dist/assets/core/core-assets/agents/commit-pr-pilot.md +13 -1
  3. package/dist/assets/core/core-assets/agents/explorer.md +1 -1
  4. package/dist/assets/core/core-assets/agents/implementer.md +1 -1
  5. package/dist/assets/core/core-assets/agents/leader.md +2 -1
  6. package/dist/assets/core/core-assets/agents/researcher.md +1 -1
  7. package/dist/assets/core/core-assets/agents/reviewer.md +1 -1
  8. package/dist/assets/core/core-assets/agents/ticket-audit.md +6 -2
  9. package/dist/assets/core/core-assets/hooks/_partials/extract-cmd.sh +20 -8
  10. package/dist/assets/core/core-assets/hooks/_partials/gate-trigger.sh +10 -5
  11. package/dist/assets/core/core-assets/hooks/_partials/resolve-worktree.sh +150 -0
  12. package/dist/assets/core/core-assets/hooks/guard-destructive.sh +227 -9
  13. package/dist/assets/core/core-assets/hooks/quality-gate-pre-commit.sh +16 -9
  14. package/dist/assets/core/core-assets/hooks/subagent-stop-handoff.sh +5 -2
  15. package/dist/assets/core/core-assets/lib-skills/i18next.md +3 -3
  16. package/dist/assets/core/core-assets/managed/arranque-sesion.md +2 -4
  17. package/dist/assets/core/core-assets/managed/cierre-sesion.md +5 -3
  18. package/dist/assets/core/core-assets/managed/intake-tickets.md +1 -1
  19. package/dist/assets/core/core-assets/managed/orquestacion.md +18 -3
  20. package/dist/assets/core/core-assets/settings/settings-base.json +10 -1
  21. package/dist/assets/core/core-assets/skills/loop-back-debug.md +1 -1
  22. package/dist/assets/core/core-assets/skills/review-diff.md +1 -1
  23. package/dist/assets/core/core-assets/skills/spec-bootstrap.md +2 -1
  24. package/dist/assets/core/core-assets/skills/ticket-intake.md +16 -18
  25. package/dist/assets/core/core-assets/skills/verify-before-done.md +2 -2
  26. package/dist/assets/core/package.json +0 -2
  27. package/dist/assets/plugins/engram/managed/engram-protocol.md +4 -3
  28. package/dist/assets/plugins/engram/skills/engram-leader.md +2 -2
  29. package/dist/assets/plugins/jscpd/scripts/check-jscpd.sh +31 -12
  30. package/dist/assets/plugins/semgrep/managed/semgrep-protocol.md +1 -1
  31. package/dist/assets/plugins/semgrep/scripts/check-semgrep.sh +170 -13
  32. package/dist/index.js +324 -316
  33. package/package.json +3 -1
@@ -35,7 +35,7 @@ If there's a recent audit of the same scope and the code hasn't changed, read it
35
35
  ## Protocol
36
36
 
37
37
  ### 1. Startup
38
- Read `CLAUDE.md` (project rules + the orchestrator block) and the `user-section` below. Set the scope: **targeted** (1 file/feature/module) or **full** (all of `src/`).
38
+ `CLAUDE.md` (project rules + the orchestrator block) is already in your context when your host injects it — read it from disk ONLY if your host did not inject it. Read the `user-section` below. Set the scope: **targeted** (1 file/feature/module) or **full** (all of `src/`).
39
39
 
40
40
  ### 2. Context gathering
41
41
  Explore **yourself** — you are a subagent and cannot launch others (`Agent` does not nest). For broad scope: `Glob` the structure, `Grep` the risk patterns, and read in full only the candidate files. Don't read generated/lock artifacts or library `ui`.
@@ -186,10 +186,22 @@ Never open the PR with the gate red.
186
186
 
187
187
  If the repo defines its own template (`.github/pull_request_template.md`), read it and match its structure instead of the default.
188
188
 
189
+ ### Always-on delta — a number in the body, never a gate
190
+
191
+ Whatever template you follow: when the shipping diff changes the **always-on layer** — the harness context every session pays for up front, i.e. the rendered `CLAUDE.md` — the body states its byte delta, measured against the same base as the PR diff:
192
+
193
+ ```bash
194
+ git show origin/{{prTarget}}:CLAUDE.md 2>/dev/null | wc -c # before (0 if the file is new)
195
+ wc -c CLAUDE.md # after
196
+ ```
197
+
198
+ - **It is a number, never a gate.** Nothing blocks on it and no automatic limit judges it: a non-deterministic check wired into the gate only teaches everyone to ignore the gate. A ceiling, if the repo wants one, belongs in an explicit deterministic cap of its own — not in this line and not in the PR flow.
199
+ - **Growth is not a veto.** State the delta AND its counterpart: what those bytes buy — payload they remove from every session, a duplicated block they retire, a failure mode they close. Bytes added up front to save a multiple of them per session is a good trade; the point is that the trade is on the record, not that the number stays small. A delta reported without its counterpart is half the measurement.
200
+ - Silent when the diff leaves that file alone. A "Δ 0" bullet is noise, not rigor.
201
+
189
202
  ## Hard rules
190
203
 
191
204
  - ❌ Never push with `--force` to `{{branchBase}}` or another protected branch.
192
- - ❌ Never commit `.claude/` or `CLAUDE.md` (gitignored by convention).
193
205
  - ❌ Never skip hooks (`--no-verify`) unless the user explicitly asks.
194
206
  - ❌ Never ask for a merge / approve the PR yourself. Your job ends with the URL.
195
207
  - ❌ Never `gh pr checks --watch`: it takes no timeout and would hang the turn before the URL reaches the user.
@@ -23,7 +23,7 @@ If the question is specific ("where is X?"), it's not you — it's `researcher`.
23
23
 
24
24
  ## Protocol
25
25
 
26
- 1. Read `CLAUDE.md` to understand the repo's conventions.
26
+ 1. `CLAUDE.md` carries the repo's conventions — it is already in your context when your host injects it; read it from disk ONLY if your host did not inject it.
27
27
  2. Define the scope: a folder, a logical module, a file pattern. The orchestrator should hand it to you precisely; if it arrives ambiguous, return `blocked` naming the options (folder X / module Y / pattern Z) so it re-sends it scoped — don't guess.
28
28
  3. Walk from the entry points (routes, module root exports, `index.ts`) toward the leaves. For each level, list files and their brief role. Apply `.claude/skills/structural-search/SKILL.md` to locate shapes and entry points without reading whole files.
29
29
  4. Identify reverse dependencies: which external modules consume this module? That indicates the "blast radius" of changing something here.
@@ -12,7 +12,7 @@ You execute **a single** task from start to verification. You don't orchestrate,
12
12
 
13
13
  ## Protocol
14
14
 
15
- 1. **Read** `CLAUDE.md`. Identify the repo's conventions and the "Project rules" (the orchestrator's section in `CLAUDE.md`). Then read whatever prior artifact your scope names — `.claude/progress/audit_ticket_<ID>.md`, `solution_<scope>.md`, `explore_*.md`: that context was already paid for in tokens, and a solution artifact means the approach is DECIDED. You implement it; you don't redesign it. If you believe the design is wrong, say so in your report and stop — don't quietly build something else.
15
+ 1. **Ground yourself in** `CLAUDE.md` it is already in your context when your host injects it; identify the repo's conventions and the "Project rules" (the orchestrator's section) from there, and read it from disk ONLY if your host did not inject it (e.g. an engine without automatic injection). Then read whatever prior artifact your scope names — `.claude/progress/audit_ticket_<ID>.md`, `solution_<scope>.md`, `explore_*.md`: that context was already paid for in tokens, and a solution artifact means the approach is DECIDED. You implement it; you don't redesign it. If you believe the design is wrong, say so in your report and stop — don't quietly build something else.
16
16
  2. **Note** in `.claude/progress/impl_<feature>.md` (your working file; on close it becomes the report):
17
17
  - `Task: <brief description>`
18
18
  - `Root cause: <file:line + why>` (only if the task is a bugfix; you can't touch code without this).
@@ -14,7 +14,7 @@ Your only job as orchestrator is to **decompose and coordinate**, never to imple
14
14
 
15
15
  ## Startup protocol
16
16
 
17
- 1. Read `CLAUDE.md` (stack, conventions, quality gate).
17
+ 1. `CLAUDE.md` (stack, conventions, quality gate) is already in your context when your host injects it; read it from disk ONLY if your host did not inject it (e.g. an engine without automatic injection).
18
18
  2. The catalog of subagents and skills is in `CLAUDE.md` (`## Available agents`, `## Available skills`).
19
19
  3. Read `progress/current.md` (repo root) if it exists — the previous session's state.
20
20
  4. Identify the task's scope against the "Project rules" below (legacy paths, critical areas, repo conventions).
@@ -96,6 +96,7 @@ Expected files:
96
96
  - `.claude/progress/plan_<scope>.md` — the `auditor`'s prioritized plan that accompanies a deep audit
97
97
  - `.claude/progress/explore_<topic>.md` — broad map (`explorer`)
98
98
  - `.claude/progress/research_<question>.md` — scoped question (`researcher`)
99
+ - `.claude/progress/solution_<scope>.md` — the design pass's decision record (`solution-design` skill), plus `solution_review_<scope>.md` for its fresh-context challenge (`researcher`)
99
100
  - `.claude/progress/impl_<feature>.md` — the `implementer`'s report (includes its `Status: DONE | BLOCKED`)
100
101
  - `.claude/progress/review_<feature>.md` — the `reviewer`'s verdict
101
102
  - `.claude/progress/receipt.txt` — the `reviewer`'s content receipt on `APPROVED` (binds the diff to the reviewed bytes; consumed by `commit-pr-pilot`)
@@ -34,7 +34,7 @@ hypothetical future abstractions or optional edge cases as BLOCKER.
34
34
 
35
35
  ## Protocol
36
36
 
37
- 1. Read `CLAUDE.md` to understand the repo's context.
37
+ 1. `CLAUDE.md` carries the repo's context — it is already in your context when your host injects it; read it from disk ONLY if your host did not inject it.
38
38
  2. Work on ONE scoped question (the orchestrator already handed you the scope). If you discover it's actually >2 independent questions, return them as a list so the orchestrator distributes them across parallel researchers — don't chain them in series yourself.
39
39
  3. Run the search:
40
40
  - Primary method: the native `Grep` (content) and `Glob` (files by name/pattern) tools. They're read-only, fast (ripgrep), and don't ask for permission.
@@ -14,7 +14,7 @@ You are a strict reviewer. Your only function is to **approve or reject**. You d
14
14
 
15
15
  ### Setup (common to both passes)
16
16
 
17
- 1. Read `CLAUDE.md`, `.claude/progress/impl_<feature>.md`, `.claude/progress/audit_ticket_<ID>.md` and `.claude/progress/solution_<scope>.md` (whichever exist). When there IS a solution artifact, the diff is judged against the approach it records — an implementation that quietly took a different path is a `SPEC_MISS`, even if the code is good. You do NOT re-open the design itself: whether that approach was the right one was settled in its own phase; your question is whether the code did what was agreed.
17
+ 1. Ground yourself in `CLAUDE.md` — already in your context when your host injects it; read it from disk ONLY if your host did not inject it. Then read `.claude/progress/impl_<feature>.md`, `.claude/progress/audit_ticket_<ID>.md` and `.claude/progress/solution_<scope>.md` (whichever exist). When there IS a solution artifact, the diff is judged against the approach it records — an implementation that quietly took a different path is a `SPEC_MISS`, even if the code is good. You do NOT re-open the design itself: whether that approach was the right one was settled in its own phase; your question is whether the code did what was agreed.
18
18
  2. Identify modified files. Diff against `{{prTarget}}` (the PR's target
19
19
  branch), **not** against the fork point: it's the EXACT diff GitHub will show and
20
20
  the one commit-pr-pilot reviews. `{{branchBase}}` and `{{prTarget}}` are usually
@@ -14,7 +14,7 @@ Your first job is NOT to plan the implementation — it is to establish **what t
14
14
 
15
15
  ## When to trigger
16
16
 
17
- - Bug in a critical feature (auth, RBAC, payments, data integrity, areas listed in `{{project.criticalAreas}}`).
17
+ - Bug in a critical feature (`{{project.criticalAreas}}`).
18
18
  - Before a structural migration (legacy → new backend, monolith → microservices, etc.).
19
19
  - New feature that crosses >3 layers (service → adapter → component → store).
20
20
  - Bug described in natural language with no clear hint of where to look.
@@ -41,9 +41,11 @@ ls .claude/progress/audit_ticket_*.md 2>/dev/null
41
41
 
42
42
  If you find a recent audit for the same ticket, read it first. Don't re-audit if the context hasn't changed.
43
43
 
44
+ **Scoped to ONE area?** When the orchestrator fans the intake's phase 2 out (the fan-out row of the orchestration table's signal→mechanism lookup), your encargo names ONE area: audit that area only, write `audit_ticket_<ID-area>.md` — e.g. `audit_ticket_BTBS-138-webapp.md` — so parallel auditors never overwrite one file, and issue the verdict FOR YOUR AREA. Don't reconcile it with the sibling areas: that synthesis is the orchestrator's, and its result is what lands in `audit_ticket_<ID>.md`.
45
+
44
46
  ## Flow
45
47
 
46
- 1. **Read**: `CLAUDE.md` (project rules + the orchestrator's role).
48
+ 1. **Ground**: `CLAUDE.md` (project rules + the orchestrator's role) — already in your context when your host injects it; read it from disk ONLY if your host did not inject it.
47
49
  2. **Curate repo context** for your analysis:
48
50
  - Literal text of the ticket (don't paraphrase).
49
51
  - Grep for the ticket's keywords → candidate files.
@@ -141,6 +143,8 @@ One line:
141
143
  done -> .claude/progress/audit_ticket_<ID>.md
142
144
  ```
143
145
 
146
+ (`audit_ticket_<ID-area>.md` when your scope was one area of a fan-out.)
147
+
144
148
  The leader reads the audit from disk and decomposes from there.
145
149
 
146
150
  <!-- navori:user-section -->
@@ -3,19 +3,31 @@
3
3
  # of truth for the sibling gate scripts; DO NOT copy this body back into a hook
4
4
  # by hand (that is the drift #225/#261 removed).
5
5
  #
6
- # PreToolUse(Bash) passes the tool input on stdin. Extract .tool_input.command
6
+ # PreToolUse(Bash) passes the tool input on stdin. Read one field out of it
7
7
  # WITHOUT hard-depending on jq (NOT preinstalled on macOS): try jq, then node
8
- # (Claude Code's own runtime), then a best-effort sed unwrap. No command
9
- # extracted → empty $cmd, and each caller decides what that means (the gate
10
- # scripts scan defensively; guard-destructive waves the command through).
8
+ # (Claude Code's own runtime), then a best-effort sed unwrap on the leaf key.
9
+ # Nothing extracted → empty output, and each caller decides what that means (the
10
+ # gate scripts scan defensively; guard-destructive waves the command through).
11
+ #
12
+ # $1 is a dotted path written HERE, never user input — the payload is the data.
13
+ # Generic on purpose: `.cwd` feeds the worktree resolver of #454 through the
14
+ # SAME hardened cascade instead of a second copy of it.
15
+ #
16
+ # $2 overrides the sed fallback's capture. `.*` (greedy, to the last quote on the
17
+ # line) is right for `command`, whose value can itself contain escaped quotes and
18
+ # which Claude Code sends LAST. Every other field takes the default `[^"]*` run,
19
+ # so a value with more JSON after it is not swallowed whole.
11
20
  payload=$(cat)
12
- extract_cmd() {
21
+ payload_field() {
13
22
  if command -v jq >/dev/null 2>&1; then
14
- printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null && return 0
23
+ printf '%s' "$payload" | jq -r ".$1 // empty" 2>/dev/null && return 0
15
24
  fi
16
25
  if command -v node >/dev/null 2>&1; then
17
- printf '%s' "$payload" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(s)?.tool_input?.command??""))}catch{}})' 2>/dev/null && return 0
26
+ printf '%s' "$payload" | node -e 'let s="";const p=process.argv[1].split(".");process.stdin.on("data",c=>s+=c).on("end",()=>{try{let v=JSON.parse(s);for(const k of p)v=v?.[k];process.stdout.write(String(v??""))}catch{}})' "$1" 2>/dev/null && return 0
18
27
  fi
19
- printf '%s' "$payload" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p'
28
+ printf '%s' "$payload" | sed -n "s/.*\"${1##*.}\"[[:space:]]*:[[:space:]]*\"\(${2:-[^\"]*}\)\".*/\1/p"
29
+ }
30
+ extract_cmd() {
31
+ payload_field tool_input.command '.*'
20
32
  }
21
33
  cmd=$(extract_cmd)
@@ -15,15 +15,20 @@
15
15
  # does NOT trigger it. Known limitation: it cannot see through `sh -c`, `eval`,
16
16
  # or obfuscation — a seatbelt, not a sandbox.
17
17
  is_scan_trigger() {
18
- local input="$1" segment
18
+ # Pre-expanded newline: zsh does NOT expand $'\n' in the REPLACEMENT of
19
+ # ${var//pat/repl} (it inserts the literal characters), so an inline $'\n'
20
+ # left compound commands unsplit there and the gate silently skipped
21
+ # `cd x && git commit` (#391). A plain variable expands identically in
22
+ # bash and zsh. ($'\n' in PATTERN position expands fine in both.)
23
+ local input="$1" segment nl=$'\n'
19
24
  # FIX B: join `\<newline>` continuations into a space FIRST, so a command
20
25
  # split across lines with a trailing backslash stays ONE logical segment
21
26
  # (otherwise the subcommand/flag lands in a segment not starting with git).
22
27
  input="${input//\\$'\n'/ }"
23
- input="${input//&&/$'\n'}"
24
- input="${input//||/$'\n'}"
25
- input="${input//;/$'\n'}"
26
- input="${input//|/$'\n'}"
28
+ input="${input//&&/$nl}"
29
+ input="${input//||/$nl}"
30
+ input="${input//;/$nl}"
31
+ input="${input//|/$nl}"
27
32
  # `<<<` feeds the already-expanded value as data — no re-evaluation — so a
28
33
  # command that contains backticks/$() is inspected, never executed.
29
34
  while IFS= read -r segment; do
@@ -0,0 +1,150 @@
1
+ # Shared worktree resolver — inlined into each gate hook at render time (see the
2
+ # include directive in the source scripts + lib/hook-includes.ts). Requires the
3
+ # `extract-cmd` partial to have run first ($payload and $cmd in scope).
4
+ #
5
+ # WHY (#454): settings.json invokes these hooks as
6
+ # `bash "$CLAUDE_PROJECT_DIR/.claude/scripts/check-semgrep.sh"`, so the hook
7
+ # PROCESS starts in the MAIN repo even when the commit happens inside an agent
8
+ # worktree under `.claude/worktrees/`. The old `cd "$(git rev-parse
9
+ # --show-toplevel)"` therefore resolved the main repo — whose tree is clean — so
10
+ # `git diff --name-only main` returned 0 files and the gate exited 0. Not a false
11
+ # negative from the scanner: the scanner never ran. A `cd` to an arbitrary tree
12
+ # is exactly the bug, so nothing below ever guesses: every candidate must prove
13
+ # it is a git working tree, or the next one is tried — and the one the COMMAND
14
+ # names must additionally prove it is part of the repository being protected
15
+ # (see the same-repository constraint in `navori_worktree`).
16
+ #
17
+ # `navori_worktree` prints the absolute root of the working tree the gated git
18
+ # command will act on, or nothing when none resolves. It is a FUNCTION, not a
19
+ # top-level assignment, because `quality-gate-pre-commit` inlines this partial on
20
+ # a path that runs on EVERY Bash tool call: callers pay the git/jq probes only
21
+ # after their own trigger matched.
22
+
23
+ # First shell token of $1: a leading single/double-quoted string (so a path with
24
+ # spaces survives) or an unquoted run of non-space characters.
25
+ navori_first_token() {
26
+ local s="$1"
27
+ case "$s" in
28
+ \"*) s="${s#\"}"; printf '%s' "${s%%\"*}" ;;
29
+ \'*) s="${s#\'}"; printf '%s' "${s%%\'*}" ;;
30
+ *) printf '%s' "${s%%[[:space:]]*}" ;;
31
+ esac
32
+ }
33
+
34
+ # Directory named by the command itself, if any. Two shapes, both real in this
35
+ # harness: `git -C <dir> commit …` (git names its own tree) and
36
+ # `cd <dir> && git commit …` (how an agent commits into its worktree from a
37
+ # session anchored elsewhere). Prints the raw token; the caller resolves it.
38
+ #
39
+ # TODO(scope): only the FIRST segment is inspected for `cd`. A `cd` buried
40
+ # mid-chain (`pnpm build && cd sub && git commit`) falls through to the payload
41
+ # cwd, which is right whenever that `cd` stays inside the same repo. Walk the
42
+ # segments if a real command shows up where it does not.
43
+ navori_cmd_dir() {
44
+ local text="$1" rest="" head=""
45
+ case "$text" in
46
+ *"git -C "*)
47
+ rest="${text#*git -C }"
48
+ ;;
49
+ *)
50
+ head="${text%%&&*}"
51
+ head="${head#"${head%%[![:space:]]*}"}"
52
+ case "$head" in
53
+ "cd "*) rest="${head#cd }" ;;
54
+ esac
55
+ ;;
56
+ esac
57
+ [ -n "$rest" ] || return 1
58
+ rest="${rest#"${rest%%[![:space:]]*}"}"
59
+ # A token that needs the shell to expand it ($VAR, `sub`, ~, globs) is NOT
60
+ # resolved here: guessing wrong points the scan at the wrong tree, which is
61
+ # the very failure this partial exists to kill. Ignoring it just falls through
62
+ # to the next candidate.
63
+ case "$rest" in
64
+ ""|*'$'*|*'`'*|*'*'*|*'?'*|"~"*) return 1 ;;
65
+ esac
66
+ navori_first_token "$rest"
67
+ }
68
+
69
+ # Absolute, symlink-resolved path of a working tree's SHARED git dir, or nothing
70
+ # when $1 is not inside a git working tree. `--git-common-dir` names the `.git`
71
+ # of the MAIN checkout, so every linked worktree of one repository — and every
72
+ # subdirectory of it — reports the SAME value, while a different repository
73
+ # reports its own and a submodule reports `<main>/.git/modules/<name>`. That
74
+ # makes it an identity test for "same repository", which `--show-toplevel` (one
75
+ # per working tree) and `--git-dir` (one per worktree) are not.
76
+ navori_repo_id() {
77
+ local dir="$1" common=""
78
+ common=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null) || return 1
79
+ [ -n "$common" ] || return 1
80
+ # `git -C <dir>` chdirs to <dir> first, so a relative answer (`.git`,
81
+ # `../../.git`) is relative to <dir>. `pwd -P` canonicalises both sides the
82
+ # same way, which also settles macOS's /var vs /private/var symlink.
83
+ (cd "$dir" && cd "$common" && pwd -P) 2>/dev/null
84
+ }
85
+
86
+ # First argument that resolves to a git working tree; prints its root.
87
+ navori_first_tree() {
88
+ local candidate toplevel
89
+ for candidate in "$@"; do
90
+ [ -n "$candidate" ] || continue
91
+ [ -d "$candidate" ] || continue
92
+ toplevel=$(git -C "$candidate" rev-parse --show-toplevel 2>/dev/null) || continue
93
+ [ -n "$toplevel" ] || continue
94
+ printf '%s' "$toplevel"
95
+ return 0
96
+ done
97
+ return 1
98
+ }
99
+
100
+ # Resolution, most specific first:
101
+ # 1. the directory the command names (`git -C` / a leading `cd`), ONLY when it
102
+ # belongs to the same repository as the anchor (see the constraint below);
103
+ # 2. the payload's `.cwd` — Claude Code sends the CURRENT working directory of
104
+ # the tool call, a documented field distinct from $CLAUDE_PROJECT_DIR (the
105
+ # project root). In an agent worktree these differ; that gap IS #454;
106
+ # 3. the hook process's own cwd — the pre-#454 behaviour, still correct for a
107
+ # plain terminal or git-hook invocation.
108
+ navori_worktree() {
109
+ local payload_cwd anchor named anchor_repo="" named_repo=""
110
+ payload_cwd=$(payload_field cwd)
111
+ # A relative `cd sub` resolves against the shell's cwd, which is the payload's
112
+ # when Claude Code sends one and the hook process's otherwise.
113
+ anchor="$payload_cwd"
114
+ [ -d "$anchor" ] || anchor="$PWD"
115
+ named=$(navori_cmd_dir "$cmd" || true)
116
+ case "$named" in
117
+ "") ;;
118
+ /*) ;;
119
+ *) named="$anchor/$named" ;;
120
+ esac
121
+ # SAME-REPOSITORY CONSTRAINT — DO NOT REMOVE (#454, review finding).
122
+ # Candidate 1 is the only one the COMMAND controls, and it is tried first, so
123
+ # accepting any git tree it happens to name lets that command OVERRIDE the
124
+ # trustworthy candidates below it: `git -C <other-repo> log && git commit`,
125
+ # `cd <other> && cd <wt> && git commit`, `cd <sub> && cd .. && git commit`, or
126
+ # merely the bytes `git -C <path>` inside a commit MESSAGE, all aimed the scan
127
+ # at a foreign tree with nothing to scan — exit 0 with the scanner never run,
128
+ # which is the very failure #454 is about (and, for a commit in the main repo,
129
+ # strictly worse than not resolving worktrees at all).
130
+ # So a directory the command names is accepted only when it is part of the
131
+ # repository the hook is protecting, i.e. the anchor's. Linked worktrees share
132
+ # the main checkout's git dir, so every legitimate agent worktree passes; a
133
+ # different repository and a submodule (its own git dir under
134
+ # `<main>/.git/modules/`) do not, and fall through to the payload cwd — the
135
+ # stricter direction. An unresolvable anchor drops candidate 1 for the same
136
+ # reason: nothing to check it against.
137
+ # Ceiling: two linked worktrees of the SAME repository are indistinguishable
138
+ # this way, so naming a sibling worktree still beats the payload cwd. Both are
139
+ # trees of the repo being protected, so the scan stays inside it. Walk the
140
+ # segments to the one that actually carries the `git commit` if a real command
141
+ # shows up where that is not enough.
142
+ if [ -n "$named" ]; then
143
+ anchor_repo=$(navori_repo_id "$anchor" || true)
144
+ named_repo=$(navori_repo_id "$named" || true)
145
+ if [ -z "$anchor_repo" ] || [ "$named_repo" != "$anchor_repo" ]; then
146
+ named=""
147
+ fi
148
+ fi
149
+ navori_first_tree "$named" "$payload_cwd" "$PWD" || true
150
+ }
@@ -8,7 +8,9 @@
8
8
  # devices. Exit 2 in a PreToolUse hook is evaluated BEFORE permission rules, so
9
9
  # this is the strongest line of defense — it overrides even an `allow` match.
10
10
  #
11
- # KNOWN, ACCEPTED LIMITATION: matching is regex-based over the command string.
11
+ # KNOWN, ACCEPTED LIMITATION: matching is regex-based over the command string
12
+ # over the part of it the shell will actually INTERPRET, since #462 (see "INERT
13
+ # CONTENT" below: a heredoc body or a `-m`/`--body` value is data, not a call).
12
14
  # A compound command is split into one segment per line first, so each rule only
13
15
  # ever matches WITHIN a segment (see "SEGMENTS" below) — that is what keeps a
14
16
  # `git commit` in one segment from being paired with a flag in another.
@@ -42,6 +44,221 @@ block() {
42
44
  exit 2
43
45
  }
44
46
 
47
+ # Pre-expanded newline, shared by the inert-content pass and the segment split.
48
+ # zsh does NOT expand $'\n' in the REPLACEMENT of ${var//pat/repl} (it inserts
49
+ # the literal characters), so an inline $'\n' left compound commands unsplit
50
+ # there and the rules below fail-open (#391). A plain variable expands
51
+ # identically in bash and zsh.
52
+ _nl=$'\n'
53
+
54
+ # INERT CONTENT (#462) — what the shell EXECUTES vs what it merely WRITES.
55
+ #
56
+ # Every rule below reads the command as TEXT, so a PR body or a commit message
57
+ # that QUOTES a destructive command was blocked even though nothing ran. That
58
+ # fires on every security change, because documenting a fix means quoting the
59
+ # attack it defends against: #403 could not write its own PR body, and a commit
60
+ # message naming a recursive delete of HOME needed the `git commit -F <file>`
61
+ # detour.
62
+ #
63
+ # `live` is `$cmd` minus its INERT spans — the text the shell hands to a program
64
+ # as DATA and never interprets as a command. `$cmd` stays intact for the
65
+ # messages; the rules read `live` (directly, or through `scan`).
66
+ #
67
+ # INERT (dropped before matching):
68
+ # · a heredoc BODY — `cat > body.md <<'EOF' … EOF` is file content.
69
+ # · the quoted VALUE of a message/body flag (`-m`, `--body`, …) — it lands in
70
+ # a commit object or in a PR, the shell never runs it.
71
+ #
72
+ # NOT inert. This is the line the decision draws — stated as the FRONTIER it
73
+ # actually is, denylist included, because an unstated denylist reads as coverage:
74
+ # · a heredoc whose opener names a SCRIPT path (`cat > run.sh <<'EOF'`) — that
75
+ # is step 1 of write-then-execute, not documentation. The extension list is
76
+ # matched case-INSENSITIVELY; an unlisted or absent extension (`Makefile`,
77
+ # `.mk`, `.lua`, `.awk`, `cat > /tmp/payload`) reads as documentation, and
78
+ # only the execution tell below can still catch it.
79
+ # · ANY command that also EXECUTES something. Precisely: a KNOWN interpreter
80
+ # (`_hd_run`) in COMMAND POSITION — optionally behind `VAR=v` and the simple
81
+ # prefixes of `_hd_pre` WITH their arguments (`sudo -u me`, `env FOO=1`,
82
+ # `timeout 5`), and behind the wrappers the tell normalizes away (`(…)`,
83
+ # `{ …; }`, `\bash`, `"bash"`, `command bash`) —, or a path in command
84
+ # position (`./x`, `/x`, `~/x`), or a `$(…)`/backtick substitution the shell
85
+ # would expand (`git commit -m "$(rm -rf ~)"` really does run it). Such a
86
+ # command is scanned WHOLE, exactly as before #462.
87
+ # ANY OTHER RUNNER ESCAPES, and that is the accepted trade, not an oversight:
88
+ # `npx tsx f`, `lua f`, `make -f f`, `find … -exec bash {} \;`, a runner
89
+ # reached through a shell function or an alias, and one named by a BRACED
90
+ # variable (`${SH} f` — the wrapper normalization collapses `{` to a space,
91
+ # so it reaches the tell as `$ SH`; the plain `$SHELL f` IS caught). A word
92
+ # split by escapes (`b\ash f`) escapes too, as it already does the rules in
93
+ # the pre-#462 guard. A denylist is never complete;
94
+ # widening it is cheap per entry, but each entry ALSO drags a legitimate
95
+ # `… && make build` sitting next to a prose heredoc back into a full scan —
96
+ # back into the exact false positive #462 exists to kill. Same class as the
97
+ # `sh -c` / `eval` limitation the header states: a seatbelt, not a sandbox.
98
+ # · a `<<` that merely sits inside a string (`echo "a << b"`) — it opens no
99
+ # heredoc, so it can never be used to swallow the next real command. A line
100
+ # whose quotes do not pair up (`echo "a \" << EOF"`) counts as a NON-opener
101
+ # for the same reason: inventing a heredoc there would elide the real
102
+ # commands that follow, the one way this pass could scan LESS than before.
103
+ # Unchanged scope: a deliberate adversary (`sh -c`, `eval`, base64) already had
104
+ # simpler doors and is still out of reach. This is a seatbelt, not a sandbox.
105
+ _hd_q='@navoriQ@' # marks a QUOTED heredoc delimiter (`<<'EOF'`: no expansion)
106
+ skeleton=""
107
+ _hd_delim=""
108
+ _hd_inert=0
109
+ _hd_expand=0
110
+ while IFS= read -r _line; do
111
+ if [ -n "$_hd_delim" ]; then
112
+ # Inside a heredoc body. The terminator is compared whitespace-trimmed (a
113
+ # `<<-` terminator may be indented); trimming can only end the body EARLIER
114
+ # than the shell would, i.e. scan more, never less.
115
+ _t="${_line#"${_line%%[![:space:]]*}"}"
116
+ _t="${_t%"${_t##*[![:space:]]}"}"
117
+ if [ "$_t" = "$_hd_delim" ]; then
118
+ _hd_delim=""
119
+ continue
120
+ fi
121
+ if [ "$_hd_inert" = 0 ]; then
122
+ skeleton="${skeleton}${_line}${_nl}"
123
+ continue
124
+ fi
125
+ # An UNQUOTED delimiter (`<<EOF`) still runs `$(…)`/backticks inside the
126
+ # body — plain prose there is inert, a substitution is a real invocation.
127
+ # Keeping exactly those lines feeds the whole-command fallback below.
128
+ if [ "$_hd_expand" = 1 ]; then
129
+ case "$_line" in
130
+ *'$('* | *'`'*) skeleton="${skeleton}${_line}${_nl}" ;;
131
+ esac
132
+ fi
133
+ continue
134
+ fi
135
+ skeleton="${skeleton}${_line}${_nl}"
136
+ # Fast path: no `<<`, no heredoc — a PreToolUse hook runs on EVERY Bash call,
137
+ # so the sed probes below are worth paying only on the lines that can open one.
138
+ case "$_line" in
139
+ *'<<'*) ;;
140
+ *) continue ;;
141
+ esac
142
+ # Heredoc opener? The probe marks the DELIMITER's own quotes first, then drops
143
+ # every other quoted span, so `cat <<'EOF'` is told apart from a `<<` inside a
144
+ # string; `<<<` (here-string) is neutralized so it can't pose as `<<`.
145
+ _probe=$(printf '%s' "$_line" | sed -E \
146
+ -e "s/<<(-?)[[:space:]]*'([A-Za-z_][A-Za-z0-9_]*)'/<<\1${_hd_q}\2/g" \
147
+ -e "s/<<(-?)[[:space:]]*\"([A-Za-z_][A-Za-z0-9_]*)\"/<<\1${_hd_q}\2/g" \
148
+ -e "s/<<(-?)[[:space:]]*\\\\([A-Za-z_][A-Za-z0-9_]*)/<<\1${_hd_q}\2/g" \
149
+ -e "s/'[^']*'//g" -e "s/\"[^\"]*\"//g" -e "s/<<</ /g")
150
+ # A quote left over in the probe means those two `sed` passes paired the quotes
151
+ # somewhere the shell would not (an escaped `\"`, an odd count): the `<<` may
152
+ # well sit inside a string, and INVENTING a heredoc here is the one failure
153
+ # mode of this whole pass that scans LESS — it would elide every following line,
154
+ # real commands included (`echo "a \" << EOF"` + a real `rm -rf ~/`). Treat the
155
+ # line as a NON-opener; worst case the body is scanned, which is what the guard
156
+ # did before #462.
157
+ case "$_probe" in
158
+ *'"'* | *"'"*) continue ;;
159
+ esac
160
+ # `^[^<]*` anchors on the FIRST `<` of the line: no match (a `<` redirect
161
+ # before the heredoc) simply leaves the body under the rules.
162
+ _open=$(printf '%s' "$_probe" \
163
+ | sed -nE "s/^[^<]*(<<-?[[:space:]]*(${_hd_q})?[A-Za-z_][A-Za-z0-9_]*).*/\1/p")
164
+ if [ -z "$_open" ]; then continue; fi
165
+ _hd_delim=$(printf '%s' "$_open" | sed -E "s/^<<-?[[:space:]]*(${_hd_q})?//")
166
+ _hd_inert=1
167
+ _hd_expand=0
168
+ case "$_open" in
169
+ *"${_hd_q}"*) ;;
170
+ *) _hd_expand=1 ;;
171
+ esac
172
+ # Writing a SCRIPT is not documenting: keep `cat > run.sh <<'EOF'` visible.
173
+ # Case-INSENSITIVE (`-i`): macOS ships a case-insensitive filesystem, so
174
+ # `cat > /tmp/p.SH` writes and runs the same file as `p.sh`.
175
+ if printf '%s' "$_line" \
176
+ | grep -qiE "\.(sh|bash|zsh|ksh|fish|py|rb|pl|js|jsx|mjs|cjs|ts|tsx|command|bat|ps1|scpt)([[:space:]]|[\"'\`]|\$)"; then
177
+ _hd_inert=0
178
+ fi
179
+ done <<< "$cmd"
180
+
181
+ # A `$(…)`/backtick substitution outside single quotes RUNS: `git commit -m
182
+ # "$(rm -rf ~)"` is an invocation wearing a message's clothes. This check must
183
+ # read the skeleton BEFORE the flag values are elided below — eliding first
184
+ # swallowed the `$(` and let exactly that command through.
185
+ #
186
+ # TWO probes, OR'd, because neither alone is enough:
187
+ # · single-quoted spans dropped — catches a `$(` sitting outside every quote.
188
+ # · the double-quoted spans ALONE — inside them an apostrophe is LITERAL, so
189
+ # the first probe pairs `it's … it's` as if it were a quoted span and deletes
190
+ # the `$(` between them. That is ordinary punctuation in the very messages
191
+ # #462 exists to allow (`-m "it's fine $(rm -rf / ) it's"`), and it disarmed
192
+ # the only check that catches an invocation disguised as a message.
193
+ # Over-reading here only falls back to scanning the whole command, i.e. to the
194
+ # pre-#462 behaviour: the safe side.
195
+ _hd_whole=0
196
+ if printf '%s' "$skeleton" | sed -E "s/'[^']*'//g" | grep -qE '\$\(|`'; then
197
+ _hd_whole=1
198
+ else
199
+ # Fast path, same idea as the `<<` one above: probe 2 costs two processes on
200
+ # EVERY Bash call, and it can only ever fire when a substitution is present
201
+ # somewhere in the first place.
202
+ case "$skeleton" in
203
+ *'$('* | *'`'*)
204
+ if printf '%s' "$skeleton" | grep -oE '"[^"]*"' | grep -qE '\$\(|`'; then
205
+ _hd_whole=1
206
+ fi
207
+ ;;
208
+ esac
209
+ fi
210
+
211
+ # Message/body flag VALUES. The flag itself stays so rule 1 still sees the shape
212
+ # of the invocation (`git commit -m "" --no-verify` is still a skip-flag), and
213
+ # `--body-file` is untouched: `([[:space:]]+|=)` can't match its `-`.
214
+ _hd_flags='-m|--message|--body|--title|--description|--notes'
215
+ skeleton=$(printf '%s' "$skeleton" | sed -E \
216
+ -e "s/(^|[[:space:]])(${_hd_flags})([[:space:]]+|=)'[^']*'/\1\2 ''/g" \
217
+ -e "s/(^|[[:space:]])(${_hd_flags})([[:space:]]+|=)\"[^\"]*\"/\1\2 \"\"/g")
218
+
219
+ # The elision holds ONLY while nothing in the command can run what was written.
220
+ # A segment that STARTS with an interpreter or with a path is an execution, and
221
+ # reverts to the whole command — which is why `cat > x <<'EOF' … EOF; bash x` is
222
+ # still blocked. Anything else keeps only what the shell will interpret.
223
+ #
224
+ # `_hd_pre` — the runner may sit behind simple prefixes, WITH their own arguments:
225
+ # `sudo -u me bash x`, `env FOO=1 bash x`, `timeout 5 bash x`. Matching only a
226
+ # prefix glued to the interpreter turned every one of those into a free pass.
227
+ _hd_pre='(sudo|doas|env|command|nohup|timeout|time|nice|ionice|stdbuf|setsid)'
228
+ _hd_pre="${_hd_pre}([[:space:]]+(-[^[:space:]]+([[:space:]]+[^-[:space:]][^[:space:]]*)?"
229
+ _hd_pre="${_hd_pre}|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|[0-9][^[:space:]]*))*[[:space:]]+"
230
+ _hd_run='bash|sh|zsh|ksh|dash|ash|fish|tcsh|csh|source|eval|exec|xargs'
231
+ _hd_run="${_hd_run}|python[0-9.]*|node|deno|bun|perl|ruby|osascript"
232
+ _hd_exec='(^|[;&|])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*'
233
+ _hd_exec="${_hd_exec}(${_hd_pre})*"
234
+ # `\$[A-Za-z_]` covers a runner named by a plain VARIABLE (`$SHELL x`). The
235
+ # BRACED form (`${SH} x`) is NOT covered and is one of the declared escapes: the
236
+ # same wrapper normalization that puts `{ bash x; }` back in command position
237
+ # collapses `{` to a space, so `${SH}` reaches the tell as `$ SH`. Catching it
238
+ # would mean teaching that normalization about `$`-prefixed braces — a change to
239
+ # a security-sensitive transform, worth its own unit.
240
+ # Why catching the plain form is safe: a variable in command position is never a
241
+ # legitimate WRITER, so the false-positive cost is ~0 and the declared frontier
242
+ # stays complete — which is what the denylist trade-off rests on (#462).
243
+ _hd_exec="${_hd_exec}((${_hd_run})([[:space:]]|\$)|\.[[:space:]]|[.~]?/|\\\$[A-Za-z_])"
244
+ # The tell reads a NORMALIZED copy, never the raw skeleton: `_hd_exec` anchors on
245
+ # `^`/`;&|`, so any wrapper between the boundary and the interpreter used to hide
246
+ # it. The transforms are FIX C's (below) plus quote REMOVAL, which is what puts
247
+ # `(bash x)`, `{ bash x; }`, `\bash x`, `"bash" x` and `command bash x` back in
248
+ # command position. Unquoting is safe HERE and not below: its only effect is
249
+ # deciding whether to fall back to the whole command, i.e. to the pre-#462
250
+ # behaviour — it never reaches the rules, so a quoted skip-flag is untouched.
251
+ if [ "$_hd_whole" = 1 ] || printf '%s' "$skeleton" | sed -E \
252
+ -e "s/(^|[;&|]|[[:space:]])command[[:space:]]+/\1/g" \
253
+ -e "s/\\\\([A-Za-z])/ \1/g" \
254
+ -e "s/[(){}]/ /g" \
255
+ -e "s/'([^']*)'/\1/g" \
256
+ -e 's/"([^"]*)"/\1/g' | grep -qE "$_hd_exec"; then
257
+ live="$cmd"
258
+ else
259
+ live="$skeleton"
260
+ fi
261
+
45
262
  # Normalized copy used ONLY by the rules below. `$cmd` stays intact for the
46
263
  # messages. The transforms only add boundaries / drop noise, so they always push
47
264
  # toward MORE blocking:
@@ -53,7 +270,7 @@ block() {
53
270
  # `\git …` and `(git …)` read as a plain `git …`.
54
271
  # `scan` keeps quoted spans intact, so a quoted skip-flag (`git commit
55
272
  # "--no-verify"`) is still caught — stripping the quotes there was a real bypass.
56
- scan="$cmd"
273
+ scan="$live"
57
274
  scan="${scan//\\$'\n'/ }" # FIX B: join line continuations
58
275
  scan="${scan//$'\n'/;}" # FIX B: flatten remaining newlines to a boundary
59
276
  scan=$(printf '%s' "$scan" | sed -E \
@@ -77,10 +294,10 @@ scan=$(printf '%s' "$scan" | sed -E \
77
294
  # `(`, `\` and `command ` wrappers were already neutralized by FIX C above.
78
295
  segments=""
79
296
  _split="$scan"
80
- _split="${_split//&&/$'\n'}"
81
- _split="${_split//||/$'\n'}"
82
- _split="${_split//;/$'\n'}"
83
- _split="${_split//|/$'\n'}"
297
+ _split="${_split//&&/$_nl}"
298
+ _split="${_split//||/$_nl}"
299
+ _split="${_split//;/$_nl}"
300
+ _split="${_split//|/$_nl}"
84
301
  while IFS= read -r _seg; do
85
302
  _seg="${_seg#"${_seg%%[![:space:]]*}"}" # strip leading ws
86
303
  while [[ "$_seg" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do # strip VAR=val prefixes
@@ -182,13 +399,14 @@ if printf '%s' "$segments" \
182
399
  block "recursive rm over a variable / root / home"
183
400
  fi
184
401
 
185
- # 4. Fork bomb.
186
- if printf '%s' "$cmd" | grep -qE ':\(\)[[:space:]]*\{[[:space:]]*:\|:'; then
402
+ # 4. Fork bomb. Reads `live`, not `scan`: FIX C turns `(` into a space, which
403
+ # would defuse the very pattern this rule looks for.
404
+ if printf '%s' "$live" | grep -qE ':\(\)[[:space:]]*\{[[:space:]]*:\|:'; then
187
405
  block "fork bomb"
188
406
  fi
189
407
 
190
408
  # 5. Writing to a raw block device (wipes a disk/partition).
191
- if printf '%s' "$cmd" | grep -qE '(of=/dev/(sd|nvme|disk|hd)|>[[:space:]]*/dev/(sd|nvme|disk|hd))'; then
409
+ if printf '%s' "$live" | grep -qE '(of=/dev/(sd|nvme|disk|hd)|>[[:space:]]*/dev/(sd|nvme|disk|hd))'; then
192
410
  block "direct write to a block device"
193
411
  fi
194
412