@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.3

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 (170) hide show
  1. package/README.md +13 -5
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -1,62 +1,1804 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PreToolUse guard (Write|Edit): blocks hub-authored writes into guarded
4
- * source and test paths on ANY branch.
3
+ * PreToolUse guard (Write|Edit|Bash): blocks any hub-authored write into a
4
+ * guarded source or test path, on any branch -- only the designated writer
5
+ * subagents (`code-implementer`, `test-author`) may edit that code; every
6
+ * other caller, including the hub itself, gets refused.
5
7
  *
6
8
  * Problem: `guard-branch-isolation.mjs` only fires while `HEAD` is `main`.
7
9
  * On a feature branch nothing else stops the hub itself from writing
8
10
  * directly into a guarded path instead of dispatching the write to a writer
9
- * spoke, as CLAUDE.md's Agent Operating Model requires.
11
+ * spoke, as CLAUDE.md's Agent Operating Model requires -- and a Write/Edit
12
+ * guard alone is bypassed by a Bash command that writes the same file
13
+ * (`cat > src/a.ts <<EOF`, `sed -i`, `cp`, a `python -c` one-liner).
10
14
  *
11
15
  * The seam: the PreToolUse payload carries a top-level `agent_type` field
12
- * when the tool call fires inside a subagent context. The field is absent
13
- * (or empty) for hub-level calls, and contains the subagent's name for
14
- * spoke calls.
16
+ * both when the tool call fires inside a subagent AND in a main session
17
+ * started with `claude --agent <name>`, while `agent_id` is sent ONLY when
18
+ * the call fires inside a subagent. A spoke is therefore identified by both
19
+ * fields together: a call naming a writer in `agent_type` but carrying no
20
+ * non-blank string `agent_id` is the hub itself running as `--agent`, not a
21
+ * dispatched spoke, and is treated as the hub.
15
22
  *
16
23
  * The decision: block when BOTH conditions hold:
17
24
  * (a) the target path is a guarded source/test path, AND
18
- * (b) `agent_type` is NOT the name of an authorised writer spoke
19
- * (`code-implementer` or `test-author`, per WRITER_SPOKES in
25
+ * (b) the call is not from an authorised writer spoke -- i.e. `agent_id`
26
+ * is absent/empty/whitespace-only/non-string, or `agent_type` is NOT the name of a
27
+ * writer (`code-implementer` or `test-author`, per WRITER_SPOKES in
20
28
  * bin/lib/agent-roster.mjs).
21
29
  *
22
- * Hub calls (absent/empty agent_type) and non-writer subagents are treated
23
- * identically -- both are blocked from guarded paths. Writer spokes are
24
- * allowed through. All other paths are allowed through unconditionally.
30
+ * Hub calls (no agent_id, whatever agent_type says) and non-writer subagents
31
+ * are treated identically -- both are blocked from guarded paths. Writer
32
+ * spokes are allowed through. All other paths are allowed through
33
+ * unconditionally.
25
34
  *
26
- * Fail-open: an unparseable payload or missing file_path exits 0 so a
27
- * malformed hook input never wedges the session.
35
+ * Write|Edit: the target is `tool_input.file_path`, checked directly.
36
+ *
37
+ * Bash: the target is whatever `tool_input.command` visibly writes. A small
38
+ * shell lexer below (no dependency, linear in the command's length)
39
+ * understands single/double/ANSI-C quotes, backslash escapes, comments,
40
+ * `$(...)`/backtick substitutions, subshell parens and brace groups
41
+ * (substituted text is analysed recursively, nesting capped at MAX_DEPTH),
42
+ * heredocs (`<<`, `<<-`, quoted or not -- the body is DATA, never parsed as
43
+ * commands, but kept so an interpreter or patch reading it can be scanned),
44
+ * here-strings, the separators `&& || ; | & newline`, and redirections
45
+ * (`>`, `>>`, `>|`, `&>`, `N>`, `>&N`; an fd duplication is never a path).
46
+ * Each simple command then has its leading `VAR=val` assignments and
47
+ * wrappers (`sudo`, `env`, `nohup`, `time`, `command`, `exec`, `nice`,
48
+ * `xargs`, `builtin`) stripped, `cd`/`pushd` tracked against the running
49
+ * cwd, and `bash|sh|zsh -c STR` / `eval STR` re-lexed one level deeper. A
50
+ * relative path is resolved against the running cwd (the payload's `cwd`,
51
+ * so a worktree session resolves into its own worktree), `.`/`..` are
52
+ * normalised lexically, and the result goes through the same
53
+ * `isProtectedPath(abs, projectDir)` the Write|Edit path uses -- an absolute
54
+ * path outside the project is never protected. Targets the lexer cannot
55
+ * resolve statically (containing `$`, a backtick, a leading `~`) are
56
+ * ignored; a glob is judged by its literal directory prefix and by its
57
+ * whole path with every globbed segment a placeholder (`packages/<glob>/src`);
58
+ * anything under `/dev/` (`/dev/null`, `/dev/stdout`, `/dev/fd/N`) is
59
+ * ignored.
60
+ *
61
+ * An ANCESTOR of a guarded directory is a path that contains one beneath
62
+ * it, judged lexically: the project root (a flat layout's own src/tests),
63
+ * a workspace container (`packages`, `apps`, `libs`), or a package
64
+ * directly inside one (`packages/cli`). A container only counts as the
65
+ * operand's own first segment (`docs/packages/old`, `packages/*\/dist` are
66
+ * not ancestors), and `node_modules` is never a package
67
+ * (`*\/node_modules`). A final glob segment is judged by
68
+ * its parent: directly under the project root it counts when it could
69
+ * expand to `src`, `tests` or a container (`rm -rf *`); directly under a
70
+ * container it always counts, as it may name a package (`packages/c*`,
71
+ * `packages/cl?`, `packages/[c]li`); directly under a package it counts
72
+ * when it could expand to `src` or `tests` (`packages/cli/*`), and that slot
73
+ * decides at any depth (`packages/cli/*\/*`, `*\/*\/*\/x`); anywhere else
74
+ * it never does (`dist/*`, `coverage/*`, `packages/cli/dist/*`).
75
+ * `packages/cli/dist`, `node_modules`, `coverage` and a linked worktree's
76
+ * own root (`.claude/worktrees/<name>`) are not ancestors. Known limit: an
77
+ * ancestor of a src/ nested below a non-container first segment
78
+ * (`.claude/worktrees/foo/packages/cli`) is knowingly not covered. A block
79
+ * on an ancestor says so in its message.
80
+ * `[[ ... ]]`, `[ ... ]` and `(( ... ))` are lexed as one unit, so a `<`,
81
+ * `>`, `&&` or `||` inside a test expression is never a redirect or a
82
+ * command boundary; a `$(...)`/backtick substitution inside `[[ ]]`/`[ ]`
83
+ * is still analysed. An unquoted `;`, newline, lone `&` or lone `|` before
84
+ * the closer means the text is not one test unit and is lexed normally.
85
+ *
86
+ * Bash write patterns detected (the reported rule is the tool's name, or
87
+ * `redirect`):
88
+ * - an output redirect (`>`, `>>`, `>|`, `&>`, `N>`, `<>`, `>& file`)
89
+ * - `tee` (every operand)
90
+ * - `sed -i` / `--in-place` / a short-flag cluster containing `i`
91
+ * - `perl -i` / `-pi` (every file operand)
92
+ * - `cp`, `install`, `rsync`, `ln` -- the destination only (last operand,
93
+ * or `-t`/`--target-directory`; `install -d` creates every operand);
94
+ * sources never count. An ancestor destination counts too, unless it
95
+ * is a directory known to exist (the project root, a trailing `/`, a
96
+ * `-t` target, several sources) and every source lands as a named
97
+ * `dest/<basename>` that is neither guarded nor an ancestor -- a
98
+ * source copying a directory's contents (`dir/`, `dir/.`), a glob or an
99
+ * unresolvable name always counts
100
+ * - `rsync --remove-source-files` -- additionally every source, guarded
101
+ * or an ancestor, since rsync deletes what it copied
102
+ * - `mv` -- any operand, source or destination; an ancestor source, and
103
+ * an ancestor destination by the same rule as `cp`
104
+ * - `rm`, `unlink`, `rmdir`, `touch`, `truncate` -- any operand; for `rm`
105
+ * an ancestor operand too
106
+ * - `dd of=PATH`
107
+ * - `patch` (never with `--dry-run`, `--check` or `-C`) / `git apply`
108
+ * (never with `--check`, `--stat`, `--numstat` or `--summary`); blocked
109
+ * when an operand, `-d`/`--directory` or `patch -o` is a guarded path,
110
+ * or when the patch text (a heredoc, here-string or `<` stdin, or a
111
+ * named patch file read from disk) has a `diff --git`/`--- `/`+++ `
112
+ * header naming one
113
+ * - an interpreter (`python*`, `node`, `nodejs`, `deno`, `bun`, `ruby`,
114
+ * `perl`, `php`) whose inline code (`-c`/`-e`/`-p`/`-r`/`eval`), stdin
115
+ * (`-`, heredoc, here-string) or script FILE located OUTSIDE the project
116
+ * calls a write verb with a string literal resolving to a guarded path
117
+ * in the WRITTEN position: the first argument of `writeFile(Sync)`,
118
+ * `appendFile(Sync)`, `createWriteStream`, `file_put_contents`,
119
+ * `File.write`/`IO.write`/`Bun.write`, `Deno.writeTextFile(Sync)`,
120
+ * `truncate(Sync)`, and the delete verbs `Deno.remove(Sync)`, `rm(Sync)`, `rmdir(Sync)`, `unlink(Sync)`, `os.remove`,
121
+ * `os.removedirs`, `shutil.rmtree`, `File.delete`, `FileUtils.rm*`; the
122
+ * destination (second) argument of `copyFile(Sync)`, `cp`/`cpSync`,
123
+ * `copy`, `shutil.copy*`; either argument of `rename(Sync)`/
124
+ * `os.rename`/`os.replace`/`shutil.move`; the path of a write-mode
125
+ * `open`/`openSync`/`fopen` (`'w'`, `'a'`, `'x'`, `'c'`, `'r+'`..., as
126
+ * the second argument or a `mode=` keyword anywhere; or perl's
127
+ * `open(F, ">path")`, `open(F, '>', 'path')` and paren-less
128
+ * `open F, ">path"`); and the receiver of `Path('...').write_text`/
129
+ * `write_bytes`/`touch`/`unlink`/`rmdir`/`rename`/`replace`. For the
130
+ * tree-removing and moving verbs (`Deno.remove(Sync)`, `rm(Sync)`,
131
+ * `rename(Sync)`,
132
+ * `os.rename`/`os.replace`, `shutil.move`/`rmtree`, `FileUtils.rm*`,
133
+ * `Path.rename`/`replace`) an ancestor literal counts too. A guarded
134
+ * path that is only read, or a write verb aimed elsewhere, does not
135
+ * count. A script inside the project is trusted and never read.
136
+ * Every other `git` subcommand, every read, test runner, linter and
137
+ * formatter is allowed.
138
+ *
139
+ * Documented FALSE NEGATIVES -- this is a static screen over command text,
140
+ * not a sandbox, and these writes pass it:
141
+ * - an interpreter running a script that lives inside the project, or any
142
+ * other indirection (a script that writes a second script, a pipe into
143
+ * an interpreter's stdin, `bash script.sh`)
144
+ * - `eval` of a computed string, and any target or code assembled at run
145
+ * time: variable-, glob-from-variable-, `~`- or command-substitution-
146
+ * expanded paths, `python -c` building the path from pieces or holding
147
+ * it in a variable, an interpreter write verb outside the list above
148
+ * (perl's `File::Path` `rmtree`/`remove_tree` among them), and perl's
149
+ * paren-less 3-arg `open F, '>', 'path'`
150
+ * - an interpreter write call whose argument list spans more than
151
+ * MAX_CALL_CHARS (1000) characters: it is not split, so not screened
152
+ * (reported as a note)
153
+ * - build steps, generators and formatters (`pnpm <script>`, `make`,
154
+ * codegen, `prettier --write`, `eslint --fix`) that write into src/tests
155
+ * - `mkdir`, `find -delete`, and `find -exec` -- always, whatever its
156
+ * operands, since `find`'s command line is never analysed; `xargs` is
157
+ * a false negative only without literal operands (`xargs rm <guarded>`
158
+ * is caught, the wrapper being stripped)
159
+ * - an ancestor the lexical rule above does not recognise: a workspace
160
+ * container under another name, a nested one (`packages/group/pkg`),
161
+ * a parent of the project root (`rm -rf ..`), or the root of a linked
162
+ * worktree (`.claude/worktrees/<name>`, so `rsync -a /tmp/x/ .` from
163
+ * inside one passes); and a mid-path glob is read only as possibly
164
+ * naming a workspace container, never `src`/`tests` (`<glob>/a.ts`)
165
+ * - `tar`/`unzip`/`curl -o`/`wget -O`, `git checkout`/`restore`/`stash`
166
+ * (`stash pop`)/`reset`/`rm`/`mv`/`clean` (`git clean -fdx`), and editors (`vim -c ...`)
167
+ * - a redirect attached to a test expression (`[[ -f a ]] > file`): the
168
+ * whole `[[`/`[` command is skipped for redirects
169
+ * - `$(...)`/backtick substitutions inside an UNQUOTED heredoc body: the
170
+ * shell runs them, but the body is treated as data
171
+ * - a session cwd (or worktree) outside CLAUDE_PROJECT_DIR: every path
172
+ * resolves outside the project and so is never guarded
173
+ * - anything nested deeper than MAX_DEPTH, a path spelled through a
174
+ * symlinked alias of the project root, and a script or patch file larger
175
+ * than MAX_READ_BYTES (the depth and both size cases are reported as
176
+ * notes)
177
+ * - a tool run through a channel that bypasses PreToolUse hooks entirely
178
+ * Hub-and-spoke is therefore a convention backed by a guard that raises the
179
+ * bar, not a proof.
180
+ *
181
+ * Maintainer override: run the command yourself with the `!` prefix at the
182
+ * Claude Code prompt (that is not a tool call, so no hook runs), or edit or
183
+ * remove this hook's registration in .claude/settings.json.
184
+ *
185
+ * Visibility: when the detector gives up on part of a command -- the
186
+ * nesting cap was hit (`nesting`), a script/patch exceeded MAX_READ_BYTES
187
+ * (`size`), an interpreter write call's argument list exceeded
188
+ * MAX_CALL_CHARS (`call size`), or a file exists but could not be read
189
+ * (`read`; a file that simply does not exist is not noted) -- it still
190
+ * allows, but records a note (a size cap is note-only: it never blocks), and the entry point prints one `allowed, but not fully analysed`
191
+ * line to stderr. A fully analysed allowed command prints nothing.
192
+ *
193
+ * Fail-open: an unparseable payload, a Bash payload with no string command,
194
+ * or an exception inside the Bash analysis exits 0 with a stderr line
195
+ * saying so, so a malformed hook input never wedges the session. A
196
+ * Write/Edit payload with no file_path has nothing to check and exits 0.
28
197
  */
29
198
  import process from "node:process";
30
- import { realpathSync } from "node:fs";
199
+ import { readFileSync, realpathSync, statSync } from "node:fs";
200
+ import { posix } from "node:path";
31
201
  import { fileURLToPath } from "node:url";
32
- import { isProtectedPath } from "../../bin/lib/protected-paths.mjs";
202
+ import {
203
+ canonicalize,
204
+ isAbsoluteLike,
205
+ isProtectedPath,
206
+ } from "../../bin/lib/protected-paths.mjs";
33
207
  import { WRITER_SPOKES } from "../../bin/lib/agent-roster.mjs";
34
208
 
209
+ function isWriterSpoke(agentType) {
210
+ return (
211
+ typeof agentType === "string" &&
212
+ agentType.length > 0 &&
213
+ WRITER_SPOKES.has(agentType)
214
+ );
215
+ }
216
+
35
217
  /**
36
218
  * Pure decision function -- exported for unit testing.
219
+ * Callers must pass `spokeAgentType(input)`, not the raw payload `agent_type`,
220
+ * or a `claude --agent` main session passes as a writer spoke.
37
221
  *
38
222
  * @param {string | undefined} filePath The file_path from the tool_input payload.
39
223
  * @param {unknown} agentType The top-level agent_type from the payload.
224
+ * @param {string} [projectDir] Scopes an absolute filePath to the project -- see isProtectedPath.
40
225
  * @returns {boolean} true = block, false = allow.
41
226
  */
42
- export function shouldBlockHubSrcWrite(filePath, agentType) {
227
+ export function shouldBlockHubSrcWrite(filePath, agentType, projectDir) {
43
228
  if (!filePath || typeof filePath !== "string") return false;
44
- if (!isProtectedPath(filePath)) return false;
229
+ if (!isProtectedPath(filePath, projectDir)) return false;
230
+ return !isWriterSpoke(agentType);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Bash detector -- lexer
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /** Nesting cap for `$(...)`, backticks, `bash -c` and `eval`. */
238
+ const MAX_DEPTH = 3;
239
+ /** Largest script or patch file the detector will read from disk. */
240
+ const MAX_READ_BYTES = 1_000_000;
241
+
242
+ const WORD_BREAK = new Set([
243
+ " ",
244
+ "\t",
245
+ "\n",
246
+ ";",
247
+ "&",
248
+ "|",
249
+ "<",
250
+ ">",
251
+ "(",
252
+ ")",
253
+ ]);
254
+ // Longest first, so `<<-` wins over `<<` and `>>` over `>`.
255
+ const REDIRECT_OPS = [
256
+ "<<<",
257
+ "<<-",
258
+ "<<",
259
+ "<>",
260
+ "<&",
261
+ "<",
262
+ ">>",
263
+ ">|",
264
+ ">&",
265
+ ">",
266
+ ];
267
+ const OUTPUT_OPS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
268
+ const HEREDOC_OPS = new Set(["<<", "<<-"]);
269
+ const DOUBLE_QUOTE_ESCAPABLE = new Set(["$", "`", '"', "\\", "\n"]);
270
+ const PARAMETER_START = /[\w@*#?$!-]/;
271
+ // Test-expression openers and the word that closes each.
272
+ const TEST_CLOSERS = new Map([
273
+ ["[[", "]]"],
274
+ ["[", "]"],
275
+ ]);
276
+ // Inside a test expression these are comparison/grouping operators, inert.
277
+ const TEST_INERT = new Set(["<", ">", "&", "|", "(", ")"]);
278
+ const NESTING_NOTE = `nesting depth cap (${MAX_DEPTH}) reached; the innermost command was not analysed`;
279
+
280
+ /** Records why the detector gave up on part of a command, when the caller asked. */
281
+ function addNote(notes, note) {
282
+ if (notes) notes.add(note);
283
+ }
284
+
285
+ /**
286
+ * Skips to just past the `close` that balances `depth` already-open
287
+ * `open`s, honouring quotes and backslashes. Used where the text is not
288
+ * worth lexing (arithmetic, `${...}`, anything past MAX_DEPTH).
289
+ */
290
+ function skipBalanced(src, start, depth, open, close) {
291
+ let level = depth;
292
+ let i = start;
293
+ while (i < src.length) {
294
+ const c = src[i];
295
+ if (c === "\\") {
296
+ i += 2;
297
+ } else if (c === "'") {
298
+ const end = src.indexOf("'", i + 1);
299
+ i = end === -1 ? src.length : end + 1;
300
+ } else if (c === '"') {
301
+ i = skipDoubleQuoted(src, i + 1);
302
+ } else {
303
+ if (c === open) level++;
304
+ else if (c === close && --level === 0) return i + 1;
305
+ i++;
306
+ }
307
+ }
308
+ return src.length;
309
+ }
310
+
311
+ function skipDoubleQuoted(src, start) {
312
+ let i = start;
313
+ while (i < src.length) {
314
+ if (src[i] === "\\") i += 2;
315
+ else if (src[i] === '"') return i + 1;
316
+ else i++;
317
+ }
318
+ return src.length;
319
+ }
320
+
321
+ /** Reads a backtick substitution body starting just past the opening backtick. */
322
+ function readBacktick(src, start) {
323
+ let inner = "";
324
+ let i = start;
325
+ while (i < src.length && src[i] !== "`") {
326
+ if (src[i] === "\\" && i + 1 < src.length) {
327
+ const next = src[i + 1];
328
+ inner +=
329
+ next === "`" || next === "\\" || next === "$" ? next : `\\${next}`;
330
+ i += 2;
331
+ } else {
332
+ inner += src[i];
333
+ i++;
334
+ }
335
+ }
336
+ return { inner, end: Math.min(i + 1, src.length) };
337
+ }
338
+
339
+ function newWord() {
340
+ // value: the word with quotes removed but expansions kept verbatim;
341
+ // dynamic: contains an expansion, so its runtime value is unknown;
342
+ // globAt: index in value of the first unquoted glob character, or -1.
343
+ return { value: "", dynamic: false, tilde: false, globAt: -1, subs: [] };
344
+ }
345
+
346
+ /**
347
+ * Lexes `src` from `start` into word / operator / redirect tokens. With
348
+ * `nested` set it stops at the `)` closing a `$(` and reports where.
349
+ * `notes` (optional Set) collects a note when a substitution body is
350
+ * skipped at the nesting cap.
351
+ */
352
+ function lex(src, start, depth, nested, notes) {
353
+ const n = src.length;
354
+ const tokens = [];
355
+ const heredocs = [];
356
+ // Per closer, the span [from, until] a failed search already covered:
357
+ // any later search starting inside it stops at the same separator, so a
358
+ // run of unclosed `[[` words cannot make the search quadratic.
359
+ const closerMiss = new Map();
360
+ let pendingRedirect = null;
361
+ let parens = 0;
362
+ let i = start;
363
+
364
+ /**
365
+ * Index of the standalone `closer` word ending a test expression that
366
+ * starts at `i`, or -1. Quote- and substitution-aware; `&&`/`||` may
367
+ * appear inside, but an unquoted `;`, newline, lone `&` or lone `|`
368
+ * ends the search -- that text is not one test unit.
369
+ */
370
+ const findTestCloser = (closer) => {
371
+ const miss = closerMiss.get(closer);
372
+ if (miss !== undefined && i >= miss.from && i <= miss.until) return -1;
373
+ let j = i;
374
+ while (j < n) {
375
+ const c = src[j];
376
+ if (c === "\\") {
377
+ j += 2;
378
+ } else if (c === "'") {
379
+ const end = src.indexOf("'", j + 1);
380
+ j = end === -1 ? n : end + 1;
381
+ } else if (c === '"') {
382
+ j = skipDoubleQuoted(src, j + 1);
383
+ } else if (c === "`") {
384
+ j = readBacktick(src, j + 1).end;
385
+ } else if (c === "$" && src[j + 1] === "(") {
386
+ j = skipBalanced(src, j + 2, 1, "(", ")");
387
+ } else if (c === "\n" || c === ";") {
388
+ break;
389
+ } else if (c === "&" || c === "|") {
390
+ if (src[j + 1] !== c) break;
391
+ j += 2;
392
+ } else if (
393
+ src.startsWith(closer, j) &&
394
+ (src[j - 1] === " " || src[j - 1] === "\t") &&
395
+ (j + closer.length >= n || WORD_BREAK.has(src[j + closer.length]))
396
+ ) {
397
+ return j;
398
+ } else {
399
+ j++;
400
+ }
401
+ }
402
+ closerMiss.set(closer, { from: i, until: j });
403
+ return -1;
404
+ };
405
+
406
+ const atCommandStart = () => {
407
+ const last = tokens[tokens.length - 1];
408
+ return (
409
+ last === undefined ||
410
+ last.kind === "op" ||
411
+ (last.kind === "word" && RESERVED_PREFIXES.has(last.word.value))
412
+ );
413
+ };
414
+
415
+ const emitOp = (value) => {
416
+ pendingRedirect = null;
417
+ tokens.push({ kind: "op", value });
418
+ };
419
+ const emitRedirect = (op, fd) => {
420
+ const redirect = { kind: "redirect", op, fd, target: null, body: null };
421
+ tokens.push(redirect);
422
+ pendingRedirect = redirect;
423
+ };
424
+
425
+ const readHeredocBodies = () => {
426
+ for (const redirect of heredocs) {
427
+ const delimiter = redirect.target.value;
428
+ const stripTabs = redirect.op === "<<-";
429
+ const bodyStart = i;
430
+ let body = null;
431
+ while (i < n && body === null) {
432
+ const newline = src.indexOf("\n", i);
433
+ const lineStart = i;
434
+ let line = src.slice(i, newline === -1 ? n : newline);
435
+ if (stripTabs) line = line.replace(/^\t+/, "");
436
+ i = newline === -1 ? n : newline + 1;
437
+ if (line === delimiter) body = src.slice(bodyStart, lineStart);
438
+ }
439
+ redirect.body = body ?? src.slice(bodyStart);
440
+ }
441
+ heredocs.length = 0;
442
+ };
443
+
444
+ const expandDollar = (word) => {
445
+ const next = src[i + 1];
446
+ if (next === "(") {
447
+ let end;
448
+ if (src[i + 2] === "(") {
449
+ end = skipBalanced(src, i + 3, 2, "(", ")");
450
+ } else if (depth < MAX_DEPTH) {
451
+ const inner = lex(src, i + 2, depth + 1, true, notes);
452
+ word.subs.push(inner.tokens);
453
+ end = inner.end;
454
+ } else {
455
+ addNote(notes, NESTING_NOTE);
456
+ end = skipBalanced(src, i + 2, 1, "(", ")");
457
+ }
458
+ word.value += src.slice(i, end);
459
+ word.dynamic = true;
460
+ i = end;
461
+ } else if (next === "{") {
462
+ const end = skipBalanced(src, i + 2, 1, "{", "}");
463
+ word.value += src.slice(i, end);
464
+ word.dynamic = true;
465
+ i = end;
466
+ } else {
467
+ if (next !== undefined && PARAMETER_START.test(next)) {
468
+ word.dynamic = true;
469
+ }
470
+ word.value += "$";
471
+ i++;
472
+ }
473
+ };
474
+
475
+ const expandBacktick = (word) => {
476
+ const { inner, end } = readBacktick(src, i + 1);
477
+ if (depth < MAX_DEPTH) {
478
+ word.subs.push(lex(inner, 0, depth + 1, false, notes).tokens);
479
+ } else {
480
+ addNote(notes, NESTING_NOTE);
481
+ }
482
+ word.value += src.slice(i, end);
483
+ word.dynamic = true;
484
+ i = end;
485
+ };
486
+
487
+ const readDoubleQuoted = (word) => {
488
+ i++;
489
+ while (i < n && src[i] !== '"') {
490
+ const c = src[i];
491
+ if (c === "\\" && i + 1 < n && DOUBLE_QUOTE_ESCAPABLE.has(src[i + 1])) {
492
+ if (src[i + 1] !== "\n") word.value += src[i + 1];
493
+ i += 2;
494
+ } else if (c === "$") {
495
+ expandDollar(word);
496
+ } else if (c === "`") {
497
+ expandBacktick(word);
498
+ } else {
499
+ word.value += c;
500
+ i++;
501
+ }
502
+ }
503
+ i++;
504
+ };
505
+
506
+ const readWord = () => {
507
+ const word = newWord();
508
+ const wordStart = i;
509
+ while (i < n && !WORD_BREAK.has(src[i])) {
510
+ const c = src[i];
511
+ if (c === "\\") {
512
+ if (i + 1 < n && src[i + 1] !== "\n") word.value += src[i + 1];
513
+ i += 2;
514
+ } else if (c === "'") {
515
+ const end = src.indexOf("'", i + 1);
516
+ word.value += src.slice(i + 1, end === -1 ? n : end);
517
+ i = end === -1 ? n : end + 1;
518
+ } else if (c === "$" && src[i + 1] === "'") {
519
+ let j = i + 2;
520
+ while (j < n && src[j] !== "'") j += src[j] === "\\" ? 2 : 1;
521
+ word.value += src.slice(i + 2, Math.min(j, n));
522
+ i = j + 1;
523
+ } else if (c === '"') {
524
+ readDoubleQuoted(word);
525
+ } else if (c === "$") {
526
+ expandDollar(word);
527
+ } else if (c === "`") {
528
+ expandBacktick(word);
529
+ } else {
530
+ if (
531
+ word.globAt === -1 &&
532
+ (c === "*" || c === "?" || c === "[" || c === "{")
533
+ ) {
534
+ word.globAt = word.value.length;
535
+ }
536
+ if (c === "~" && i === wordStart) word.tilde = true;
537
+ word.value += c;
538
+ i++;
539
+ }
540
+ }
541
+ return word;
542
+ };
543
+
544
+ const readRedirect = (fd) => {
545
+ const op = REDIRECT_OPS.find((candidate) => src.startsWith(candidate, i));
546
+ i += op.length;
547
+ emitRedirect(op, fd);
548
+ };
549
+
550
+ while (i < n) {
551
+ const c = src[i];
552
+ const next = src[i + 1];
553
+ if (c === " " || c === "\t") {
554
+ i++;
555
+ } else if (c === "\\" && next === "\n") {
556
+ i += 2;
557
+ } else if (c === "\n") {
558
+ emitOp("\n");
559
+ i++;
560
+ if (heredocs.length > 0) readHeredocBodies();
561
+ } else if (c === "#") {
562
+ const newline = src.indexOf("\n", i);
563
+ i = newline === -1 ? n : newline;
564
+ } else if (c === ")") {
565
+ if (nested && parens === 0) return { tokens, end: i + 1 };
566
+ parens = Math.max(0, parens - 1);
567
+ emitOp(")");
568
+ i++;
569
+ } else if (c === "(") {
570
+ if (next === "(") {
571
+ // `(( ... ))` arithmetic: never a redirect, never a command.
572
+ i = skipBalanced(src, i + 2, 2, "(", ")");
573
+ emitOp(";");
574
+ } else {
575
+ parens++;
576
+ emitOp("(");
577
+ i++;
578
+ }
579
+ } else if (c === ";") {
580
+ emitOp(";");
581
+ i += next === ";" || next === "&" ? 2 : 1;
582
+ } else if (c === "&") {
583
+ if (next === "&") {
584
+ emitOp("&&");
585
+ i += 2;
586
+ } else if (next === ">") {
587
+ const op = src[i + 2] === ">" ? "&>>" : "&>";
588
+ i += op.length;
589
+ emitRedirect(op, null);
590
+ } else {
591
+ emitOp("&");
592
+ i++;
593
+ }
594
+ } else if (c === "|") {
595
+ emitOp(next === "|" ? "||" : "|");
596
+ i += next === "|" || next === "&" ? 2 : 1;
597
+ } else if (c === "<" || c === ">") {
598
+ readRedirect(null);
599
+ } else {
600
+ let digitsEnd = i;
601
+ while (digitsEnd < n && src[digitsEnd] >= "0" && src[digitsEnd] <= "9") {
602
+ digitsEnd++;
603
+ }
604
+ if (digitsEnd > i && (src[digitsEnd] === "<" || src[digitsEnd] === ">")) {
605
+ const fd = src.slice(i, digitsEnd);
606
+ i = digitsEnd;
607
+ readRedirect(fd);
608
+ } else {
609
+ const commandStart = !pendingRedirect && atCommandStart();
610
+ const wordStart = i;
611
+ const word = readWord();
612
+ if (pendingRedirect) {
613
+ pendingRedirect.target = word;
614
+ if (HEREDOC_OPS.has(pendingRedirect.op)) {
615
+ heredocs.push(pendingRedirect);
616
+ }
617
+ pendingRedirect = null;
618
+ } else {
619
+ tokens.push({ kind: "word", word });
620
+ // `[[ ... ]]` / `[ ... ]`: one unit, so `<`, `>`, `&&` and `||`
621
+ // inside the test expression are neither redirects nor
622
+ // separators -- but its words are still lexed, so a `$(...)` or
623
+ // backtick substitution inside is collected for analysis.
624
+ const closer = TEST_CLOSERS.get(src.slice(wordStart, i));
625
+ const closeAt =
626
+ commandStart && closer !== undefined ? findTestCloser(closer) : -1;
627
+ if (closer !== undefined && closeAt !== -1) {
628
+ while (i < closeAt) {
629
+ const inner = src[i];
630
+ if (inner === " " || inner === "\t" || TEST_INERT.has(inner)) {
631
+ i++;
632
+ } else {
633
+ tokens.push({ kind: "word", word: readWord() });
634
+ }
635
+ }
636
+ tokens.push({
637
+ kind: "word",
638
+ word: { ...newWord(), value: closer },
639
+ });
640
+ i = Math.max(i, closeAt + closer.length);
641
+ }
642
+ }
643
+ }
644
+ }
645
+ }
646
+ return { tokens, end: n };
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // Bash detector -- path resolution
651
+ // ---------------------------------------------------------------------------
652
+
653
+ function basenameOf(name) {
654
+ return name.slice(name.lastIndexOf("/") + 1);
655
+ }
656
+
657
+ /** Lexical resolution: `null` when `text` is relative and the cwd is unknown. */
658
+ function resolveAgainst(base, text) {
659
+ if (isAbsoluteLike(text)) return posix.normalize(text);
660
+ if (base === null) return null;
661
+ return posix.normalize(`${base}/${text}`);
662
+ }
663
+
664
+ function isInsideProject(absPath, projectDir) {
665
+ const root = projectDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
666
+ const path = absPath.replace(/\\/g, "/").toLowerCase();
667
+ return path === root || path.startsWith(`${root}/`);
668
+ }
669
+
670
+ /**
671
+ * Returns the resolved path when `text` names a guarded path (or a guarded
672
+ * directory itself, e.g. `packages/cli/src/`), else null. With an unknown
673
+ * cwd (after a `cd` the lexer could not follow) a relative path is matched
674
+ * as-is -- the conservative side.
675
+ */
676
+ function protectedPathString(text, ctx, base) {
677
+ if (text === "" || text.startsWith("~")) return null;
678
+ const resolved = resolveAgainst(base, text) ?? posix.normalize(text);
679
+ if (resolved.startsWith("/dev/")) return null;
680
+ const scope = isAbsoluteLike(resolved) ? ctx.projectDir : undefined;
681
+ return isProtectedPath(resolved, scope) ||
682
+ isProtectedPath(`${resolved}/`, scope)
683
+ ? resolved
684
+ : null;
685
+ }
686
+
687
+ function protectedTarget(word, ctx, base = ctx.cwd) {
688
+ if (!word || word.dynamic || word.tilde) return null;
689
+ if (word.globAt === -1) return protectedPathString(word.value, ctx, base);
690
+ // Every expansion of a glob lives under its literal directory prefix, so
691
+ // that prefix (plus a placeholder entry) decides -- as does the whole
692
+ // path with each globbed segment a placeholder (`packages/*/src`). The
693
+ // glob is reported.
694
+ const prefix = word.value.slice(0, word.globAt);
695
+ const entry = `${prefix.slice(0, prefix.lastIndexOf("/") + 1)}__glob__`;
696
+ const placeholders = word.value
697
+ .split("/")
698
+ .map((segment) => (GLOB_CHAR.test(segment) ? "__glob__" : segment))
699
+ .join("/");
45
700
  if (
46
- typeof agentType === "string" &&
47
- agentType.length > 0 &&
48
- WRITER_SPOKES.has(agentType)
701
+ !protectedPathString(entry, ctx, base) &&
702
+ !protectedPathString(placeholders, ctx, base)
49
703
  ) {
50
- return false;
704
+ return null;
705
+ }
706
+ return resolveAgainst(base, word.value) ?? word.value;
707
+ }
708
+
709
+ function firstProtected(words, ctx, rule, base = ctx.cwd) {
710
+ for (const word of words) {
711
+ const path = protectedTarget(word, ctx, base);
712
+ if (path) return { path, rule };
51
713
  }
52
- return true;
714
+ return null;
53
715
  }
54
716
 
55
- // Deliberately inlined in every hook rather than shared: caps.ts counts
56
- // .claude/hooks/*.mjs files against a hard limit, so a helper module would
57
- // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
58
- // is not, so comparing them directly is false under any symlinked path and the
59
- // guard body would never run -- exit 0, i.e. fail open.
717
+ // Workspace container directories: `<container>` and `<container>/<pkg>`
718
+ // each hold a whole package's src/tests beneath them.
719
+ const WORKSPACE_CONTAINERS = ["packages", "apps", "libs"];
720
+ const GUARDED_DIRS = ["src", "tests"];
721
+ // Never a workspace package: package managers skip node_modules when
722
+ // expanding workspace globs, so `<container>/node_modules` holds no package.
723
+ const NON_PACKAGE_DIRS = ["node_modules"];
724
+ const GLOB_CHAR = /[*?[{]/;
725
+ const MATCH_ANY = /^/;
726
+
727
+ /**
728
+ * A matcher for one glob path segment. Brace expansion, and a character
729
+ * class the RegExp engine rejects, both match anything -- the conservative
730
+ * side for a guard.
731
+ */
732
+ function globMatcher(pattern) {
733
+ if (pattern.includes("{")) return MATCH_ANY;
734
+ let source = "";
735
+ for (let i = 0; i < pattern.length; i++) {
736
+ const c = pattern[i];
737
+ const classEnd = c === "[" ? pattern.indexOf("]", i + 2) : -1;
738
+ if (c === "*") source += ".*";
739
+ else if (c === "?") source += ".";
740
+ else if (classEnd !== -1) {
741
+ const body = pattern.slice(i + 1, classEnd);
742
+ const negate = body.startsWith("!") || body.startsWith("^");
743
+ const members = (negate ? body.slice(1) : body).replace(
744
+ /[\\\]^]/g,
745
+ "\\$&",
746
+ );
747
+ source += `[${negate ? "^" : ""}${members}]`;
748
+ i = classEnd;
749
+ } else source += c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
750
+ }
751
+ try {
752
+ return new RegExp(`^${source}$`);
753
+ } catch {
754
+ // An out-of-order range (`[z-a]`): unmatchable for the shell too, but
755
+ // matching anything here is the safe side.
756
+ return MATCH_ANY;
757
+ }
758
+ }
759
+
760
+ /** `resolved` relative to the project root; null when it lies outside it. */
761
+ function projectRelative(resolved, ctx) {
762
+ if (isAbsoluteLike(resolved)) {
763
+ if (!isInsideProject(resolved, ctx.projectDir)) return null;
764
+ const root = ctx.projectDir.replace(/\\/g, "/").replace(/\/+$/, "");
765
+ return resolved.replace(/\\/g, "/").slice(root.length);
766
+ }
767
+ // Unknown cwd: a relative path is read as project-relative (conservative).
768
+ return resolved === ".." || resolved.startsWith("../") ? null : resolved;
769
+ }
770
+
771
+ /**
772
+ * True when project-relative `segments` name a directory that CONTAINS a
773
+ * guarded tree: the project root (a flat layout's own src/tests), a
774
+ * workspace container, or a package directly inside one. A container only
775
+ * ever counts as the operand's OWN first segment: a container-shaped name
776
+ * deeper down (`docs/packages/old`) is an ordinary nested directory, and
777
+ * `node_modules` is never a package (`*\/node_modules`). A glob in the
778
+ * first segment counts as a container when it could expand to one
779
+ * (`*\/cli`, `pack*\/*`), since at runtime it may name exactly that
780
+ * ancestor. A glob in the last segment is read by what its PARENT is:
781
+ * under the project root it counts when it
782
+ * could expand to `src`, `tests` or a container (`*`); under a workspace
783
+ * container it always counts, since it may name a package (`packages/c*`);
784
+ * under a package it counts when it could expand to `src` or `tests`
785
+ * (`packages/cli/*`). That same slot -- index 2, directly under a package --
786
+ * decides at any depth: when it is a glob that could expand to `src` or
787
+ * `tests`, the operand expands into a package's src/tests however many
788
+ * segments follow (`packages/cli/*\/*`, `*\/*\/*\/x`). A glob any deeper
789
+ * (`dist/*`, `packages/cli/dist/*`) never counts, and nor does a literal path
790
+ * below a package (`packages/*\/dist`).
791
+ *
792
+ * Known limit: this is anchored on the operand's own first segment, so an
793
+ * ancestor of a src/ nested below a NON-container first segment -- a linked
794
+ * worktree's package (`.claude/worktrees/foo/packages/cli`), or any other
795
+ * checkout or package tree further down -- is knowingly not covered.
796
+ */
797
+ function containsGuardedTree(segments, globbed) {
798
+ const n = segments.length;
799
+ if (n === 0) return true;
800
+ const first = segments[0];
801
+ const last = segments[n - 1];
802
+ if (n === 1 && globbed && GLOB_CHAR.test(last)) {
803
+ const matcher = globMatcher(last);
804
+ return [...WORKSPACE_CONTAINERS, ...GUARDED_DIRS].some((name) =>
805
+ matcher.test(name),
806
+ );
807
+ }
808
+ const container =
809
+ WORKSPACE_CONTAINERS.includes(first) ||
810
+ (globbed &&
811
+ GLOB_CHAR.test(first) &&
812
+ WORKSPACE_CONTAINERS.some((name) => globMatcher(first).test(name)));
813
+ if (!container) return false;
814
+ if (n === 1) return true;
815
+ if (NON_PACKAGE_DIRS.includes(segments[1])) return false;
816
+ if (n === 2) return true;
817
+ // n >= 3: only a glob directly under a package (index 2) can name src/tests.
818
+ const slot = segments[2];
819
+ if (!globbed || !GLOB_CHAR.test(slot)) return false;
820
+ const matcher = globMatcher(slot);
821
+ return GUARDED_DIRS.some((name) => matcher.test(name));
822
+ }
823
+
824
+ /**
825
+ * The resolved path when `word` names an ANCESTOR of a guarded directory
826
+ * (see containsGuardedTree), else null. `root` reports the project root.
827
+ */
828
+ function ancestorTarget(word, ctx, base = ctx.cwd) {
829
+ if (!word || word.dynamic || word.tilde || word.value === "") return null;
830
+ const resolved =
831
+ resolveAgainst(base, word.value) ?? posix.normalize(word.value);
832
+ if (resolved.startsWith("/dev/")) return null;
833
+ const relative = projectRelative(resolved, ctx);
834
+ if (relative === null) return null;
835
+ const segments = relative
836
+ .split("/")
837
+ .filter((segment) => segment !== "" && segment !== ".");
838
+ return containsGuardedTree(segments, word.globAt !== -1)
839
+ ? { path: resolved, root: segments.length === 0 }
840
+ : null;
841
+ }
842
+
843
+ function firstAncestor(words, ctx, rule) {
844
+ for (const word of words) {
845
+ const hit = ancestorTarget(word, ctx);
846
+ if (hit) return { path: hit.path, rule, ancestor: true };
847
+ }
848
+ return null;
849
+ }
850
+
851
+ /** A source operand that copies a directory's CONTENTS (`dir/`, `dir/.`) or an unknown name. */
852
+ function copiedName(source) {
853
+ if (source.dynamic || source.tilde || source.globAt !== -1) return null;
854
+ if (source.value.endsWith("/")) return null;
855
+ const name = basenameOf(source.value);
856
+ return name === "" || name === "." || name === ".." ? null : name;
857
+ }
858
+
859
+ /**
860
+ * A cp/rsync/install/ln/mv destination that is an ancestor of a guarded
861
+ * directory. Into a directory known to exist (the project root, a trailing
862
+ * `/`, a `-t` target or several sources) each source lands as
863
+ * `dest/<basename>`, blocked when that is guarded or itself an ancestor, or
864
+ * when the source's name is unknown or it copies a directory's contents.
865
+ * Any other ancestor destination may be a rename that creates the whole
866
+ * tree, and is blocked.
867
+ */
868
+ function ancestorDestination(name, sources, destination, into, ctx) {
869
+ const dest = ancestorTarget(destination, ctx);
870
+ if (!dest) return null;
871
+ const hit = { path: dest.path, rule: name, ancestor: true };
872
+ const knownDir =
873
+ into ||
874
+ dest.root ||
875
+ destination.value.endsWith("/") ||
876
+ destination.value.endsWith("/.");
877
+ if (!knownDir) return hit;
878
+ for (const source of sources) {
879
+ const copied = copiedName(source);
880
+ if (copied === null) return hit;
881
+ const child = { ...newWord(), value: `${dest.path}/${copied}` };
882
+ if (protectedTarget(child, ctx) || ancestorTarget(child, ctx)) return hit;
883
+ }
884
+ return null;
885
+ }
886
+
887
+ function errorText(error) {
888
+ return error instanceof Error ? error.message : String(error);
889
+ }
890
+
891
+ function sizeNote(absPath) {
892
+ return `size cap (${MAX_READ_BYTES} bytes) exceeded; ${absPath} was not scanned`;
893
+ }
894
+
895
+ /**
896
+ * The default reader: a regular file of at most MAX_READ_BYTES. A file that
897
+ * does not exist (ENOENT) is nothing to scan and is not noted; any other
898
+ * failure -- a directory, a permission error -- is noted as a `read` gap.
899
+ */
900
+ function readFromDisk(ctx, absPath) {
901
+ let stats;
902
+ try {
903
+ stats = statSync(absPath);
904
+ } catch (error) {
905
+ if (error?.code !== "ENOENT") {
906
+ addNote(ctx.notes, `could not read ${absPath}: ${errorText(error)}`);
907
+ }
908
+ return null;
909
+ }
910
+ if (!stats.isFile()) {
911
+ addNote(ctx.notes, `could not read ${absPath}: not a regular file`);
912
+ return null;
913
+ }
914
+ if (stats.size > MAX_READ_BYTES) {
915
+ addNote(ctx.notes, sizeNote(absPath));
916
+ return null;
917
+ }
918
+ try {
919
+ return readFileSync(absPath, "utf8");
920
+ } catch (error) {
921
+ addNote(ctx.notes, `could not read ${absPath}: ${errorText(error)}`);
922
+ return null;
923
+ }
924
+ }
925
+
926
+ function safeRead(ctx, absPath) {
927
+ if (ctx.readFile === null) return readFromDisk(ctx, absPath);
928
+ let text;
929
+ try {
930
+ text = ctx.readFile(absPath);
931
+ } catch (error) {
932
+ // An injected reader that throws is a read gap, noted -- the detector
933
+ // itself does not throw on its account.
934
+ addNote(ctx.notes, `could not read ${absPath}: ${errorText(error)}`);
935
+ return null;
936
+ }
937
+ if (typeof text !== "string") return null;
938
+ if (text.length > MAX_READ_BYTES) {
939
+ addNote(ctx.notes, sizeNote(absPath));
940
+ return null;
941
+ }
942
+ return text;
943
+ }
944
+
945
+ /** Reads the file a word names; with `trustProject`, a file inside the project is not read. */
946
+ function readTarget(word, ctx, trustProject, base = ctx.cwd) {
947
+ if (!word || word.dynamic || word.tilde || word.globAt !== -1) return null;
948
+ const absPath = resolveAgainst(base, word.value);
949
+ if (absPath === null) return null;
950
+ if (trustProject && isInsideProject(absPath, ctx.projectDir)) return null;
951
+ return safeRead(ctx, absPath);
952
+ }
953
+
954
+ /** The text a command reads on stdin: the last heredoc, here-string or `<` file. */
955
+ function stdinText(redirects, ctx, trustProject) {
956
+ let text = null;
957
+ for (const redirect of redirects) {
958
+ if (!redirect.target || (redirect.fd !== null && redirect.fd !== "0")) {
959
+ continue;
960
+ }
961
+ if (HEREDOC_OPS.has(redirect.op)) text = redirect.body ?? "";
962
+ else if (redirect.op === "<<<") text = redirect.target.value;
963
+ else if (redirect.op === "<") {
964
+ text = readTarget(redirect.target, ctx, trustProject);
965
+ }
966
+ }
967
+ return text;
968
+ }
969
+
970
+ // ---------------------------------------------------------------------------
971
+ // Bash detector -- option parsing
972
+ // ---------------------------------------------------------------------------
973
+
974
+ function sliceWord(word, offset) {
975
+ return {
976
+ ...word,
977
+ value: word.value.slice(offset),
978
+ tilde: word.value[offset] === "~",
979
+ globAt: word.globAt >= offset ? word.globAt - offset : -1,
980
+ subs: [],
981
+ };
982
+ }
983
+
984
+ const NO_OPTIONS = new Set();
985
+
986
+ /**
987
+ * A getopt-ish split of `args` into options and operands. `withArg` names
988
+ * options taking a value (attached or as the next word); `optionalRest`
989
+ * names short options whose value can only be attached (`sed -i.bak`).
990
+ * With `stopAtOperand`, parsing stops at the first operand and `rest` holds
991
+ * it and everything after.
992
+ */
993
+ function getopt(args, withArg = NO_OPTIONS, settings = {}) {
994
+ const optionalRest = settings.optionalRest ?? NO_OPTIONS;
995
+ const options = [];
996
+ const operands = [];
997
+ let i = 0;
998
+ for (; i < args.length; i++) {
999
+ const word = args[i];
1000
+ const value = word.value;
1001
+ if (value === "--") {
1002
+ i++;
1003
+ break;
1004
+ }
1005
+ if (!value.startsWith("-") || value === "-") {
1006
+ if (settings.stopAtOperand) break;
1007
+ operands.push(word);
1008
+ continue;
1009
+ }
1010
+ if (value.startsWith("--")) {
1011
+ const eq = value.indexOf("=");
1012
+ if (eq !== -1) {
1013
+ options.push({
1014
+ name: value.slice(0, eq),
1015
+ value: sliceWord(word, eq + 1),
1016
+ });
1017
+ } else if (withArg.has(value)) {
1018
+ options.push({ name: value, value: args[i + 1] ?? null });
1019
+ i++;
1020
+ } else {
1021
+ options.push({ name: value, value: null });
1022
+ }
1023
+ continue;
1024
+ }
1025
+ for (let c = 1; c < value.length; c++) {
1026
+ const name = `-${value[c]}`;
1027
+ if (withArg.has(name) || optionalRest.has(name)) {
1028
+ if (c + 1 < value.length) {
1029
+ options.push({ name, value: sliceWord(word, c + 1) });
1030
+ } else if (withArg.has(name)) {
1031
+ options.push({ name, value: args[i + 1] ?? null });
1032
+ i++;
1033
+ } else {
1034
+ options.push({ name, value: null });
1035
+ }
1036
+ break;
1037
+ }
1038
+ options.push({ name, value: null });
1039
+ }
1040
+ }
1041
+ const rest = args.slice(i);
1042
+ return { options, operands: [...operands, ...rest], rest };
1043
+ }
1044
+
1045
+ function optionValues(options, ...names) {
1046
+ return options
1047
+ .filter((option) => names.includes(option.name) && option.value !== null)
1048
+ .map((option) => option.value);
1049
+ }
1050
+
1051
+ function hasOption(options, ...names) {
1052
+ return options.some((option) => names.includes(option.name));
1053
+ }
1054
+
1055
+ // ---------------------------------------------------------------------------
1056
+ // Bash detector -- per-command analysis
1057
+ // ---------------------------------------------------------------------------
1058
+
1059
+ const ASSIGNMENT = /^[A-Za-z_]\w*\+?=/;
1060
+ const RESERVED_PREFIXES = new Set([
1061
+ "!",
1062
+ "{",
1063
+ "}",
1064
+ "if",
1065
+ "then",
1066
+ "else",
1067
+ "elif",
1068
+ "do",
1069
+ "while",
1070
+ "until",
1071
+ ]);
1072
+ const WRAPPERS = new Map([
1073
+ [
1074
+ "sudo",
1075
+ {
1076
+ withArg: new Set(["-u", "-g", "-C", "-h", "-p", "-U", "-r", "-t", "-D"]),
1077
+ },
1078
+ ],
1079
+ ["env", { withArg: new Set(["-u", "-C", "-S", "-P", "--unset", "--chdir"]) }],
1080
+ ["nohup", { withArg: NO_OPTIONS }],
1081
+ ["time", { withArg: NO_OPTIONS }],
1082
+ ["command", { withArg: NO_OPTIONS }],
1083
+ ["exec", { withArg: new Set(["-a"]) }],
1084
+ ["nice", { withArg: new Set(["-n", "--adjustment"]) }],
1085
+ [
1086
+ "xargs",
1087
+ {
1088
+ withArg: new Set(["-I", "-L", "-n", "-P", "-d", "-E", "-s", "-a"]),
1089
+ optionalRest: new Set(["-i", "-l", "-e"]),
1090
+ },
1091
+ ],
1092
+ ["builtin", { withArg: NO_OPTIONS }],
1093
+ ]);
1094
+
1095
+ /** Drops leading assignments, reserved words and wrapper commands. */
1096
+ function stripPrefixes(words) {
1097
+ let k = 0;
1098
+ while (k < words.length) {
1099
+ const word = words[k];
1100
+ if (ASSIGNMENT.test(word.value) || RESERVED_PREFIXES.has(word.value)) {
1101
+ k++;
1102
+ continue;
1103
+ }
1104
+ const name = basenameOf(word.value);
1105
+ const wrapper = word.dynamic ? undefined : WRAPPERS.get(name);
1106
+ if (!wrapper) break;
1107
+ const { options, rest } = getopt(words.slice(k + 1), wrapper.withArg, {
1108
+ optionalRest: wrapper.optionalRest,
1109
+ stopAtOperand: true,
1110
+ });
1111
+ // `command -v x` only looks `x` up; it runs nothing.
1112
+ if (name === "command" && hasOption(options, "-v", "-V")) return [];
1113
+ k = words.length - rest.length;
1114
+ }
1115
+ return words.slice(k);
1116
+ }
1117
+
1118
+ const COPY_ARGS = new Map([
1119
+ ["cp", new Set(["-t", "--target-directory", "-S", "--suffix"])],
1120
+ [
1121
+ "install",
1122
+ new Set([
1123
+ "-m",
1124
+ "--mode",
1125
+ "-o",
1126
+ "--owner",
1127
+ "-g",
1128
+ "--group",
1129
+ "-t",
1130
+ "--target-directory",
1131
+ "-S",
1132
+ "--suffix",
1133
+ ]),
1134
+ ],
1135
+ [
1136
+ "rsync",
1137
+ new Set([
1138
+ "-e",
1139
+ "--rsh",
1140
+ "-f",
1141
+ "--filter",
1142
+ "-T",
1143
+ "--temp-dir",
1144
+ "-B",
1145
+ "--block-size",
1146
+ "--exclude",
1147
+ "--include",
1148
+ "--exclude-from",
1149
+ "--include-from",
1150
+ "--files-from",
1151
+ "--rsync-path",
1152
+ "-M",
1153
+ "--remote-option",
1154
+ ]),
1155
+ ],
1156
+ ["ln", new Set(["-t", "--target-directory", "-S", "--suffix"])],
1157
+ ["mv", new Set(["-t", "--target-directory", "-S", "--suffix"])],
1158
+ ]);
1159
+ const REMOVE_ARGS = new Map([
1160
+ ["rm", NO_OPTIONS],
1161
+ ["unlink", NO_OPTIONS],
1162
+ ["rmdir", NO_OPTIONS],
1163
+ ["touch", new Set(["-r", "--reference", "-d", "--date", "-t"])],
1164
+ ["truncate", new Set(["-s", "--size", "-r", "--reference"])],
1165
+ ]);
1166
+ const SED_ARGS = new Set([
1167
+ "-e",
1168
+ "--expression",
1169
+ "-f",
1170
+ "--file",
1171
+ "-l",
1172
+ "--line-length",
1173
+ ]);
1174
+ const PATCH_ARGS = new Set([
1175
+ "-p",
1176
+ "--strip",
1177
+ "-d",
1178
+ "--directory",
1179
+ "-i",
1180
+ "--input",
1181
+ "-o",
1182
+ "--output",
1183
+ "-r",
1184
+ "--reject-file",
1185
+ "-B",
1186
+ "--prefix",
1187
+ "-z",
1188
+ "--suffix",
1189
+ "-F",
1190
+ "--fuzz",
1191
+ "-D",
1192
+ "--ifdef",
1193
+ "-V",
1194
+ "--version-control",
1195
+ "-Y",
1196
+ "--basename-prefix",
1197
+ ]);
1198
+ const PATCH_DRY = ["--dry-run", "--check", "-C"];
1199
+ const GIT_GLOBAL_ARGS = new Set([
1200
+ "-C",
1201
+ "-c",
1202
+ "--git-dir",
1203
+ "--work-tree",
1204
+ "--namespace",
1205
+ "--config-env",
1206
+ ]);
1207
+ const GIT_APPLY_ARGS = new Set([
1208
+ "-p",
1209
+ "-C",
1210
+ "--directory",
1211
+ "--exclude",
1212
+ "--include",
1213
+ "--whitespace",
1214
+ "--build-fake-ancestor",
1215
+ ]);
1216
+ const GIT_APPLY_DRY = ["--check", "--stat", "--numstat", "--summary"];
1217
+ const SHELL_ARGS = new Set(["-o", "-O"]);
1218
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
1219
+
1220
+ const INTERPRETER =
1221
+ /^(?:python(?:\d+(?:\.\d+)*)?|node|nodejs|deno|bun|ruby|perl|php)$/;
1222
+ const PYTHON_ARGS = new Set(["-c", "-m", "-W", "-X", "-Q"]);
1223
+ const NODE_ARGS = new Set([
1224
+ "-e",
1225
+ "--eval",
1226
+ "-p",
1227
+ "--print",
1228
+ "-r",
1229
+ "--require",
1230
+ "--import",
1231
+ "--loader",
1232
+ "--experimental-loader",
1233
+ "--input-type",
1234
+ "-C",
1235
+ "--conditions",
1236
+ "--env-file",
1237
+ "--title",
1238
+ ]);
1239
+ const RUBY_ARGS = new Set(["-e", "-r", "-I", "-C", "-E", "-F"]);
1240
+ const PERL_ARGS = new Set(["-e", "-E", "-M", "-m", "-I"]);
1241
+ const PERL_OPTIONAL = new Set(["-i", "-0", "-l", "-C", "-x", "-d", "-D"]);
1242
+ const PHP_ARGS = new Set(["-r", "-f", "-c", "-d", "-z"]);
1243
+
1244
+ // A write-verb CALL in interpreter code; the argument list after the `(`
1245
+ // is then split and only the written-position argument is checked.
1246
+ // Delete, move and truncate calls count as writes too.
1247
+ const WRITE_CALL =
1248
+ /\b(Deno\.writeTextFileSync|Deno\.writeTextFile|Deno\.removeSync|Deno\.remove|writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream|file_put_contents|copyFileSync|copyFile|cpSync|cp|copy|renameSync|rename|openSync|open|fopen|truncateSync|truncate|rmSync|rm|rmdirSync|rmdir|unlinkSync|unlink|shutil\.copy\w*|shutil\.move|shutil\.rmtree|os\.rename|os\.replace|os\.remove|os\.removedirs|File\.delete|FileUtils\.rm\w*|(?:File|IO|Bun)\.write)\s*\(/g;
1249
+ // Verbs that remove or move a whole directory tree: an ANCESTOR of a
1250
+ // guarded directory as their target counts too (see containsGuardedTree).
1251
+ const TREE_VERB =
1252
+ /^(?:Deno\.removeSync|Deno\.remove|rmSync|rm|renameSync|rename|shutil\.move|shutil\.rmtree|os\.rename|os\.replace|FileUtils\.rm\w*)$/;
1253
+ // `\b` keeps `remove` (os.remove, Deno.remove) from reading as a move.
1254
+ const MOVE_VERB = /rename|replace|\bmove/;
1255
+ // `Path('...').write_text(...)`, `.unlink()`, `.rename(...)`...: the
1256
+ // receiver is written, removed or moved.
1257
+ const PATH_RECEIVER_WRITE =
1258
+ /\bPath\s*\(\s*[rRbBuU]?(['"])([^'"\n]{0,4096})\1\s*\)\s*\.(write_text|write_bytes|touch|unlink|rmdir|rename|replace)\s*\(/g;
1259
+ // A whole argument that is one string literal (optionally a `kw=` keyword
1260
+ // argument, optionally a Python string prefix).
1261
+ const LITERAL_ARG = /^(?:\w+\s*=\s*)?[rRbBuU]{0,2}(['"`])([^'"`\n]*)\1$/;
1262
+ // An fopen-style mode that writes: contains w, a, x, c (php) or +.
1263
+ const WRITE_MODE = /^[rbtU]*[waxc+][rwaxcbtU+]*$/;
1264
+ // perl's open modes, alone (3-arg form) or prefixed to the path (2-arg form).
1265
+ const PERL_MODE_ONLY = /^(?:\+?>{1,2}|\+<)$/;
1266
+ const PERL_MODE_PATH = /^(?:\+?>{1,2}|\+<)\s*(\S.*)$/;
1267
+ // perl's paren-less 2-arg form: `open F, ">path"` / `open my $fh, ">path"`.
1268
+ const PERL_BARE_OPEN =
1269
+ /\bopen\s+(?:my\s+)?\$?\w+\s*,\s*(['"])(?:\+?>{1,2}|\+<)\s*([^'"\n]{1,4096})\1/g;
1270
+ const KWARG = /^\s*\w+\s*=[^=]/;
1271
+ const MODE_KWARG = /^\s*mode\s*=[^=]/;
1272
+ // An argument list longer than this is not split (keeps the scan linear).
1273
+ const MAX_CALL_CHARS = 1000;
1274
+ const CALL_SIZE_NOTE = `call size cap (${MAX_CALL_CHARS} chars) exceeded; an interpreter write call's arguments were not analysed`;
1275
+ const SRC_OR_TESTS_SEGMENT = /(?:^|\/)(?:src|tests)(?:\/|$)/;
1276
+ const PATCH_HEADER = /^(?:\+\+\+ |--- |diff --git )(.*)$/gm;
1277
+
1278
+ /** Index just past the quoted string opening at `i` (backslash-aware). */
1279
+ function skipQuoted(code, i) {
1280
+ const quote = code[i];
1281
+ let j = i + 1;
1282
+ while (j < code.length && code[j] !== quote) j += code[j] === "\\" ? 2 : 1;
1283
+ return j;
1284
+ }
1285
+
1286
+ /** One linear pass: the index of each `(`'s matching `)`, quotes skipped. */
1287
+ function matchParens(code) {
1288
+ const close = new Map();
1289
+ const open = [];
1290
+ for (let i = 0; i < code.length; i++) {
1291
+ const c = code[i];
1292
+ if (c === "'" || c === '"' || c === "`") i = skipQuoted(code, i);
1293
+ else if (c === "(") open.push(i);
1294
+ else if (c === ")" && open.length > 0) close.set(open.pop(), i);
1295
+ }
1296
+ return close;
1297
+ }
1298
+
1299
+ /**
1300
+ * Splits the top-level arguments of the call whose `(` is at `openAt`;
1301
+ * null when it never closes or spans more than MAX_CALL_CHARS (so a flood
1302
+ * of unclosed calls stays linear). A call past the cap is noted in `notes`
1303
+ * -- it is allowed unscreened, but never silently.
1304
+ */
1305
+ function splitArgs(code, openAt, parens, notes) {
1306
+ const closeAt = parens.get(openAt);
1307
+ if (closeAt === undefined) return null;
1308
+ if (closeAt - openAt > MAX_CALL_CHARS) {
1309
+ addNote(notes, CALL_SIZE_NOTE);
1310
+ return null;
1311
+ }
1312
+ const args = [];
1313
+ let depth = 0;
1314
+ let argStart = openAt + 1;
1315
+ for (let i = argStart; i < closeAt; i++) {
1316
+ const c = code[i];
1317
+ if (c === "'" || c === '"' || c === "`") i = skipQuoted(code, i);
1318
+ else if (c === "(" || c === "[" || c === "{") depth++;
1319
+ else if (c === ")" || c === "]" || c === "}") depth--;
1320
+ else if (c === "," && depth === 0) {
1321
+ args.push(code.slice(argStart, i));
1322
+ argStart = i + 1;
1323
+ }
1324
+ }
1325
+ args.push(code.slice(argStart, closeAt));
1326
+ return args;
1327
+ }
1328
+
1329
+ function literalOf(arg) {
1330
+ if (typeof arg !== "string") return null;
1331
+ const match = LITERAL_ARG.exec(arg.trim());
1332
+ if (!match || match[2].includes("${")) return null;
1333
+ return match[2];
1334
+ }
1335
+
1336
+ /** The literal path(s) a call writes, by verb and argument position. */
1337
+ function writtenLiterals(verb, args) {
1338
+ if (verb === "open" || verb === "openSync" || verb === "fopen") {
1339
+ // A `mode=` keyword argument wins wherever it sits; otherwise the
1340
+ // second argument, unless that is some other keyword argument.
1341
+ const modeArg =
1342
+ args.find((arg) => MODE_KWARG.test(arg)) ??
1343
+ (KWARG.test(args[1] ?? "") ? undefined : args[1]);
1344
+ const mode = literalOf(modeArg);
1345
+ if (mode === null) return [];
1346
+ if (PERL_MODE_ONLY.test(mode.trim())) return [literalOf(args[2])];
1347
+ const perlPath = PERL_MODE_PATH.exec(mode);
1348
+ if (perlPath) return [perlPath[1]];
1349
+ return WRITE_MODE.test(mode) ? [literalOf(args[0])] : [];
1350
+ }
1351
+ if (MOVE_VERB.test(verb)) return [literalOf(args[0]), literalOf(args[1])];
1352
+ if (/^(?:copy|cp|shutil\.copy)/.test(verb)) return [literalOf(args[1])];
1353
+ // writeFile/appendFile/file_put_contents/createWriteStream/File|IO|Bun.write
1354
+ // and every delete/truncate verb: the first.
1355
+ return [literalOf(args[0])];
1356
+ }
1357
+
1358
+ /**
1359
+ * Interpreter code: blocked only when a write verb's WRITTEN argument (not
1360
+ * any literal anywhere) is a string literal naming a guarded path.
1361
+ */
1362
+ function scanCode(code, ctx, rule) {
1363
+ const check = (literal, tree) => {
1364
+ if (literal === null) return null;
1365
+ const text = literal.trim();
1366
+ if (SRC_OR_TESTS_SEGMENT.test(text)) {
1367
+ const path = protectedPathString(text, ctx, ctx.cwd);
1368
+ if (path) return { path, rule };
1369
+ }
1370
+ if (!tree) return null;
1371
+ const ancestor = ancestorTarget({ ...newWord(), value: text }, ctx);
1372
+ return ancestor ? { path: ancestor.path, rule, ancestor: true } : null;
1373
+ };
1374
+ for (const match of code.matchAll(PATH_RECEIVER_WRITE)) {
1375
+ const hit = check(match[2], MOVE_VERB.test(match[3]));
1376
+ if (hit) return hit;
1377
+ }
1378
+ for (const match of code.matchAll(PERL_BARE_OPEN)) {
1379
+ const hit = check(match[2], false);
1380
+ if (hit) return hit;
1381
+ }
1382
+ let parens = null;
1383
+ for (const match of code.matchAll(WRITE_CALL)) {
1384
+ parens ??= matchParens(code);
1385
+ const args = splitArgs(
1386
+ code,
1387
+ match.index + match[0].length - 1,
1388
+ parens,
1389
+ ctx.notes,
1390
+ );
1391
+ if (!args) continue;
1392
+ const tree = TREE_VERB.test(match[1]);
1393
+ for (const literal of writtenLiterals(match[1], args)) {
1394
+ const hit = check(literal, tree);
1395
+ if (hit) return hit;
1396
+ }
1397
+ }
1398
+ return null;
1399
+ }
1400
+
1401
+ /** Patch text: blocked when a `diff --git`/`---`/`+++` header names a guarded path. */
1402
+ function scanPatch(text, base, ctx, rule) {
1403
+ for (const match of text.matchAll(PATCH_HEADER)) {
1404
+ const candidates = match[0].startsWith("diff")
1405
+ ? match[1].split(" ")
1406
+ : [match[1].split("\t")[0].trim()];
1407
+ for (const candidate of candidates) {
1408
+ const path = candidate.replace(/^"|"$/g, "").replace(/^[ab]\//, "");
1409
+ if (path === "" || path === "/dev/null") continue;
1410
+ const hit = protectedPathString(path, ctx, base);
1411
+ if (hit) return { path: hit, rule };
1412
+ }
1413
+ }
1414
+ return null;
1415
+ }
1416
+
1417
+ function interpreterSource(name, args) {
1418
+ if (name.startsWith("python")) {
1419
+ const { options, rest } = getopt(args, PYTHON_ARGS, {
1420
+ stopAtOperand: true,
1421
+ });
1422
+ if (hasOption(options, "-m")) return { skip: true };
1423
+ const code = optionValues(options, "-c");
1424
+ return { code, script: rest[0] };
1425
+ }
1426
+ if (name === "node" || name === "nodejs" || name === "bun") {
1427
+ const { options, rest } = getopt(args, NODE_ARGS, { stopAtOperand: true });
1428
+ const operands =
1429
+ name === "bun" && rest[0]?.value === "run" ? rest.slice(1) : rest;
1430
+ const code = optionValues(options, "-e", "--eval", "-p", "--print");
1431
+ return { code, script: operands[0] };
1432
+ }
1433
+ if (name === "deno") {
1434
+ const { rest } = getopt(args, NO_OPTIONS, { stopAtOperand: true });
1435
+ if (rest[0]?.value === "eval")
1436
+ return { code: rest.slice(1, 2), script: undefined };
1437
+ const runArgs = rest[0]?.value === "run" ? rest.slice(1) : rest;
1438
+ const run = getopt(runArgs, NO_OPTIONS, { stopAtOperand: true });
1439
+ return { code: [], script: run.rest[0] };
1440
+ }
1441
+ if (name === "ruby") {
1442
+ const { options, rest } = getopt(args, RUBY_ARGS, { stopAtOperand: true });
1443
+ return { code: optionValues(options, "-e"), script: rest[0] };
1444
+ }
1445
+ if (name === "php") {
1446
+ const { options, rest } = getopt(args, PHP_ARGS, { stopAtOperand: true });
1447
+ const script = optionValues(options, "-f")[0] ?? rest[0];
1448
+ return { code: optionValues(options, "-r"), script };
1449
+ }
1450
+ // perl: in-place edits are handled by the caller; here only the code.
1451
+ const { options, operands } = getopt(args, PERL_ARGS, {
1452
+ optionalRest: PERL_OPTIONAL,
1453
+ });
1454
+ const code = optionValues(options, "-e", "-E");
1455
+ return { code, script: code.length > 0 ? undefined : operands[0] };
1456
+ }
1457
+
1458
+ function analyseInterpreter(name, args, redirects, ctx) {
1459
+ if (name === "perl") {
1460
+ const { options, operands } = getopt(args, PERL_ARGS, {
1461
+ optionalRest: PERL_OPTIONAL,
1462
+ });
1463
+ if (hasOption(options, "-i")) {
1464
+ const files = hasOption(options, "-e", "-E")
1465
+ ? operands
1466
+ : operands.slice(1);
1467
+ const hit = firstProtected(files, ctx, name);
1468
+ if (hit) return hit;
1469
+ }
1470
+ }
1471
+ const source = interpreterSource(name, args);
1472
+ if (source.skip) return null;
1473
+ if (source.code.length > 0) {
1474
+ return scanCode(
1475
+ source.code.map((word) => word.value).join("\n"),
1476
+ ctx,
1477
+ name,
1478
+ );
1479
+ }
1480
+ const text =
1481
+ source.script && source.script.value !== "-"
1482
+ ? readTarget(source.script, ctx, true)
1483
+ : stdinText(redirects, ctx, true);
1484
+ return text ? scanCode(text, ctx, name) : null;
1485
+ }
1486
+
1487
+ function analyseSed(args, ctx) {
1488
+ const { options, operands } = getopt(args, SED_ARGS, {
1489
+ optionalRest: new Set(["-i"]),
1490
+ });
1491
+ if (!hasOption(options, "-i", "--in-place")) return null;
1492
+ // BSD `sed -i '' ...` passes the (empty) backup suffix as its own word.
1493
+ const files = operands[0]?.value === "" ? operands.slice(1) : operands;
1494
+ const hasScript = hasOption(options, "-e", "--expression", "-f", "--file");
1495
+ return firstProtected(hasScript ? files : files.slice(1), ctx, "sed");
1496
+ }
1497
+
1498
+ function analyseCopy(name, args, ctx) {
1499
+ const { options, operands } = getopt(args, COPY_ARGS.get(name));
1500
+ const targets =
1501
+ name === "rsync" ? [] : optionValues(options, "-t", "--target-directory");
1502
+ if (name === "mv") {
1503
+ const hit = firstProtected([...operands, ...targets], ctx, name);
1504
+ if (hit) return hit;
1505
+ } else if (name === "install" && hasOption(options, "-d", "--directory")) {
1506
+ return firstProtected(operands, ctx, name);
1507
+ }
1508
+ const into = targets.length > 0 || operands.length > 2;
1509
+ const sources = targets.length > 0 ? operands : operands.slice(0, -1);
1510
+ const destination =
1511
+ targets.length > 0 ? targets[targets.length - 1] : operands.at(-1);
1512
+ // A whole ancestor moved away takes its src/tests with it; so does
1513
+ // `rsync --remove-source-files`, which deletes every file it copied.
1514
+ const removesSources =
1515
+ name === "mv" ||
1516
+ (name === "rsync" && hasOption(options, "--remove-source-files"));
1517
+ if (removesSources) {
1518
+ const moved =
1519
+ (name === "rsync" ? firstProtected(sources, ctx, name) : null) ??
1520
+ firstAncestor(sources, ctx, name);
1521
+ if (moved) return moved;
1522
+ }
1523
+ if (targets.length > 0) {
1524
+ const hit = firstProtected(targets, ctx, name);
1525
+ if (hit) return hit;
1526
+ } else if (operands.length < 2) {
1527
+ return null;
1528
+ }
1529
+ // `host:path` is a remote rsync destination, never a local write.
1530
+ if (name === "rsync" && /^[^/]*:/.test(destination.value)) return null;
1531
+ return (
1532
+ firstProtected([destination], ctx, name) ??
1533
+ ancestorDestination(name, sources, destination, into, ctx)
1534
+ );
1535
+ }
1536
+
1537
+ function analysePatch(args, redirects, ctx) {
1538
+ const rule = "patch";
1539
+ const { options, operands } = getopt(args, PATCH_ARGS);
1540
+ if (hasOption(options, ...PATCH_DRY)) return null;
1541
+ const directory = optionValues(options, "-d", "--directory")[0];
1542
+ let base = ctx.cwd;
1543
+ if (directory) {
1544
+ const hit = firstProtected([directory], ctx, rule);
1545
+ if (hit) return hit;
1546
+ base =
1547
+ directory.dynamic || directory.tilde
1548
+ ? null
1549
+ : resolveAgainst(ctx.cwd, directory.value);
1550
+ }
1551
+ const outputs = optionValues(options, "-o", "--output");
1552
+ const written = firstProtected(
1553
+ [...outputs, ...operands.slice(0, 1)],
1554
+ ctx,
1555
+ rule,
1556
+ base,
1557
+ );
1558
+ if (written) return written;
1559
+ const patchFile = optionValues(options, "-i", "--input")[0] ?? operands[1];
1560
+ const text =
1561
+ patchFile && patchFile.value !== "-"
1562
+ ? readTarget(patchFile, ctx, false)
1563
+ : stdinText(redirects, ctx, false);
1564
+ return text ? scanPatch(text, base, ctx, rule) : null;
1565
+ }
1566
+
1567
+ function analyseGit(args, redirects, ctx) {
1568
+ const { options, rest } = getopt(args, GIT_GLOBAL_ARGS, {
1569
+ stopAtOperand: true,
1570
+ });
1571
+ if (rest[0]?.value !== "apply") return null;
1572
+ const rule = "patch (git apply)";
1573
+ let base = ctx.cwd;
1574
+ for (const dir of optionValues(options, "-C")) {
1575
+ base = dir.dynamic || dir.tilde ? null : resolveAgainst(base, dir.value);
1576
+ }
1577
+ const applyCtx = { ...ctx, cwd: base };
1578
+ const apply = getopt(rest.slice(1), GIT_APPLY_ARGS);
1579
+ if (hasOption(apply.options, ...GIT_APPLY_DRY)) return null;
1580
+ const directory = optionValues(apply.options, "--directory")[0];
1581
+ let root = base;
1582
+ if (directory) {
1583
+ const hit = firstProtected([directory], applyCtx, rule);
1584
+ if (hit) return hit;
1585
+ root =
1586
+ directory.dynamic || directory.tilde
1587
+ ? null
1588
+ : resolveAgainst(base, directory.value);
1589
+ }
1590
+ const files = apply.operands.filter((word) => word.value !== "-");
1591
+ const named = firstProtected(files, applyCtx, rule);
1592
+ if (named) return named;
1593
+ const texts =
1594
+ files.length > 0
1595
+ ? files.map((word) => readTarget(word, applyCtx, false))
1596
+ : [stdinText(redirects, applyCtx, false)];
1597
+ for (const text of texts) {
1598
+ const hit = text ? scanPatch(text, root, ctx, rule) : null;
1599
+ if (hit) return hit;
1600
+ }
1601
+ return null;
1602
+ }
1603
+
1604
+ function analyseString(text, ctx, depth) {
1605
+ if (depth + 1 > MAX_DEPTH) {
1606
+ addNote(ctx.notes, NESTING_NOTE);
1607
+ return null;
1608
+ }
1609
+ return analyseTokens(
1610
+ lex(text, 0, depth + 1, false, ctx.notes).tokens,
1611
+ { ...ctx },
1612
+ depth + 1,
1613
+ );
1614
+ }
1615
+
1616
+ function analyseShell(args, redirects, ctx, depth) {
1617
+ const { options, rest } = getopt(args, SHELL_ARGS, { stopAtOperand: true });
1618
+ if (hasOption(options, "-c")) {
1619
+ return rest[0] ? analyseString(rest[0].value, ctx, depth) : null;
1620
+ }
1621
+ if (rest.length > 0) return null;
1622
+ const text = stdinText(redirects, ctx, true);
1623
+ return text ? analyseString(text, ctx, depth) : null;
1624
+ }
1625
+
1626
+ function changeDirectory(args, ctx) {
1627
+ const target = getopt(args).operands[0];
1628
+ if (!target || target.value === "-" || target.dynamic || target.tilde) {
1629
+ ctx.cwd = null;
1630
+ return;
1631
+ }
1632
+ ctx.cwd = resolveAgainst(ctx.cwd, target.value);
1633
+ }
1634
+
1635
+ function analyseInvocation(name, args, redirects, ctx, depth) {
1636
+ if (INTERPRETER.test(name)) {
1637
+ return analyseInterpreter(name, args, redirects, ctx);
1638
+ }
1639
+ if (SHELLS.has(name)) return analyseShell(args, redirects, ctx, depth);
1640
+ if (COPY_ARGS.has(name)) return analyseCopy(name, args, ctx);
1641
+ if (REMOVE_ARGS.has(name)) {
1642
+ const { operands } = getopt(args, REMOVE_ARGS.get(name));
1643
+ return (
1644
+ firstProtected(operands, ctx, name) ??
1645
+ (name === "rm" ? firstAncestor(operands, ctx, name) : null)
1646
+ );
1647
+ }
1648
+ switch (name) {
1649
+ case "cd":
1650
+ case "pushd":
1651
+ changeDirectory(args, ctx);
1652
+ return null;
1653
+ case "popd":
1654
+ ctx.cwd = null;
1655
+ return null;
1656
+ case "tee":
1657
+ return firstProtected(getopt(args).operands, ctx, "tee");
1658
+ case "sed":
1659
+ return analyseSed(args, ctx);
1660
+ case "dd":
1661
+ return firstProtected(
1662
+ args
1663
+ .filter((word) => word.value.startsWith("of="))
1664
+ .map((word) => sliceWord(word, 3)),
1665
+ ctx,
1666
+ "dd",
1667
+ );
1668
+ case "patch":
1669
+ return analysePatch(args, redirects, ctx);
1670
+ case "git":
1671
+ return analyseGit(args, redirects, ctx);
1672
+ case "eval":
1673
+ return analyseString(
1674
+ args.map((word) => word.value).join(" "),
1675
+ ctx,
1676
+ depth,
1677
+ );
1678
+ default:
1679
+ return null;
1680
+ }
1681
+ }
1682
+
1683
+ function isOutputRedirect(redirect) {
1684
+ if (!redirect.target) return false;
1685
+ if (OUTPUT_OPS.has(redirect.op)) return true;
1686
+ return redirect.op === ">&" && !/^(?:\d+|-)$/.test(redirect.target.value);
1687
+ }
1688
+
1689
+ function analyseCommand(command, ctx, depth) {
1690
+ const nested = [
1691
+ ...command.words,
1692
+ ...command.redirects.map((redirect) => redirect.target),
1693
+ ];
1694
+ for (const word of nested) {
1695
+ for (const sub of word?.subs ?? []) {
1696
+ const hit = analyseTokens(sub, { ...ctx }, depth + 1);
1697
+ if (hit) return hit;
1698
+ }
1699
+ }
1700
+ const words = stripPrefixes(command.words);
1701
+ const head = words[0];
1702
+ if (head && !head.dynamic) {
1703
+ const hit = analyseInvocation(
1704
+ basenameOf(head.value),
1705
+ words.slice(1),
1706
+ command.redirects,
1707
+ ctx,
1708
+ depth,
1709
+ );
1710
+ if (hit) return hit;
1711
+ if (head.value === "[[" || head.value === "[") return null;
1712
+ }
1713
+ for (const redirect of command.redirects) {
1714
+ if (!isOutputRedirect(redirect)) continue;
1715
+ const path = protectedTarget(redirect.target, ctx);
1716
+ if (path) return { path, rule: "redirect" };
1717
+ }
1718
+ return null;
1719
+ }
1720
+
1721
+ function analyseTokens(tokens, ctx, depth) {
1722
+ const savedCwds = [];
1723
+ let command = { words: [], redirects: [] };
1724
+ for (const token of tokens) {
1725
+ if (token.kind === "op") {
1726
+ const hit = analyseCommand(command, ctx, depth);
1727
+ if (hit) return hit;
1728
+ command = { words: [], redirects: [] };
1729
+ if (token.value === "(") savedCwds.push(ctx.cwd);
1730
+ else if (token.value === ")" && savedCwds.length > 0) {
1731
+ ctx.cwd = savedCwds.pop();
1732
+ }
1733
+ } else if (token.kind === "word") {
1734
+ command.words.push(token.word);
1735
+ } else {
1736
+ command.redirects.push(token);
1737
+ }
1738
+ }
1739
+ return analyseCommand(command, ctx, depth);
1740
+ }
1741
+
1742
+ /**
1743
+ * Finds the first guarded path a Bash command visibly writes to -- exported
1744
+ * for unit testing. It never executes anything: the command is only lexed,
1745
+ * and the only filesystem access is reading a script located outside the
1746
+ * project or a named patch file. It is written so that no command text
1747
+ * makes it throw (reader failures become notes); the entry point still
1748
+ * wraps it in a fail-open catch in case that design has a hole.
1749
+ *
1750
+ * @param {string} command The `tool_input.command` text.
1751
+ * @param {{ cwd: string, projectDir: string, readFile?: (absPath: string) => string | undefined, notes?: Set<string> }} opts
1752
+ * `cwd` resolves relative paths; `projectDir` scopes absolute ones (see
1753
+ * isProtectedPath); `readFile` defaults to a size-capped real read;
1754
+ * `notes`, when given, receives one line per part of the command the
1755
+ * detector could not analyse (`nesting`, `size`, `call size`, `read`).
1756
+ * @returns {{ path: string, rule: string, ancestor?: true } | null} The
1757
+ * resolved path and the rule that matched (`redirect`, or the writing
1758
+ * tool's name), or null. `ancestor` is set when the path is not itself
1759
+ * guarded but CONTAINS a guarded tree (see containsGuardedTree).
1760
+ */
1761
+ export function findBashWriteToProtectedPath(command, opts) {
1762
+ if (typeof command !== "string" || command.trim() === "") return null;
1763
+ if (!opts || typeof opts.projectDir !== "string") return null;
1764
+ const ctx = {
1765
+ cwd:
1766
+ typeof opts.cwd === "string" && opts.cwd !== ""
1767
+ ? posix.normalize(opts.cwd.replace(/\\/g, "/"))
1768
+ : null,
1769
+ projectDir: opts.projectDir,
1770
+ readFile: typeof opts.readFile === "function" ? opts.readFile : null,
1771
+ notes: opts.notes instanceof Set ? opts.notes : null,
1772
+ };
1773
+ return analyseTokens(lex(command, 0, 0, false, ctx.notes).tokens, ctx, 0);
1774
+ }
1775
+
1776
+ /**
1777
+ * Bash counterpart of shouldBlockHubSrcWrite -- exported for unit testing.
1778
+ * Writer spokes are never blocked; any other caller is blocked when the
1779
+ * command writes into a guarded path.
1780
+ * Callers must pass `spokeAgentType(input)`, not the raw payload `agent_type`,
1781
+ * or a `claude --agent` main session passes as a writer spoke.
1782
+ *
1783
+ * @param {string} command
1784
+ * @param {unknown} agentType The top-level agent_type from the payload.
1785
+ * @param {{ cwd: string, projectDir: string, readFile?: (absPath: string) => string | undefined, notes?: Set<string> }} opts
1786
+ * Same as findBashWriteToProtectedPath; `notes` stays empty for a writer
1787
+ * spoke, whose command is not analysed at all.
1788
+ * @returns {{ path: string, rule: string } | null} The write to block, or null to allow.
1789
+ */
1790
+ export function shouldBlockHubBashWrite(command, agentType, opts) {
1791
+ if (isWriterSpoke(agentType)) return null;
1792
+ return findBashWriteToProtectedPath(command, opts);
1793
+ }
1794
+
1795
+ // Kept as a duplicated, self-contained block in every hook file rather than
1796
+ // imported from a shared helper -- each hook stays a single independent
1797
+ // file, which keeps this project's hook count easy to reason about against
1798
+ // CLAUDE.md's hook budget. `import.meta.url` is symlink-resolved but
1799
+ // `process.argv[1]` is not, so comparing them directly would be false under
1800
+ // a symlinked invocation path -- and the guard below would then never run,
1801
+ // i.e. silently fail open (exit 0) instead of blocking.
60
1802
  function isEntryPoint() {
61
1803
  try {
62
1804
  return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -65,19 +1807,45 @@ function isEntryPoint() {
65
1807
  }
66
1808
  }
67
1809
 
68
- // Only run when invoked directly, not when imported for testing.
69
- if (isEntryPoint()) {
70
- const chunks = [];
71
- for await (const chunk of process.stdin) chunks.push(chunk);
72
- let input;
73
- try {
74
- input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
75
- } catch {
1810
+ // The agent_type the pure decision functions should see: the payload's own
1811
+ // only when a string `agent_id` that is non-empty after `.trim()` proves the
1812
+ // call fires inside a subagent; otherwise (absent, non-string, empty or
1813
+ // whitespace-only) undefined, i.e. the hub. `agent_type` alone is not
1814
+ // enough -- a `claude --agent code-implementer` main session sends it too
1815
+ // (see the header's "The seam").
1816
+ function spokeAgentType(input) {
1817
+ return typeof input.agent_id === "string" && input.agent_id.trim() !== ""
1818
+ ? input.agent_type
1819
+ : undefined;
1820
+ }
1821
+
1822
+ function runWriteGuard(input) {
1823
+ const filePath = input.tool_input?.file_path ?? "";
1824
+ if (typeof filePath !== "string") {
1825
+ process.stderr.write(
1826
+ `guard-hub-src-writes: ${input.tool_name} payload has no string tool_input.file_path (got ${typeof filePath}); allowing without analysis.\n`,
1827
+ );
1828
+ process.exit(0);
1829
+ }
1830
+ const agentType = spokeAgentType(input);
1831
+ // Canonicalized (case-correct, symlinks resolved) where the real
1832
+ // filesystem can confirm it -- isProtectedPath's own case-insensitive
1833
+ // comparison (see its doc comment) is the fallback for whatever a
1834
+ // not-yet-existing path can't be canonicalized against.
1835
+ const projectDir = canonicalize(
1836
+ process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
1837
+ );
1838
+ // Only an ABSOLUTE filePath has a filesystem anchor worth canonicalizing --
1839
+ // canonicalize() resolves a relative path against this hook's own cwd,
1840
+ // which is not necessarily the project the write actually targets, and
1841
+ // isProtectedPath already matches a relative filePath as-is (see its doc
1842
+ // comment). Leaving a relative filePath unresolved here keeps that
1843
+ // contract instead of silently changing what it's compared against.
1844
+ const scopedFilePath =
1845
+ filePath && isAbsoluteLike(filePath) ? canonicalize(filePath) : filePath;
1846
+ if (!shouldBlockHubSrcWrite(scopedFilePath, agentType, projectDir)) {
76
1847
  process.exit(0);
77
1848
  }
78
- const filePath = input.tool_input?.file_path ?? "";
79
- const agentType = input.agent_type;
80
- if (!shouldBlockHubSrcWrite(filePath, agentType)) process.exit(0);
81
1849
  process.stderr.write(
82
1850
  "guard-hub-src-writes: Hub-authored write to a guarded path detected.\n" +
83
1851
  ` Path: ${filePath}\n` +
@@ -86,3 +1854,87 @@ if (isEntryPoint()) {
86
1854
  );
87
1855
  process.exit(2);
88
1856
  }
1857
+
1858
+ function runBashGuard(input) {
1859
+ const command = input.tool_input?.command;
1860
+ if (typeof command !== "string") {
1861
+ process.stderr.write(
1862
+ `guard-hub-src-writes: Bash payload has no string tool_input.command (got ${typeof command}); allowing without analysis.\n`,
1863
+ );
1864
+ process.exit(0);
1865
+ }
1866
+ const notes = new Set();
1867
+ let hit;
1868
+ try {
1869
+ // Both anchors canonicalized, so a payload cwd spelled through a
1870
+ // symlink (macOS's /var -> /private/var) still lands inside projectDir.
1871
+ const cwd = canonicalize(
1872
+ typeof input.cwd === "string" && input.cwd !== ""
1873
+ ? input.cwd
1874
+ : process.cwd(),
1875
+ );
1876
+ const projectDir = canonicalize(process.env.CLAUDE_PROJECT_DIR ?? cwd);
1877
+ hit = shouldBlockHubBashWrite(command, spokeAgentType(input), {
1878
+ cwd,
1879
+ projectDir,
1880
+ notes,
1881
+ });
1882
+ } catch (error) {
1883
+ // Fail open -- a detector bug must never wedge every Bash call -- but
1884
+ // say so on stderr, with the stack, rather than allowing silently.
1885
+ process.stderr.write(
1886
+ `guard-hub-src-writes: Bash analysis failed (detector bug), allowing the command:\n${
1887
+ error instanceof Error ? (error.stack ?? error.message) : String(error)
1888
+ }\n`,
1889
+ );
1890
+ process.exit(0);
1891
+ }
1892
+ if (!hit) {
1893
+ if (notes.size > 0) {
1894
+ process.stderr.write(
1895
+ `guard-hub-src-writes: allowed, but not fully analysed: ${[...notes].join("; ")}\n`,
1896
+ );
1897
+ }
1898
+ process.exit(0);
1899
+ }
1900
+ const what = hit.ancestor
1901
+ ? "writes, removes or replaces an ancestor of a guarded path (a directory with a guarded src/tests tree beneath it)"
1902
+ : "writes to a guarded path";
1903
+ process.stderr.write(
1904
+ `guard-hub-src-writes: Hub-authored Bash command ${what}.\n` +
1905
+ ` Path: ${hit.path}\n` +
1906
+ ` Rule: ${hit.rule}\n` +
1907
+ " Why: hub-and-spoke -- a Bash write into a guarded src/tests path is refused for every caller except the writer spokes, the same allowlist as Write/Edit.\n" +
1908
+ " Fix: dispatch the change to 'code-implementer' (src/**) or 'test-author' (tests/**).\n" +
1909
+ " Override (maintainer): run the command yourself with the `!` prefix at the Claude Code prompt (not a tool call, so no hook runs), or edit/remove this hook's registration in .claude/settings.json.\n" +
1910
+ " See: CLAUDE.md's Agent Operating Model.\n",
1911
+ );
1912
+ process.exit(2);
1913
+ }
1914
+
1915
+ // Only run when invoked directly, not when imported for testing.
1916
+ if (isEntryPoint()) {
1917
+ const chunks = [];
1918
+ for await (const chunk of process.stdin) chunks.push(chunk);
1919
+ let input;
1920
+ try {
1921
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1922
+ } catch (error) {
1923
+ process.stderr.write(
1924
+ `guard-hub-src-writes: unparseable hook payload (${errorText(error)}); allowing without analysis.\n`,
1925
+ );
1926
+ process.exit(0);
1927
+ }
1928
+ if (input === null || typeof input !== "object") {
1929
+ process.stderr.write(
1930
+ "guard-hub-src-writes: unparseable hook payload (not a JSON object); allowing without analysis.\n",
1931
+ );
1932
+ process.exit(0);
1933
+ }
1934
+ const isBash =
1935
+ input.tool_name === "Bash" ||
1936
+ (input.tool_name === undefined &&
1937
+ typeof input.tool_input?.command === "string");
1938
+ if (isBash) runBashGuard(input);
1939
+ else runWriteGuard(input);
1940
+ }