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.
- package/CHANGELOG.md +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- 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
|
+
};
|