create-agent-rig 0.4.0 → 0.6.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -0,0 +1,490 @@
1
+ // The credential vocabulary, decided once, so that everything which refuses a
2
+ // credential refuses the same set.
3
+ //
4
+ // Every layer that refuses one reads THIS module — which layers exist is a
5
+ // question about the project, not about this file. A freshly generated rig has
6
+ // the `guard-secret-file` PreToolUse hook and nothing else; the generator this
7
+ // came from adds a commit-time check and a CI sweep over the same vocabulary
8
+ // (`scripts/validate-no-secrets.mjs`, wired in `.husky/pre-commit` and the CI
9
+ // workflow). Read `.husky/` and the CI config to know which of those you have.
10
+ //
11
+ // `.claude/rules/invariants.md` ("one mechanism, one implementation") is why the
12
+ // vocabulary is a module and not a list per consumer: two copies of an invariant
13
+ // disagree, and the one nobody is looking at is the one that is wrong. That is
14
+ // worth keeping even at one consumer, because the ignore rules are the second
15
+ // reader of this set whether or not they import it.
16
+ //
17
+ // ⚠ The `see <file> › "<test>"` pointers below name suites that live in the
18
+ // GENERATOR this rig came from, not in this repository — the same arrangement
19
+ // `.claude/rules/invariants.md` describes for the hooks themselves under "About
20
+ // the hooks you were given". They are where a claim is proven, not cover you
21
+ // have here. The moment you edit this module, its tests are yours.
22
+ //
23
+ // It answers two different questions, and keeping them apart matters:
24
+ //
25
+ // isCredentialPath(path) — is this file a credential BY ITS NAME?
26
+ // findSecretValues(text) — does this TEXT contain a credential value?
27
+ //
28
+ // The first is what `.gitignore` can express; the second is what nothing in this
29
+ // repository expressed before, which is the gap AR-49(b) was filed for.
30
+ //
31
+ // 🔴 WHAT THIS DELIBERATELY DOES NOT DO, because a guard's own claim about its
32
+ // reach is the first thing to go stale:
33
+ //
34
+ // - It never returns the matched value. A guard that prints what it found has
35
+ // copied the credential into a hook transcript, a CI log and a terminal
36
+ // scrollback — it has leaked the secret in the act of refusing it. The
37
+ // finding is `{ id, line }` and the tests assert the serialised finding
38
+ // carries neither the value nor a fragment of it: see secrets-lib.test.ts ›
39
+ // "never carries the credential itself into the finding it reports".
40
+ // - The assignment arm judges at most 32 candidates on one line and gives up.
41
+ // Past that a real credential is missed — it needs 32+ keyword-plus-identifier
42
+ // candidates ahead of the real value on ONE line, so a minified bundle or a
43
+ // single-line config rather than ordinary source. Pinned from both sides, the
44
+ // way the scan cap is: see secrets-lib.test.ts › "finds a credential behind
45
+ // thirty-one innocent candidates" and › "misses a credential behind more
46
+ // candidates than the cap allows".
47
+ // - It is a TEXT scan, not an entropy analyser. A credential that matches none
48
+ // of the shapes below passes, and a base64 blob that happens to look like
49
+ // one does not. It targets drift, not an adversary. Named rather than left
50
+ // to be discovered, because the list of prefixes below invites the opposite
51
+ // assumption: a password inside a `postgres://user:pass@host` or a
52
+ // basic-auth URL, a bare unassigned JWT, a storage connection string of
53
+ // the `AccountKey=…` form and a PuTTY key header are all still invisible
54
+ // to it. Nor are these siblings of the prefixes it does know: the `xapp-`
55
+ // and `xoxe-` chat-app forms, a test-mode payment key, and the classic
56
+ // un-prefixed model-provider key.
57
+ // - `assigned-secret` reads a keyword next to a long value. It cannot tell a
58
+ // real token from a long placeholder, which is why the length bound exists
59
+ // and why the placeholder forms this repository actually writes are pinned
60
+ // by test rather than by hope: see secrets-lib.test.ts › "leaves the
61
+ // placeholder %s alone".
62
+ //
63
+ // 🔴 AND IT IS BOUNDED, which is the rule `invariants.md` says cost the most to
64
+ // learn. Its PreToolUse consumer fails OPEN — a hook that throws allows the edit
65
+ // — so every line of work here is a potential total bypass, for all the rules at
66
+ // once rather than for the one that broke. (The validator fails closed: it exits
67
+ // 1 on a finding. The asymmetry is deliberate and belongs to those files.)
68
+ //
69
+ // Hence, and every one of these is a bound rather than a hope: the input is
70
+ // capped BEFORE it is split; no quantifier nests inside another; and the one
71
+ // pattern that judges its value walks a line's candidates under an explicit cap
72
+ // rather than to exhaustion. The last was paid for — an unbounded run between
73
+ // keyword and separator made this arm quadratic, in a guard that fails open. The
74
+ // cap is asserted by test from both
75
+ // sides — that a secret past it is missed, and that the same secret is found
76
+ // without the cap, so the miss is not passing for the wrong reason: see
77
+ // secrets-lib.test.ts › "scans nothing past the limit it was given".
78
+
79
+ /**
80
+ * The words this project calls a credential — a NAMED SUBSET of the router's
81
+ * 55-member `SECURITY_WORDS`, not a derivation from it.
82
+ *
83
+ * The distinction is the whole decision. `SECURITY_WORDS` answers "is this path
84
+ * a security surface worth a careful reviewer", and `auth`, `session`, `cors`
85
+ * and `acl` belong in that answer. This set answers "is this file a credential",
86
+ * where those same words are ordinary names in ordinary source — `auth.ts`,
87
+ * `session.ts`, `permissions.ts` — and a guard that refused them would be routed
88
+ * around within the week. A routed-around guard is worse than no guard, because
89
+ * everyone believes they are covered.
90
+ *
91
+ * A test asserts every word here is still in the router's set, so the two cannot
92
+ * drift into disagreeing about what a credential is.
93
+ */
94
+ export const CREDENTIAL_WORDS = new Set([
95
+ 'apikey',
96
+ 'apikeys',
97
+ 'bearer',
98
+ 'creds',
99
+ 'credential',
100
+ 'credentials',
101
+ 'jwt',
102
+ 'passwd',
103
+ 'password',
104
+ 'passwords',
105
+ 'secret',
106
+ 'secrets',
107
+ 'token',
108
+ 'tokens',
109
+ ]);
110
+
111
+ /**
112
+ * Credential files whose name carries no extension to give them away.
113
+ *
114
+ * `.envrc` is here and is deliberately WIDER than the router: `isSecretFile`
115
+ * misses it, because its only dot is at index 0 so the extension arm returns
116
+ * early. direnv writes `export JIRA_API_TOKEN=…` into it verbatim, which makes
117
+ * the miss expensive. Widening the router itself changes routing behaviour in a
118
+ * file under a declared elevated path — a different change at a different tier.
119
+ */
120
+ export const CREDENTIAL_BASENAMES = new Set([
121
+ '.envrc',
122
+ '.netrc',
123
+ '.npmrc',
124
+ '.pgpass',
125
+ 'id_ed25519',
126
+ 'id_rsa',
127
+ ]);
128
+
129
+ /** Extensions that mean key material, whatever the file is called. */
130
+ export const CREDENTIAL_EXTENSIONS = new Set(['jks', 'key', 'keystore', 'p12', 'pem', 'pfx']);
131
+
132
+ /**
133
+ * Directory names whose contents are credentials whatever they are called.
134
+ *
135
+ * This is the arm AR-49(a) left open, and the reason it is closed HERE rather
136
+ * than in `.gitignore`: an ignore rule over `secrets/` hides legitimate source
137
+ * and a gitignore has no include directive to claw it back. Refusing a commit
138
+ * is the right instrument; hiding a directory is not.
139
+ */
140
+ export const CREDENTIAL_SEGMENTS = new Set(['credentials', 'secrets']);
141
+
142
+ /**
143
+ * Suffixes that mark the documented placeholder form of an env file.
144
+ *
145
+ * `.env.example` is committed on purpose — it is how a project states which
146
+ * variables it needs. The exemption is scoped to the env arm alone: a file under
147
+ * `secrets/` stays a credential however it is suffixed.
148
+ */
149
+ const PLACEHOLDER_SUFFIXES = ['.example', '.sample', '.template'];
150
+
151
+ /** The last dot-separated part, or `''` for a name whose only dot leads it. */
152
+ const extensionOf = (basename) => {
153
+ const dot = basename.lastIndexOf('.');
154
+ return dot <= 0 ? '' : basename.slice(dot + 1).toLowerCase();
155
+ };
156
+
157
+ /**
158
+ * The suffixes that make `<stem>.env.<suffix>` an env FILE rather than a source
159
+ * file about the environment.
160
+ *
161
+ * 🔴 Named rather than open-ended, and that is the whole point. A bare
162
+ * `basename.includes('.env.')` reaches `prod.env.local` — and also
163
+ * `packages/shared/src/config.env.ts`, `src/app.env.ts` and `docs/setup.env.md`,
164
+ * which are ordinary source. `CLAUDE.md` puts env loading in `packages/shared`,
165
+ * so that false positive lands on the file most likely to need writing. See
166
+ * secrets-lib.test.ts › "leaves %s writable — it is source, not a credential".
167
+ *
168
+ * ⚠ The price, named rather than left to be found: an environment this list does
169
+ * not know — `app.env.qa`, `svc.env.ci` — is invisible to the path arm. A file
170
+ * called `.env.qa` is unaffected; only the `<stem>.env.<suffix>` form needs the
171
+ * suffix to be recognised.
172
+ */
173
+ const ENV_SUFFIXES = new Set([
174
+ 'local',
175
+ 'development',
176
+ 'dev',
177
+ 'production',
178
+ 'prod',
179
+ 'staging',
180
+ 'stage',
181
+ 'test',
182
+ ]);
183
+
184
+ // `.env` at either end, or in the middle followed by a named environment.
185
+ const isEnvFile = (basename) => {
186
+ if (basename === '.env' || basename.startsWith('.env.') || basename.endsWith('.env')) return true;
187
+ const middle = basename.indexOf('.env.');
188
+ if (middle < 0) return false;
189
+ return ENV_SUFFIXES.has(basename.slice(middle + '.env.'.length));
190
+ };
191
+
192
+ /**
193
+ * Whether a path names a credential file, from the path text alone.
194
+ *
195
+ * Deliberately answers from the NAME: it is the question a pre-commit hook and a
196
+ * PreToolUse hook can both ask before any content exists to read.
197
+ */
198
+ export const isCredentialPath = (relativePath) => {
199
+ const normalised = String(relativePath ?? '').replaceAll('\\', '/');
200
+ // 🔴 Lowercased once, and every arm below reads the lowered form. macOS and
201
+ // Windows are case-insensitive filesystems, so `.ENV` and `.env` are the SAME
202
+ // FILE — a case-sensitive check refuses one spelling and waves the other
203
+ // through, while git records whichever one was typed.
204
+ const segments = normalised
205
+ .split('/')
206
+ .filter((segment) => segment !== '')
207
+ .map((segment) => segment.toLowerCase());
208
+ if (segments.length === 0) return false;
209
+
210
+ const basename = segments[segments.length - 1];
211
+
212
+ // Directories only — the basename is not a segment. A file merely NAMED for
213
+ // the subject (`secrets-lib.test.ts`, a `secrets-and-tokens.md` note) is
214
+ // source, not a credential.
215
+ for (let i = 0; i < segments.length - 1; i += 1)
216
+ if (CREDENTIAL_SEGMENTS.has(segments[i])) return true;
217
+
218
+ if (CREDENTIAL_BASENAMES.has(basename)) return true;
219
+
220
+ // The two most famous credential filenames there are, and both were missed:
221
+ // a cloud CLI's `credentials` file and `.git-credentials` carry the word as
222
+ // the BASENAME,
223
+ // which the segment loop above deliberately skips. Matched exactly, or behind
224
+ // a `-`, so `credentials.ts` and `secrets-lib.test.ts` stay source.
225
+ if (CREDENTIAL_SEGMENTS.has(basename)) return true;
226
+ // 🔴 DOTFILES only. `.git-credentials` is a credential store; `scripts/rotate-secrets`
227
+ // and `ops/sync-credentials` are ordinary extensionless scripts, and refusing
228
+ // to write them is the false positive that gets a guard uninstalled. See
229
+ // secrets-lib.test.ts › "leaves %s writable — it is source, not a credential".
230
+ if (basename.startsWith('.') && (basename.endsWith('-credentials') || basename.endsWith('-secrets')))
231
+ return true;
232
+
233
+ if (isEnvFile(basename))
234
+ return !PLACEHOLDER_SUFFIXES.some((suffix) => basename.endsWith(suffix));
235
+
236
+ const extension = extensionOf(basename);
237
+ if (extension === '') return false;
238
+ return CREDENTIAL_EXTENSIONS.has(extension) || CREDENTIAL_WORDS.has(extension);
239
+ };
240
+
241
+ /**
242
+ * The `assigned-secret` pattern, built from `CREDENTIAL_WORDS`.
243
+ *
244
+ * 🔴 THE RUN BETWEEN KEYWORD AND SEPARATOR IS BOUNDED, and that bound is the
245
+ * whole difference between linear and quadratic. Unbounded, the engine restarts
246
+ * at every offset and walks `[A-Za-z0-9_-]*` to end-of-line at each keyword hit.
247
+ * In a guard that FAILS OPEN and shares its matcher with two others, that is not
248
+ * slowness, it is a bypass of all three. `.claude/rules/invariants.md` states the
249
+ * bar exactly: not "is it fast enough on realistic input" but "can any input make
250
+ * it do unbounded work at all". Pinned by secrets-lib.test.ts › "grows linearly
251
+ * with input size rather than quadratically".
252
+ *
253
+ * Forty characters is chosen to clear the longest real prefix this has to
254
+ * cross — `_ACCESS_KEY` after `secret` — with room, and nothing longer is an
255
+ * assignment anyone writes.
256
+ *
257
+ * ⚠ NOT anchored with `\b`, deliberately. It would read as the obvious fix for
258
+ * the same measurements, and it breaks the case this arm exists for:
259
+ * a name like `<VENDOR>_SECRET_ACCESS_KEY` is one word, so there is no word
260
+ * boundary before `SECRET` and the keyword would never be found.
261
+ * What separates a credential from an identifier here is the VALUE, not the
262
+ * keyword — see `IDENTIFIER_VALUE` below.
263
+ */
264
+ const assignmentPattern = () => {
265
+ // Ordered longest-first so `credentials` is tried before `credential`. This is
266
+ // presentation, not correctness: the bounded run absorbs either tail.
267
+ const words = [...CREDENTIAL_WORDS].sort((a, b) => b.length - a.length);
268
+ // `api_key` and `api-key` are the same keyword spelled three ways; the
269
+ // vocabulary holds only the closed form, so the separators are added here.
270
+ const alternation = [...words, 'api[_-]?keys?'].join('|');
271
+ return new RegExp(
272
+ `(?:${alternation})[A-Za-z0-9_-]{0,40}["']?\\s*[=:]\\s*["']?([A-Za-z0-9_\\-+/=]{16,})`,
273
+ 'i',
274
+ );
275
+ };
276
+
277
+ /**
278
+ * A value that is a bare identifier, which is what SOURCE is made of.
279
+ *
280
+ * 🔴 This is the rule that separates an assigned random string from
281
+ * `readonly credentials: RequestCredentials`. (Deliberately described rather
282
+ * than shown: a literal example here is a finding in this file, which is the
283
+ * same reason the suites assemble their fixtures.)
284
+ *
285
+ * Without it the arm reported ordinary source — `const trailingToken =
286
+ * getTrailingToken(…)`, `exports.isTokenOnSameLine = …`, `credentials:
287
+ * CredentialsContainer`. Those are the shapes a TypeScript service tree is MADE
288
+ * of, and this module ships into one. What keeps that true is not this comment:
289
+ * see secrets-lib.test.ts › "leaves %s alone" in the block about ordinary
290
+ * TypeScript, and › "finds no credential value in any file this repository
291
+ * tracks", which sweeps the whole tree with no exemption.
292
+ *
293
+ * A guard that fires on honest work is routed around within the week, and a
294
+ * routed-around guard is worse than none because everyone believes they are
295
+ * covered. See secrets-lib.test.ts › "leaves %s alone" in the block about
296
+ * ordinary TypeScript.
297
+ *
298
+ * ⚠ The price is real and is pinned too: an all-letters secret is invisible to
299
+ * this arm — see › "cannot see an assigned value that is all letters, which is
300
+ * the price of the rule above". Shapes with a recognisable prefix are caught by
301
+ * the patterns above regardless of what they are assigned to.
302
+ */
303
+ const IDENTIFIER_VALUE = /^[A-Za-z]+$/;
304
+
305
+ /**
306
+ * The credential shapes a refusal names — each id chosen so a block can say WHICH
307
+ * shape it matched without quoting what it matched.
308
+ *
309
+ * How many things read this list is a question about the project, answered at the
310
+ * top of this file; it is not a property of the list.
311
+ *
312
+ * Every pattern is deliberately flat: no quantifier nests inside another.
313
+ *
314
+ * 🔴 THAT IS NOT SUFFICIENT ON ITS OWN, and reading it as sufficient is how the
315
+ * quadratic in `assignedPattern` got written. Disjoint consecutive classes bound
316
+ * each backtrack STEP; they say nothing about the engine RESTARTING at every
317
+ * offset. An unanchored alternation followed by an unbounded run is linear per
318
+ * attempt and quadratic over the line. The rule for a thirteenth pattern is
319
+ * therefore: a literal prefix and ONE bounded class, or a bound on every
320
+ * quantifier that can follow a match that fails.
321
+ */
322
+ export const SECRET_VALUE_PATTERNS = [
323
+ { id: 'atlassian-token', pattern: /ATATT3x[A-Za-z0-9_\-=]{16,}/ },
324
+ // `gh[pousr]_` rather than `ghp_` alone: the other four are live credentials
325
+ // of the same shape, and an id called `github-pat` reads as though they were
326
+ // already covered.
327
+ { id: 'github-pat', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },
328
+ // The negative lookahead is the issuer's own PUBLISHED example key. It appears
329
+ // in documentation, and this generator ships a target whose README quotes such
330
+ // docs — refusing it at commit time, with --no-verify hook-blocked, leaves an
331
+ // author nothing but to route around the guard. A fixed-length literal, so it
332
+ // adds no backtracking surface. See secrets-lib.test.ts › "leaves the published
333
+ // example access key alone".
334
+ { id: 'cloud-access-key', pattern: /\bAKIA(?!IOSFODNN7EXAMPLE\b)[A-Z0-9]{16}\b/ },
335
+ { id: 'anthropic-key', pattern: /\bsk-ant-[A-Za-z0-9\-_]{16,}/ },
336
+ { id: 'private-key-block', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
337
+ // Six more with a recognisable prefix. Each is a literal followed by ONE
338
+ // bounded class — the cheapest shape there is, and the one that cannot
339
+ // backtrack. They are here because a tool that names four issuers by prefix
340
+ // invites the reader to assume it knows the rest; the ones it still does not
341
+ // know are named in the limits block above rather than left to be discovered.
342
+ { id: 'slack-token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{16,}/ },
343
+ // Exactly 35, not 16-or-more: the open bound matched ordinary identifiers
344
+ // that merely begin with those four letters (`AIzaSyntaxHighlighter`).
345
+ // A negative lookahead, not `\b`. The class admits `-`, and `\b` after a `-`
346
+ // needs a word character next — in the wild a key is followed by a quote, an
347
+ // ampersand or end of line, so a key whose last character is `-` (about one in
348
+ // sixty-four) went unseen. Found by a reviewer measuring what the tightening
349
+ // cost; see secrets-lib.test.ts › "reports a key of that shape ending in %s".
350
+ { id: 'google-api-key', pattern: /\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/ },
351
+ { id: 'stripe-live-key', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}/ },
352
+ { id: 'openai-project-key', pattern: /\bsk-proj-[A-Za-z0-9_-]{16,}/ },
353
+ { id: 'npm-token', pattern: /\bnpm_[A-Za-z0-9]{30,}/ },
354
+ { id: 'gitlab-pat', pattern: /\bglpat-[A-Za-z0-9_-]{16,}/ },
355
+ // A keyword next to a long LITERAL value — the only pattern here that is
356
+ // built rather than written, because it is the only one whose vocabulary this
357
+ // project decides.
358
+ //
359
+ // THREE bounds, and every one of them was paid for by a defect:
360
+ //
361
+ // 1. Sixteen characters. This is what keeps `.env.example` committable —
362
+ // `your-token-here` is fifteen. See secrets-lib.test.ts › "leaves the
363
+ // placeholder %s alone".
364
+ //
365
+ // 2. A LITERAL value class — no dot, no bracket. An earlier form read
366
+ // `\S{16,}` and matched a code expression as happily as a literal, which
367
+ // against this repository's own tree produced false positives and nothing
368
+ // else, feeding a hook that blocks a commit. `.claude/rules/invariants.md`:
369
+ // "Where a false block interrupts ordinary work, stay narrow and specific."
370
+ // What keeps it narrow is not this comment but two live tests — see
371
+ // secrets-lib.test.ts › "leaves the honest line %s alone" and › "finds no
372
+ // credential value in any file this repository tracks".
373
+ //
374
+ // 3. An OPTIONAL leading quote, and the keyword need not touch the separator.
375
+ // Without those two the pattern missed `const token = "…"`, `"api_key":
376
+ // "…"` and `SECRET_ACCESS_KEY=…` — every quoted assignment, which is the
377
+ // dominant shape in a TypeScript, JSON or YAML tree. The second of those is
378
+ // the sharp one: `cloud-access-key` matches the key IDENTIFIER, which is
379
+ // public, so without this the scanner caught the public half of a key pair
380
+ // and missed the private half. That is worse than catching neither, because
381
+ // it reads as coverage. See secrets-lib.test.ts › "reports %s, whose value
382
+ // is quoted rather than bare" and › "reports %s, where the keyword is not
383
+ // adjacent to the separator".
384
+ //
385
+ // 🔴 THE KEYWORDS ARE DERIVED FROM `CREDENTIAL_WORDS`, not restated. A
386
+ // hardcoded five-word list sat here while the file called itself "one
387
+ // vocabulary, decided once" and the vocabulary had fourteen — two lists
388
+ // answering one question, which is the exact thing this module exists to
389
+ // prevent. A test walks every word: see secrets-lib.test.ts › "accepts every
390
+ // word in the credential vocabulary as an assignment keyword".
391
+ //
392
+ // What is still lost, stated as measured: a value whose literal run breaks
393
+ // before sixteen characters — see › "cannot see a credential whose literal run
394
+ // breaks before sixteen characters". An inline JWT is NOT lost; its first
395
+ // segment is twenty-odd literal characters.
396
+ {
397
+ id: 'assigned-secret',
398
+ pattern: assignmentPattern(),
399
+ // The value is captured so it can be judged, not merely matched.
400
+ valueGroup: 1,
401
+ reject: IDENTIFIER_VALUE,
402
+ },
403
+ ];
404
+
405
+ /**
406
+ * How much text a scan reads before it stops.
407
+ *
408
+ * A cap rather than a timeout, because a cap is decidable before the work
409
+ * starts. Two megabytes covers every source file this rulebook expects and stops
410
+ * a generated blob from turning a fail-open guard into a bypass.
411
+ */
412
+ export const DEFAULT_SCAN_LIMIT = 2 * 1024 * 1024;
413
+
414
+ /**
415
+ * A global twin per rejecting pattern, built once.
416
+ *
417
+ * Only patterns that JUDGE their value need to walk a line: the rest answer with
418
+ * one `test`. Built at module load rather than per line, because a fail-open
419
+ * guard should not allocate per unit of input.
420
+ */
421
+ const GLOBAL_TWIN = new Map(
422
+ SECRET_VALUE_PATTERNS.filter((entry) => entry.reject).map((entry) => [
423
+ entry.id,
424
+ new RegExp(entry.pattern.source, `${entry.pattern.flags}g`),
425
+ ]),
426
+ );
427
+
428
+ /** How many candidates on one line are judged before the line is given up on. */
429
+ const MAX_CANDIDATES_PER_LINE = 32;
430
+
431
+ /**
432
+ * Whether one pattern claims this line.
433
+ *
434
+ * A rejecting pattern walks the line's candidates rather than judging only the
435
+ * first: `credentials: RequestCredentials, token: <a real one>` has an innocent
436
+ * match before a guilty one, and stopping at the first would miss it. Bounded by
437
+ * `MAX_CANDIDATES_PER_LINE`, so the walk cannot become the unbounded work this
438
+ * module exists to avoid.
439
+ */
440
+ const matches = (entry, line) => {
441
+ if (!entry.reject) return entry.pattern.test(line);
442
+ const global = GLOBAL_TWIN.get(entry.id);
443
+ if (!global) return entry.pattern.test(line);
444
+ global.lastIndex = 0;
445
+ for (let seen = 0; seen < MAX_CANDIDATES_PER_LINE; seen += 1) {
446
+ const match = global.exec(line);
447
+ if (match === null) return false;
448
+ if (!entry.reject.test(match[entry.valueGroup] ?? '')) return true;
449
+ // 🔴 RESUME ONE CHARACTER PAST THE START, never past the whole match. A
450
+ // rejected match ends after its all-letters value, and a credential keyword
451
+ // INSIDE that value is then consumed with it: `AtlassianApiToken` ends in
452
+ // `Token`, so `const secret: AtlassianApiToken = "<a real one>"` was not seen
453
+ // at all — by this module, and therefore by everything that reads it. The property was inverted — more credential
454
+ // vocabulary on the line meant less detection. See secrets-lib.test.ts ›
455
+ // "reports %s, whose real value sits behind a rejected one".
456
+ //
457
+ // This is also what makes the cap above load-bearing rather than decorative:
458
+ // the walk now revisits offsets, so it is the cap that bounds it.
459
+ global.lastIndex = match.index + 1;
460
+ }
461
+ return false;
462
+ };
463
+
464
+ /**
465
+ * Every credential shape found in `text`, as `{ id, line }` and nothing else.
466
+ *
467
+ * At most `limit` characters are read. Most patterns answer in one `test`; the
468
+ * arm that JUDGES its value walks up to `MAX_CANDIDATES_PER_LINE` candidates per
469
+ * line and revisits offsets under that cap, which is what bounds it — see the
470
+ * note on `matches()`. A pattern is reported once
471
+ * per line however many times it occurs there: the finding locates the problem,
472
+ * it does not count it.
473
+ */
474
+ export const findSecretValues = (text, options = {}) => {
475
+ const source = typeof text === 'string' ? text : '';
476
+ const limit = Number.isFinite(options.limit) ? Math.max(0, options.limit) : DEFAULT_SCAN_LIMIT;
477
+ // Capped BEFORE the split, so the array below is bounded by `limit` and not by
478
+ // the caller's input — `invariants.md`: cap first, then spread.
479
+ const scanned = source.length > limit ? source.slice(0, limit) : source;
480
+
481
+ const findings = [];
482
+ const lines = scanned.split('\n');
483
+ for (let index = 0; index < lines.length; index += 1) {
484
+ const line = lines[index];
485
+ for (const entry of SECRET_VALUE_PATTERNS) {
486
+ if (matches(entry, line)) findings.push({ id: entry.id, line: index + 1 });
487
+ }
488
+ }
489
+ return findings;
490
+ };