@monte3l/groundwork 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/bin/m3l-groundwork.mjs +10 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +79 -0
- package/dist/caps.d.ts +25 -0
- package/dist/caps.js +69 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.js +77 -0
- package/dist/emit.d.ts +7 -0
- package/dist/emit.js +42 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +9 -0
- package/dist/harness/conformance.d.ts +20 -0
- package/dist/harness/conformance.js +18 -0
- package/dist/harness/frontmatter.d.ts +38 -0
- package/dist/harness/frontmatter.js +204 -0
- package/dist/harness/grade.d.ts +4 -0
- package/dist/harness/grade.js +105 -0
- package/dist/harness/rules.d.ts +55 -0
- package/dist/harness/rules.js +580 -0
- package/dist/harness/types.d.ts +32 -0
- package/dist/harness/types.js +9 -0
- package/dist/inventory.d.ts +63 -0
- package/dist/inventory.js +66 -0
- package/dist/jsonc.d.ts +14 -0
- package/dist/jsonc.js +83 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +297 -0
- package/dist/merge-json.d.ts +74 -0
- package/dist/merge-json.js +135 -0
- package/dist/mode.d.ts +19 -0
- package/dist/mode.js +53 -0
- package/dist/packs.d.ts +61 -0
- package/dist/packs.js +186 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +79 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +323 -0
- package/dist/survey/fs-walk.d.ts +14 -0
- package/dist/survey/fs-walk.js +60 -0
- package/dist/survey/survey-docs.d.ts +4 -0
- package/dist/survey/survey-docs.js +69 -0
- package/dist/survey/survey-harness.d.ts +4 -0
- package/dist/survey/survey-harness.js +121 -0
- package/dist/survey/survey-shape.d.ts +4 -0
- package/dist/survey/survey-shape.js +182 -0
- package/dist/survey/survey-toolchain.d.ts +4 -0
- package/dist/survey/survey-toolchain.js +217 -0
- package/dist/survey/survey.d.ts +5 -0
- package/dist/survey/survey.js +21 -0
- package/dist/survey/types.d.ts +117 -0
- package/dist/survey/types.js +8 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +13 -0
- package/dist/toolchain/conformance.d.ts +20 -0
- package/dist/toolchain/conformance.js +30 -0
- package/dist/toolchain/grade.d.ts +4 -0
- package/dist/toolchain/grade.js +244 -0
- package/dist/toolchain/rules.d.ts +118 -0
- package/dist/toolchain/rules.js +706 -0
- package/dist/toolchain/tsconfig-chain.d.ts +36 -0
- package/dist/toolchain/tsconfig-chain.js +116 -0
- package/dist/toolchain/types.d.ts +27 -0
- package/dist/toolchain/types.js +9 -0
- package/package.json +59 -0
- package/plugin/skills/customize/SKILL.md +305 -0
- package/plugin/src/domain-map.ts +134 -0
- package/plugin/src/index.ts +4 -0
- package/plugin/src/kind-facet-map.ts +174 -0
- package/plugin/src/pack-map.ts +65 -0
- package/templates/core/.claude/agents/Explore.md +43 -0
- package/templates/core/.claude/agents/code-implementer.md +258 -0
- package/templates/core/.claude/agents/code-reviewer.md +163 -0
- package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
- package/templates/core/.claude/agents/test-author.md +211 -0
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
- package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
- package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
- package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
- package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
- package/templates/core/.claude/rules/agent-dispatch.md +121 -0
- package/templates/core/.claude/rules/refactoring.md +52 -0
- package/templates/core/.claude/rules/src.md +114 -0
- package/templates/core/.claude/rules/tests.md +129 -0
- package/templates/core/.claude/settings.json +111 -0
- package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
- package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
- package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
- package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
- package/templates/core/.github/workflows/ci.yml +123 -0
- package/templates/core/.github/workflows/dependency-review.yml +26 -0
- package/templates/core/.github/workflows/security-audit.yml +54 -0
- package/templates/core/.node-version +1 -0
- package/templates/core/.prettierignore +5 -0
- package/templates/core/.prettierrc.json +4 -0
- package/templates/core/CLAUDE.md +127 -0
- package/templates/core/README.md +24 -0
- package/templates/core/_gitignore +19 -0
- package/templates/core/_npmrc +1 -0
- package/templates/core/bin/check-exports.mjs +92 -0
- package/templates/core/bin/check-harness.mjs +27 -0
- package/templates/core/bin/check-node-version.mjs +51 -0
- package/templates/core/bin/check-toolchain.mjs +20 -0
- package/templates/core/bin/lib/agent-roster.mjs +8 -0
- package/templates/core/bin/lib/frontmatter.mjs +210 -0
- package/templates/core/bin/lib/harness-rules.mjs +916 -0
- package/templates/core/bin/lib/protected-paths.mjs +23 -0
- package/templates/core/bin/lib/report.mjs +56 -0
- package/templates/core/bin/lib/signed-range.mjs +178 -0
- package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
- package/templates/core/bin/lib/verify-steps.mjs +131 -0
- package/templates/core/bin/lib/verify-steps.packs.json +1 -0
- package/templates/core/bin/lint-commit.mjs +50 -0
- package/templates/core/bin/strip-claude-trailers.mjs +25 -0
- package/templates/core/bin/verify.mjs +64 -0
- package/templates/core/commitlint.config.js +11 -0
- package/templates/core/docs/research/harness-refresh.md +27 -0
- package/templates/core/docs/research/typescript-refresh.md +32 -0
- package/templates/core/eslint.config.js +105 -0
- package/templates/core/knip.json +6 -0
- package/templates/core/lefthook.yml +39 -0
- package/templates/core/package.json +58 -0
- package/templates/core/pnpm-workspace.yaml +13 -0
- package/templates/core/src/index.ts +12 -0
- package/templates/core/tests/index.test.ts +8 -0
- package/templates/core/tsconfig.base.json +36 -0
- package/templates/core/tsconfig.build.json +10 -0
- package/templates/core/tsconfig.json +11 -0
- package/templates/core/vitest.config.ts +32 -0
- package/templates/packs/README.md +81 -0
- package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
- package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
- package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
- package/templates/packs/harness-extras/pack.json +65 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
- package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
- package/templates/packs/statusline/pack.json +31 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Bash): block a command that combines `run_in_background:
|
|
4
|
+
* true` with a shell-level detach construct (`nohup`, `disown`, or a trailing
|
|
5
|
+
* `&`) in the same call.
|
|
6
|
+
*
|
|
7
|
+
* The two mechanisms both try to survive process/session churn, but stacking
|
|
8
|
+
* them produces a false "completed" report instead of a working one: the
|
|
9
|
+
* harness's own background-job tracking loses track of a process it never
|
|
10
|
+
* actually owns (the shell already detached it), so a poll against the
|
|
11
|
+
* harness-tracked job reports done while the real work is either still
|
|
12
|
+
* running unobserved or was silently killed along with the polling wrapper.
|
|
13
|
+
* Pick exactly one detachment mechanism per call: `run_in_background: true`
|
|
14
|
+
* alone (the harness tracks it), or a plain foreground command wrapped in
|
|
15
|
+
* `nohup <cmd> > <log> 2>&1 & disown` with the polling done separately
|
|
16
|
+
* against the raw PID (`kill -0 $PID`) -- never both on the same invocation.
|
|
17
|
+
*
|
|
18
|
+
* Fail-open on everything ambiguous: a malformed payload, a missing/
|
|
19
|
+
* non-boolean `run_in_background`, or a command with no detach construct all
|
|
20
|
+
* exit 0. This is a DENYLIST of the three known detach constructs, not a
|
|
21
|
+
* strict parse of the shell grammar -- extend `hasShellDetach` as new gaps
|
|
22
|
+
* are found rather than reaching for a real shell parser.
|
|
23
|
+
*/
|
|
24
|
+
import process from "node:process";
|
|
25
|
+
import { realpathSync } from "node:fs";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Does `command` contain a shell-level detach construct: `nohup`, `disown`,
|
|
30
|
+
* or a trailing background `&`?
|
|
31
|
+
*
|
|
32
|
+
* Both checks are anchored to avoid matching ordinary argument text, not
|
|
33
|
+
* just shell operators:
|
|
34
|
+
*
|
|
35
|
+
* - `nohup`/`disown` only count in COMMAND POSITION -- the start of the
|
|
36
|
+
* whole string, or right after a command separator (`;`, `&`, `|`, `(`).
|
|
37
|
+
* An unanchored `\bnohup\b` would also fire on `grep -n nohup file.txt`
|
|
38
|
+
* (searching FOR the word) -- matching argument text, not an invocation.
|
|
39
|
+
* - the bare `&` check excludes `&&` (logical AND), both fd-duplication
|
|
40
|
+
* redirect spellings -- `2>&1` (a `&` immediately preceded by `>`) and
|
|
41
|
+
* `&>`/`&>>` (bash's combined-redirect shorthand for `> file 2>&1`) -- and
|
|
42
|
+
* requires the `&` to sit at a command boundary (end-of-command or
|
|
43
|
+
* followed by whitespace), so an embedded query-string `&`
|
|
44
|
+
* (`...100&page=2`) isn't misread as backgrounding.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} command
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function hasShellDetach(command) {
|
|
50
|
+
if (/(?:^|[;&|(])\s*(?:nohup|disown)\b/.test(command)) return true;
|
|
51
|
+
return /(?<![&>])&(?![&>\S])/.test(command);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pure decision function -- exported for unit testing. Every ambiguous or
|
|
56
|
+
* non-applicable input returns `false` (allow), and only one confirmed
|
|
57
|
+
* verdict returns `true` (block).
|
|
58
|
+
*
|
|
59
|
+
* @param {string} command
|
|
60
|
+
* @param {unknown} runInBackground raw `tool_input.run_in_background` value
|
|
61
|
+
* @returns {boolean} true = block, false = allow
|
|
62
|
+
*/
|
|
63
|
+
export function shouldBlockDoubleBackground(command, runInBackground) {
|
|
64
|
+
if (runInBackground !== true) return false;
|
|
65
|
+
if (typeof command !== "string" || command.length === 0) return false;
|
|
66
|
+
return hasShellDetach(command);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readStdin() {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
72
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Deliberately inlined in every hook rather than shared: caps.ts counts
|
|
76
|
+
// .claude/hooks/*.mjs files against a hard limit, so a helper module would
|
|
77
|
+
// cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
|
|
78
|
+
// is not, so comparing them directly is false under any symlinked path and the
|
|
79
|
+
// guard body would never run -- exit 0, i.e. fail open.
|
|
80
|
+
function isEntryPoint() {
|
|
81
|
+
try {
|
|
82
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only run when invoked directly, not when imported for testing.
|
|
89
|
+
if (isEntryPoint()) {
|
|
90
|
+
const raw = await readStdin();
|
|
91
|
+
let input;
|
|
92
|
+
try {
|
|
93
|
+
input = JSON.parse(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const command = input.tool_input?.command;
|
|
99
|
+
const runInBackground = input.tool_input?.run_in_background;
|
|
100
|
+
|
|
101
|
+
if (!shouldBlockDoubleBackground(command, runInBackground)) process.exit(0);
|
|
102
|
+
|
|
103
|
+
process.stderr.write(`\
|
|
104
|
+
[guard-double-background] Blocked: this command combines \`run_in_background:
|
|
105
|
+
true\` with a shell-level detach construct (\`nohup\`, \`disown\`, or a
|
|
106
|
+
trailing \`&\`). Stacking both risks a false "completed" report -- the
|
|
107
|
+
harness loses track of a process the shell already detached. Pick exactly one:
|
|
108
|
+
- \`run_in_background: true\` alone, polled via TaskOutput/Monitor, or
|
|
109
|
+
- a foreground \`nohup <cmd> > <log> 2>&1 & disown\`, polled separately
|
|
110
|
+
against the raw PID (\`kill -0 $PID\`) -- with run_in_background left false.
|
|
111
|
+
`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Bash): block a `git push` issued through the agent's Bash
|
|
4
|
+
* tool when any outgoing commit is unsigned or has an invalid signature.
|
|
5
|
+
*
|
|
6
|
+
* This is the FIRST Bash-matcher hook in the baseline -- every other
|
|
7
|
+
* PreToolUse hook inspects `tool_input.file_path`; this one inspects
|
|
8
|
+
* `tool_input.command`.
|
|
9
|
+
*
|
|
10
|
+
* Opt-in, not always-on: `signingEnabled()` (bin/lib/signed-range.mjs) checks
|
|
11
|
+
* whether THIS machine's git is configured to sign commits at all
|
|
12
|
+
* (`commit.gpgsign`). A project whose author hasn't set up commit signing
|
|
13
|
+
* gets no enforcement here -- the alternative (enforcing by default) would
|
|
14
|
+
* hard-block every push on a machine with no GPG/SSH signing key configured,
|
|
15
|
+
* which is not a safe default for a freshly bootstrapped project. Turn
|
|
16
|
+
* signing on (`git config commit.gpgsign true`) and this guard turns on with
|
|
17
|
+
* it, no other configuration step.
|
|
18
|
+
*
|
|
19
|
+
* Fail-open by design (matching every sibling hook): a malformed payload, a
|
|
20
|
+
* non-push command, signing not enabled, or a git failure all exit 0. The
|
|
21
|
+
* literal-signature verdict is the only thing that blocks (exit 2). Pair
|
|
22
|
+
* this with your remote's own "require signed commits" branch-protection
|
|
23
|
+
* setting for the authoritative, unbypassable layer -- this hook is only the
|
|
24
|
+
* earlier, local catch.
|
|
25
|
+
*/
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
import { realpathSync } from "node:fs";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import {
|
|
30
|
+
parseGitPush,
|
|
31
|
+
outgoingCommits,
|
|
32
|
+
unsignedCommits,
|
|
33
|
+
signingEnabled,
|
|
34
|
+
} from "../../bin/lib/signed-range.mjs";
|
|
35
|
+
|
|
36
|
+
async function readStdin() {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
39
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Deliberately inlined in every hook rather than shared: caps.ts counts
|
|
43
|
+
// .claude/hooks/*.mjs files against a hard limit, so a helper module would
|
|
44
|
+
// cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
|
|
45
|
+
// is not, so comparing them directly is false under any symlinked path and the
|
|
46
|
+
// guard body would never run -- exit 0, i.e. fail open.
|
|
47
|
+
function isEntryPoint() {
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only run when invoked directly, not when imported for testing.
|
|
56
|
+
if (isEntryPoint()) {
|
|
57
|
+
const raw = await readStdin();
|
|
58
|
+
let input;
|
|
59
|
+
try {
|
|
60
|
+
input = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const command = input.tool_input?.command;
|
|
66
|
+
const { isPush, dryRun } = parseGitPush(
|
|
67
|
+
typeof command === "string" ? command : "",
|
|
68
|
+
);
|
|
69
|
+
if (!isPush || dryRun) process.exit(0);
|
|
70
|
+
if (!signingEnabled()) process.exit(0);
|
|
71
|
+
|
|
72
|
+
let bad;
|
|
73
|
+
try {
|
|
74
|
+
bad = unsignedCommits(outgoingCommits());
|
|
75
|
+
} catch {
|
|
76
|
+
process.exit(0); // cannot determine range -> defer to remote branch protection
|
|
77
|
+
}
|
|
78
|
+
if (bad.length === 0) process.exit(0);
|
|
79
|
+
|
|
80
|
+
process.stderr.write(`\
|
|
81
|
+
[guard-git-push-signed] Blocked: refusing to push unsigned/unverified commits.
|
|
82
|
+
${bad.map(({ sha, code }) => ` - ${sha.slice(0, 12)} (%G? = ${code})`).join("\n")}
|
|
83
|
+
|
|
84
|
+
commit.gpgsign is enabled on this machine, so every commit pushed to the
|
|
85
|
+
remote must carry a valid signature. Re-sign the range, e.g.:
|
|
86
|
+
git rebase --exec 'git commit --amend --no-edit -S' origin/main
|
|
87
|
+
Then retry the push.
|
|
88
|
+
`);
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Write|Edit): blocks hub-authored writes into guarded
|
|
4
|
+
* source and test paths on ANY branch.
|
|
5
|
+
*
|
|
6
|
+
* Problem: `guard-branch-isolation.mjs` only fires while `HEAD` is `main`.
|
|
7
|
+
* On a feature branch nothing else stops the hub itself from writing
|
|
8
|
+
* directly into a guarded path instead of dispatching the write to a writer
|
|
9
|
+
* spoke, as CLAUDE.md's Agent Operating Model requires.
|
|
10
|
+
*
|
|
11
|
+
* The seam: the PreToolUse payload carries a top-level `agent_type` field
|
|
12
|
+
* when the tool call fires inside a subagent context. The field is absent
|
|
13
|
+
* (or empty) for hub-level calls, and contains the subagent's name for
|
|
14
|
+
* spoke calls.
|
|
15
|
+
*
|
|
16
|
+
* The decision: block when BOTH conditions hold:
|
|
17
|
+
* (a) the target path is a guarded source/test path, AND
|
|
18
|
+
* (b) `agent_type` is NOT the name of an authorised writer spoke
|
|
19
|
+
* (`code-implementer` or `test-author`, per WRITER_SPOKES in
|
|
20
|
+
* bin/lib/agent-roster.mjs).
|
|
21
|
+
*
|
|
22
|
+
* Hub calls (absent/empty agent_type) and non-writer subagents are treated
|
|
23
|
+
* identically -- both are blocked from guarded paths. Writer spokes are
|
|
24
|
+
* allowed through. All other paths are allowed through unconditionally.
|
|
25
|
+
*
|
|
26
|
+
* Fail-open: an unparseable payload or missing file_path exits 0 so a
|
|
27
|
+
* malformed hook input never wedges the session.
|
|
28
|
+
*/
|
|
29
|
+
import process from "node:process";
|
|
30
|
+
import { realpathSync } from "node:fs";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { isProtectedPath } from "../../bin/lib/protected-paths.mjs";
|
|
33
|
+
import { WRITER_SPOKES } from "../../bin/lib/agent-roster.mjs";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure decision function -- exported for unit testing.
|
|
37
|
+
*
|
|
38
|
+
* @param {string | undefined} filePath The file_path from the tool_input payload.
|
|
39
|
+
* @param {unknown} agentType The top-level agent_type from the payload.
|
|
40
|
+
* @returns {boolean} true = block, false = allow.
|
|
41
|
+
*/
|
|
42
|
+
export function shouldBlockHubSrcWrite(filePath, agentType) {
|
|
43
|
+
if (!filePath || typeof filePath !== "string") return false;
|
|
44
|
+
if (!isProtectedPath(filePath)) return false;
|
|
45
|
+
if (
|
|
46
|
+
typeof agentType === "string" &&
|
|
47
|
+
agentType.length > 0 &&
|
|
48
|
+
WRITER_SPOKES.has(agentType)
|
|
49
|
+
) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Deliberately inlined in every hook rather than shared: caps.ts counts
|
|
56
|
+
// .claude/hooks/*.mjs files against a hard limit, so a helper module would
|
|
57
|
+
// cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
|
|
58
|
+
// is not, so comparing them directly is false under any symlinked path and the
|
|
59
|
+
// guard body would never run -- exit 0, i.e. fail open.
|
|
60
|
+
function isEntryPoint() {
|
|
61
|
+
try {
|
|
62
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Only run when invoked directly, not when imported for testing.
|
|
69
|
+
if (isEntryPoint()) {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
72
|
+
let input;
|
|
73
|
+
try {
|
|
74
|
+
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
const filePath = input.tool_input?.file_path ?? "";
|
|
79
|
+
const agentType = input.agent_type;
|
|
80
|
+
if (!shouldBlockHubSrcWrite(filePath, agentType)) process.exit(0);
|
|
81
|
+
process.stderr.write(
|
|
82
|
+
"guard-hub-src-writes: Hub-authored write to a guarded path detected.\n" +
|
|
83
|
+
` Path: ${filePath}\n` +
|
|
84
|
+
" Dispatch the write to 'code-implementer' (src/**) or 'test-author' (tests/**) instead.\n" +
|
|
85
|
+
" See: CLAUDE.md's Agent Operating Model.\n",
|
|
86
|
+
);
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Write|Edit): blocks writing a relative import that is
|
|
4
|
+
* missing the `.js` extension.
|
|
5
|
+
*
|
|
6
|
+
* This is the #1 documented ESM gotcha -- a relative import without `.js`
|
|
7
|
+
* type-checks but fails to resolve at runtime in Node. Path-scoped rules
|
|
8
|
+
* inject on Read, not reliably at Write, so this runs as a hook.
|
|
9
|
+
*
|
|
10
|
+
* Blocks by exiting 2 with a message on stderr (Claude Code convention).
|
|
11
|
+
*/
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
|
|
14
|
+
const ALLOWED = [".js", ".mjs", ".cjs", ".json", ".node"];
|
|
15
|
+
|
|
16
|
+
/** Extract the content being written from the tool input. */
|
|
17
|
+
function contentToCheck(input) {
|
|
18
|
+
const ti = input.tool_input ?? {};
|
|
19
|
+
// Write -> content; Edit -> new_string (the text being introduced).
|
|
20
|
+
return [ti.content, ti.new_string].filter((s) => typeof s === "string");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Find relative import/export specifiers missing an allowed extension. */
|
|
24
|
+
function offendingSpecifiers(source) {
|
|
25
|
+
const offenders = [];
|
|
26
|
+
// Matches: from "./x", from '../y', import("./z"), require("./w")
|
|
27
|
+
const re = /\b(?:from|import|require)\s*\(?\s*["'](\.\.?\/[^"']*)["']/g;
|
|
28
|
+
let m;
|
|
29
|
+
while ((m = re.exec(source)) !== null) {
|
|
30
|
+
const spec = m[1];
|
|
31
|
+
const hasAllowed = ALLOWED.some((ext) => spec.endsWith(ext));
|
|
32
|
+
if (!hasAllowed) offenders.push(spec);
|
|
33
|
+
}
|
|
34
|
+
return offenders;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readStdin() {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
40
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const raw = await readStdin();
|
|
44
|
+
let input;
|
|
45
|
+
try {
|
|
46
|
+
input = JSON.parse(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
process.exit(0); // Not our concern; let the call through.
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const filePath = input.tool_input?.file_path ?? "";
|
|
52
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(filePath)) process.exit(0);
|
|
53
|
+
|
|
54
|
+
const offenders = contentToCheck(input).flatMap(offendingSpecifiers);
|
|
55
|
+
if (offenders.length > 0) {
|
|
56
|
+
const unique = [...new Set(offenders)];
|
|
57
|
+
process.stderr.write(
|
|
58
|
+
`Blocked: relative import(s) missing the required \`.js\` extension ` +
|
|
59
|
+
`(ESM + NodeNext will fail to resolve at runtime):\n` +
|
|
60
|
+
unique.map((s) => ` - "${s}" -> "${s}.js"`).join("\n") +
|
|
61
|
+
`\nAdd the \`.js\` extension to every relative import.\n`,
|
|
62
|
+
);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.exit(0);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Write|Edit): blocks CommonJS constructs in TypeScript
|
|
4
|
+
* source. This project is ESM only ("type": "module"); `require`,
|
|
5
|
+
* `module.exports`, `exports.`, `__dirname`, and `__filename` are forbidden.
|
|
6
|
+
*
|
|
7
|
+
* Blocks by exiting 2 with a message on stderr.
|
|
8
|
+
*/
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const PATTERNS = [
|
|
14
|
+
{ re: /\brequire\s*\(/, label: "require(...)" },
|
|
15
|
+
{ re: /\bmodule\.exports\b/, label: "module.exports" },
|
|
16
|
+
// A bare \b also fires inside kebab-case filenames like
|
|
17
|
+
// "check-doc-exports.mjs" (hyphen -> letter is a word-boundary
|
|
18
|
+
// transition). Excluding a preceding identifier char or hyphen keeps real
|
|
19
|
+
// `exports.foo = ...` assignments blocked while letting mentions of any
|
|
20
|
+
// "*-exports.<ext>" bin script through.
|
|
21
|
+
{ re: /(?<![\w-])exports\.[A-Za-z_$]/, label: "exports.<name>" },
|
|
22
|
+
{ re: /\b__dirname\b/, label: "__dirname" },
|
|
23
|
+
{ re: /\b__filename\b/, label: "__filename" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const SOURCE_EXT_RE = /\.(ts|tsx|mts|cts|js|mjs)$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when `filePath` is TS/JS source this guard should inspect -- not a
|
|
30
|
+
* config file, doc, or a file under this hook's own directory.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} filePath
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isGuardedFilePath(filePath) {
|
|
36
|
+
if (!SOURCE_EXT_RE.test(filePath)) return false;
|
|
37
|
+
// Normalize separators first: Windows paths use "\", and a literal "/"
|
|
38
|
+
// check silently never matches there, leaving this hook unable to exempt
|
|
39
|
+
// its own directory on that platform.
|
|
40
|
+
if (filePath.replace(/\\/g, "/").includes("/.claude/hooks/")) return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scan `content` for forbidden CommonJS constructs. Returns a human-readable
|
|
46
|
+
* label per match (empty array when clean).
|
|
47
|
+
*
|
|
48
|
+
* @param {string} content
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function findCommonJsHits(content) {
|
|
52
|
+
return PATTERNS.filter((p) => p.re.test(content)).map((p) => p.label);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function contentToCheck(input) {
|
|
56
|
+
const ti = input.tool_input ?? {};
|
|
57
|
+
return [ti.content, ti.new_string].filter((s) => typeof s === "string");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readStdin() {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
63
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deliberately inlined in every hook rather than shared: caps.ts counts
|
|
67
|
+
// .claude/hooks/*.mjs files against a hard limit, so a helper module would
|
|
68
|
+
// cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
|
|
69
|
+
// is not, so comparing them directly is false under any symlinked path and the
|
|
70
|
+
// guard body would never run -- exit 0, i.e. fail open.
|
|
71
|
+
function isEntryPoint() {
|
|
72
|
+
try {
|
|
73
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Main execution -- only run when invoked directly, not when imported for testing.
|
|
80
|
+
if (isEntryPoint()) {
|
|
81
|
+
const raw = await readStdin();
|
|
82
|
+
let input;
|
|
83
|
+
try {
|
|
84
|
+
input = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const filePath = input.tool_input?.file_path ?? "";
|
|
90
|
+
if (!isGuardedFilePath(filePath)) process.exit(0);
|
|
91
|
+
|
|
92
|
+
const source = contentToCheck(input).join("\n");
|
|
93
|
+
const hits = findCommonJsHits(source);
|
|
94
|
+
if (hits.length > 0) {
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`Blocked: CommonJS construct(s) found (this project is ESM only):\n` +
|
|
97
|
+
hits.map((h) => ` - ${h}`).join("\n") +
|
|
98
|
+
`\nUse ESM equivalents: import/export, import.meta.url, ` +
|
|
99
|
+
`fileURLToPath(import.meta.url).\n`,
|
|
100
|
+
);
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Write|Edit): protects tool-owned artifacts.
|
|
4
|
+
*
|
|
5
|
+
* - `dist/**` is tsc output and must never be hand-edited.
|
|
6
|
+
* - `coverage/**` is the vitest v8 coverage report and must never be
|
|
7
|
+
* hand-edited.
|
|
8
|
+
*
|
|
9
|
+
* Add another generated-output directory to PROTECTED_DIR_NAMES below if
|
|
10
|
+
* this project introduces one (e.g. a bundler's own output dir).
|
|
11
|
+
*
|
|
12
|
+
* Blocks by exiting 2 with a message on stderr.
|
|
13
|
+
*/
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
|
|
16
|
+
const PROTECTED_DIR_NAMES = ["dist", "coverage"];
|
|
17
|
+
|
|
18
|
+
async function readStdin() {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
21
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const raw = await readStdin();
|
|
25
|
+
let input;
|
|
26
|
+
try {
|
|
27
|
+
input = JSON.parse(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const filePath = input.tool_input?.file_path ?? "";
|
|
33
|
+
|
|
34
|
+
const hit = PROTECTED_DIR_NAMES.find((name) =>
|
|
35
|
+
new RegExp(`(^|/)${name}/`).test(filePath),
|
|
36
|
+
);
|
|
37
|
+
if (hit) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`Blocked: \`${hit}/\` is generated output and must never be ` +
|
|
40
|
+
`hand-edited. Change the source and rebuild/retest instead.\n`,
|
|
41
|
+
);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.exit(0);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse blocking (Write|Edit): refuse to write a real secret to disk.
|
|
4
|
+
*
|
|
5
|
+
* CLAUDE.md's Security section requires that no secret or token ever land in
|
|
6
|
+
* source, tests, or fixtures. A CI secret-scanning step is the backstop;
|
|
7
|
+
* this hook is the earlier, write-time block so a credential never reaches
|
|
8
|
+
* the working tree -- where it would linger in git reflog/objects even
|
|
9
|
+
* after a fix.
|
|
10
|
+
*
|
|
11
|
+
* Two independent triggers, both hard-block (exit 2):
|
|
12
|
+
* 1. Path: a dotenv file (`.env`, `.env.local`, ...) -- these are
|
|
13
|
+
* gitignored secret stores and should never be authored by the agent.
|
|
14
|
+
* Template variants (`.env.example`/`.sample`/`.template`/`.dist`) are
|
|
15
|
+
* allowed.
|
|
16
|
+
* 2. Content: a known secret-key assignment carrying a *real* value, or a
|
|
17
|
+
* recognised high-entropy token/private-key literal. References and
|
|
18
|
+
* placeholders (`${{ secrets.X }}`, `process.env.X`, `<your-token>`, ...)
|
|
19
|
+
* are NOT flagged -- the point is to catch values, not names.
|
|
20
|
+
*
|
|
21
|
+
* Detection is deliberately conservative: prefix-anchored token shapes and
|
|
22
|
+
* placeholder exclusion keep false positives off legitimate config/docs
|
|
23
|
+
* (e.g. `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` in a workflow).
|
|
24
|
+
*/
|
|
25
|
+
import process from "node:process";
|
|
26
|
+
import { realpathSync } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
async function readStdin() {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
33
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Env-var-name prefixes/exact names whose assigned *value* is a secret we
|
|
38
|
+
* must never persist. Project-specific keys can be appended here.
|
|
39
|
+
*/
|
|
40
|
+
export const SECRET_KEYS = [
|
|
41
|
+
"NPM_TOKEN",
|
|
42
|
+
"GITHUB_TOKEN",
|
|
43
|
+
"GH_TOKEN",
|
|
44
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
45
|
+
"AWS_ACCESS_KEY_ID",
|
|
46
|
+
"AWS_SESSION_TOKEN",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// High-signal literal shapes -- a match is almost certainly a real credential.
|
|
50
|
+
const TOKEN_LITERALS = [
|
|
51
|
+
/\bghp_[A-Za-z0-9]{36}\b/, // GitHub personal access token
|
|
52
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,}\b/, // GitHub fine-grained PAT
|
|
53
|
+
/\bnpm_[A-Za-z0-9]{36}\b/, // npm automation token
|
|
54
|
+
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key id
|
|
55
|
+
/-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, // PEM private key
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A value is a placeholder/reference, not a real secret, when it defers to an
|
|
60
|
+
* env var or CI secret, or is an obvious dummy. Such values must not be flagged.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} value
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function isPlaceholderValue(value) {
|
|
66
|
+
const v = value.trim().replace(/^["']|["']$/g, "");
|
|
67
|
+
if (v.length === 0) return true;
|
|
68
|
+
if (/\$\{|\$\(|process\.env|secrets\.|env\./.test(v)) return true; // reference
|
|
69
|
+
if (/^<.*>$/.test(v)) return true; // <your-token>
|
|
70
|
+
if (
|
|
71
|
+
/(example|placeholder|changeme|your[-_]|xxx+|dummy|fake|redacted)/i.test(v)
|
|
72
|
+
)
|
|
73
|
+
return true;
|
|
74
|
+
// Too short / low-entropy to be a real token.
|
|
75
|
+
return v.length < 16;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True when the basename is a real dotenv file (not a committed template).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} filePath
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function isEnvFilePath(filePath) {
|
|
85
|
+
const base = path.basename(filePath);
|
|
86
|
+
if (!/^\.env(\.|$)/.test(base)) return false;
|
|
87
|
+
return !/\.(example|sample|template|dist)$/.test(base);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Scan `content` for secret-key assignments carrying a real value or for a
|
|
92
|
+
* recognised token/private-key literal. Returns a human-readable reason per
|
|
93
|
+
* match (empty array when clean).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} content
|
|
96
|
+
* @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
export function findSecrets(content) {
|
|
99
|
+
const hits = [];
|
|
100
|
+
|
|
101
|
+
for (const key of SECRET_KEYS) {
|
|
102
|
+
// KEY = value or KEY: value (env/yaml/json-ish), value runs to EOL.
|
|
103
|
+
const re = new RegExp(`\\b${key}\\b\\s*[:=]\\s*(.+)`, "g");
|
|
104
|
+
for (const m of content.matchAll(re)) {
|
|
105
|
+
if (!isPlaceholderValue(m[1]))
|
|
106
|
+
hits.push(`${key} assigned a literal value`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const re of TOKEN_LITERALS) {
|
|
111
|
+
if (re.test(content))
|
|
112
|
+
hits.push(`recognised token/key literal (${re.source})`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return hits;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Combined verdict for a write. Returns the block reasons (empty when allowed).
|
|
120
|
+
*
|
|
121
|
+
* @param {string} filePath
|
|
122
|
+
* @param {string} content
|
|
123
|
+
* @returns {string[]}
|
|
124
|
+
*/
|
|
125
|
+
export function isSecretWrite(filePath, content) {
|
|
126
|
+
const reasons = [];
|
|
127
|
+
if (isEnvFilePath(filePath)) {
|
|
128
|
+
reasons.push(`writing a dotenv secret file (${path.basename(filePath)})`);
|
|
129
|
+
}
|
|
130
|
+
reasons.push(...findSecrets(content ?? ""));
|
|
131
|
+
return reasons;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Deliberately inlined in every hook rather than shared: caps.ts counts
|
|
135
|
+
// .claude/hooks/*.mjs files against a hard limit, so a helper module would
|
|
136
|
+
// cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
|
|
137
|
+
// is not, so comparing them directly is false under any symlinked path and the
|
|
138
|
+
// guard body would never run -- exit 0, i.e. fail open.
|
|
139
|
+
function isEntryPoint() {
|
|
140
|
+
try {
|
|
141
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Main execution -- only run when invoked directly, not when imported for testing.
|
|
148
|
+
if (isEntryPoint()) {
|
|
149
|
+
const raw = await readStdin();
|
|
150
|
+
let input;
|
|
151
|
+
try {
|
|
152
|
+
input = JSON.parse(raw);
|
|
153
|
+
} catch {
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const filePath = input.tool_input?.file_path ?? "";
|
|
158
|
+
if (typeof filePath !== "string" || filePath.length === 0) process.exit(0);
|
|
159
|
+
|
|
160
|
+
// Content by field presence (Write -> content, Edit -> new_string), matching
|
|
161
|
+
// the sibling-hook convention so the guard stays live if tool_name is absent.
|
|
162
|
+
const ti = input.tool_input ?? {};
|
|
163
|
+
const content =
|
|
164
|
+
typeof ti.content === "string"
|
|
165
|
+
? ti.content
|
|
166
|
+
: typeof ti.new_string === "string"
|
|
167
|
+
? ti.new_string
|
|
168
|
+
: "";
|
|
169
|
+
|
|
170
|
+
const reasons = isSecretWrite(filePath, content);
|
|
171
|
+
if (reasons.length === 0) process.exit(0);
|
|
172
|
+
|
|
173
|
+
process.stderr.write(`\
|
|
174
|
+
[guard-secret-writes] Blocked write to ${filePath}:
|
|
175
|
+
${reasons.map((r) => ` - ${r}`).join("\n")}
|
|
176
|
+
|
|
177
|
+
Secrets and tokens must never be written to the working tree -- they persist in
|
|
178
|
+
git objects/reflog even after removal. Use a CI secret or an env var reference
|
|
179
|
+
instead of a literal value, and keep real dotenv files out of the repo (.env*
|
|
180
|
+
is gitignored; author .env.example with placeholders).
|
|
181
|
+
`);
|
|
182
|
+
process.exit(2);
|
|
183
|
+
}
|