@monte3l/groundwork 0.1.0-next.1 → 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.
- package/README.md +16 -8
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /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
|
|
4
|
-
* source
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* spoke
|
|
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)
|
|
19
|
-
*
|
|
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 (
|
|
23
|
-
* identically -- both are blocked from guarded paths. Writer
|
|
24
|
-
* allowed through. All other paths are allowed through
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
WRITER_SPOKES.has(agentType)
|
|
701
|
+
!protectedPathString(entry, ctx, base) &&
|
|
702
|
+
!protectedPathString(placeholders, ctx, base)
|
|
49
703
|
) {
|
|
50
|
-
return
|
|
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
|
|
714
|
+
return null;
|
|
53
715
|
}
|
|
54
716
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|