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.
- package/dist/assets/core/core-assets/agents/auditor.md +1 -1
- package/dist/assets/core/core-assets/agents/commit-pr-pilot.md +13 -1
- package/dist/assets/core/core-assets/agents/explorer.md +1 -1
- package/dist/assets/core/core-assets/agents/implementer.md +1 -1
- package/dist/assets/core/core-assets/agents/leader.md +2 -1
- package/dist/assets/core/core-assets/agents/researcher.md +1 -1
- package/dist/assets/core/core-assets/agents/reviewer.md +1 -1
- package/dist/assets/core/core-assets/agents/ticket-audit.md +6 -2
- package/dist/assets/core/core-assets/hooks/_partials/extract-cmd.sh +20 -8
- package/dist/assets/core/core-assets/hooks/_partials/gate-trigger.sh +10 -5
- package/dist/assets/core/core-assets/hooks/_partials/resolve-worktree.sh +150 -0
- package/dist/assets/core/core-assets/hooks/guard-destructive.sh +227 -9
- package/dist/assets/core/core-assets/hooks/quality-gate-pre-commit.sh +16 -9
- package/dist/assets/core/core-assets/hooks/subagent-stop-handoff.sh +5 -2
- package/dist/assets/core/core-assets/lib-skills/i18next.md +3 -3
- package/dist/assets/core/core-assets/managed/arranque-sesion.md +2 -4
- package/dist/assets/core/core-assets/managed/cierre-sesion.md +5 -3
- package/dist/assets/core/core-assets/managed/intake-tickets.md +1 -1
- package/dist/assets/core/core-assets/managed/orquestacion.md +18 -3
- package/dist/assets/core/core-assets/settings/settings-base.json +10 -1
- package/dist/assets/core/core-assets/skills/loop-back-debug.md +1 -1
- package/dist/assets/core/core-assets/skills/review-diff.md +1 -1
- package/dist/assets/core/core-assets/skills/spec-bootstrap.md +2 -1
- package/dist/assets/core/core-assets/skills/ticket-intake.md +16 -18
- package/dist/assets/core/core-assets/skills/verify-before-done.md +2 -2
- package/dist/assets/core/package.json +0 -2
- package/dist/assets/plugins/engram/managed/engram-protocol.md +4 -3
- package/dist/assets/plugins/engram/skills/engram-leader.md +2 -2
- package/dist/assets/plugins/jscpd/scripts/check-jscpd.sh +31 -12
- package/dist/assets/plugins/semgrep/managed/semgrep-protocol.md +1 -1
- package/dist/assets/plugins/semgrep/scripts/check-semgrep.sh +170 -13
- package/dist/index.js +324 -316
- 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
|
-
|
|
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.
|
|
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. **
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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. **
|
|
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.
|
|
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
|
|
9
|
-
# extracted → empty
|
|
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
|
-
|
|
21
|
+
payload_field() {
|
|
13
22
|
if command -v jq >/dev/null 2>&1; then
|
|
14
|
-
printf '%s' "$payload" | jq -r
|
|
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(
|
|
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
|
|
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
|
-
|
|
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//&&/$
|
|
24
|
-
input="${input//||/$
|
|
25
|
-
input="${input//;/$
|
|
26
|
-
input="${input//|/$
|
|
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="$
|
|
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//&&/$
|
|
81
|
-
_split="${_split//||/$
|
|
82
|
-
_split="${_split//;/$
|
|
83
|
-
_split="${_split//|/$
|
|
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
|
-
|
|
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' "$
|
|
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
|
|