@rmyndharis/aimhooman 0.2.0 → 0.3.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/.agents/rules/aimhooman.md +9 -6
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.clinerules/aimhooman.md +9 -6
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor/rules/aimhooman.mdc +9 -6
- package/.github/copilot-instructions.md +9 -6
- package/.kiro/steering/aimhooman.md +9 -6
- package/.windsurf/rules/aimhooman.md +9 -6
- package/AGENTS.md +9 -6
- package/CHANGELOG.md +60 -0
- package/CONTRIBUTING.md +2 -3
- package/GEMINI.md +9 -6
- package/README.md +56 -375
- package/SECURITY.md +6 -4
- package/bin/aimhooman.mjs +125 -140
- package/docs/ai-artifacts.gitignore +63 -0
- package/docs/catalog.md +41 -0
- package/docs/design/frictionless-enforcement.md +27 -17
- package/hooks/hooks.json +1 -1
- package/package.json +5 -2
- package/rules/paths.json +0 -114
- package/schemas/overrides.schema.json +4 -1
- package/skills/aimhooman/SKILL.md +11 -8
- package/src/exclude.mjs +2 -2
- package/src/hook.mjs +33 -12
- package/src/report.mjs +12 -37
- package/src/scan-session.mjs +14 -21
- package/src/scan-target.mjs +10 -10
- package/src/scan.mjs +9 -24
- package/src/state.mjs +59 -10
- package/rules/secrets.json +0 -96
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
This document describes aimhooman's enforcement model. aimhooman is designed to
|
|
4
4
|
prevent known AI-tool residue from leaking into Git history **without editing
|
|
5
|
-
`.gitignore
|
|
6
|
-
Plugin startup provides best-effort prevention without `init`;
|
|
7
|
-
enforcement requires local or global hook setup.
|
|
5
|
+
`.gitignore` by default, and with repair before rejection on the default ordinary
|
|
6
|
+
commit path.** Plugin startup provides best-effort prevention without `init`;
|
|
7
|
+
complete Git-boundary enforcement requires local or global hook setup.
|
|
8
8
|
|
|
9
9
|
## Goals
|
|
10
10
|
|
|
11
11
|
- **Zero-activation for Claude Code:** the plugin is active every session
|
|
12
12
|
(SessionStart), no `init`.
|
|
13
|
-
- **No `.gitignore` burden:** known AI artifacts are auto-excluded via
|
|
14
|
-
`.git/info/exclude` (local, never committed
|
|
13
|
+
- **No `.gitignore` burden by default:** known AI artifacts are auto-excluded via
|
|
14
|
+
`.git/info/exclude` (local, never committed). `init --gitignore` is the opt-in
|
|
15
|
+
variant that writes the same managed block to the worktree `.gitignore`, so a
|
|
16
|
+
team can commit it and share the ignore set across clones.
|
|
15
17
|
- **Repair-first for hygiene by default:** AI artifacts are excluded before
|
|
16
|
-
staging or unstaged at commit. If a block cannot be repaired
|
|
17
|
-
pinned tree,
|
|
18
|
+
staging or unstaged at commit. If a block cannot be repaired or remains in the
|
|
19
|
+
pinned tree, the operation stops. An incomplete scan warns on
|
|
20
|
+
`clean`/`compliance` and stops on `strict`; the final ref guard stops on it
|
|
21
|
+
at every profile.
|
|
18
22
|
- **Broad, community-updatable rule catalog:** covers many AI tools; extendable
|
|
19
23
|
via PR (core) and local rules (personal).
|
|
20
24
|
- **Block remains available as opt-in** (`strict` profile) for teams that want
|
|
@@ -41,8 +45,9 @@ enforcement requires local or global hook setup.
|
|
|
41
45
|
3. **PreToolUse cannot unstage.** It runs before the tool executes; at `git add X`,
|
|
42
46
|
X is not staged yet. So unstage is a git-tier (`pre-commit`) capability; the
|
|
43
47
|
plugin tier does prevention (excludes) plus advisory (warn).
|
|
44
|
-
4. **`.gitignore` stays clean** because aimhooman writes
|
|
45
|
-
(local, not committed
|
|
48
|
+
4. **`.gitignore` stays clean by default** because aimhooman writes
|
|
49
|
+
`.git/info/exclude` (local, not committed). The opt-in `init --gitignore`
|
|
50
|
+
writes the same managed block to `.gitignore` for teams that want it committed.
|
|
46
51
|
|
|
47
52
|
## Design
|
|
48
53
|
|
|
@@ -54,16 +59,18 @@ enforcement requires local or global hook setup.
|
|
|
54
59
|
| 1. Catch-all | `pre-commit` hook unstages AI artifacts from the index | git hook | proceeds after repair; stops if repair fails |
|
|
55
60
|
| 2. Agent guard | PreToolUse reports paths and rejects unprovable protected Git mutations | plugin | advisory for paths; fail-closed for boundary bypass |
|
|
56
61
|
| 3. Strict policy | `strict` profile blocks instead of repairing (exit 10) | both | block |
|
|
57
|
-
| 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation
|
|
62
|
+
| 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation; an incomplete scan warns on clean/compliance, stops on strict |
|
|
58
63
|
| 5. Final ref check | prepared `reference-transaction` scans what each commit introduced to `HEAD` or a branch changes (and the message of commits authored locally) | git hook | blocks a violation or incomplete scan |
|
|
59
64
|
|
|
60
65
|
Default profile `clean` uses layers 0, 1, 2, 4, and 5. Successful repair keeps the
|
|
61
66
|
ordinary path low-friction. The pinned-tree and final-ref scans deliberately stop if
|
|
62
|
-
a block remains on a path the commit actually changes
|
|
67
|
+
a block remains on a path the commit actually changes. The final-ref scan also stops
|
|
68
|
+
when it is incomplete, on every profile; an incomplete pinned-tree scan warns on
|
|
69
|
+
`clean`/`compliance` and stops on `strict`.
|
|
63
70
|
A file already in history is not re-tried on every later commit: the final ref check
|
|
64
71
|
judges a commit by its change set, so an inherited path no longer blocks unrelated
|
|
65
72
|
work. Use `aimhooman check --tracked` (or scan the PR range in CI) to surface legacy
|
|
66
|
-
|
|
73
|
+
artifacts without bricking the branch.
|
|
67
74
|
Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
|
|
68
75
|
hook checks dispatcher integrity there without scanning unresolved references,
|
|
69
76
|
then keeps the scan veto at `prepared`, after Git has locked them.
|
|
@@ -95,14 +102,15 @@ rather than run the command on its own, which is the friction this tier exists t
|
|
|
95
102
|
### Components
|
|
96
103
|
|
|
97
104
|
1. **Rule catalog** — `rules/paths.json`, `rules/attribution.json`,
|
|
98
|
-
`rules/markers.json
|
|
105
|
+
`rules/markers.json`. Covers Claude, Codex, Copilot, Cursor, Aider, SpecStory,
|
|
99
106
|
Continue, Playwright MCP, Remember, Superpowers, and a generic agent dir.
|
|
100
107
|
Community extends via PR (core); personal extensions via
|
|
101
108
|
`<common-git-dir>/aimhooman/rules/*.json` (local, shared by linked worktrees,
|
|
102
109
|
loaded after core, and only able to add restrictions).
|
|
103
110
|
2. **Auto-exclude** — `applyExclude` writes patterns derived from unambiguous
|
|
104
111
|
`ephemeral-state` and `local-settings` rules to `.git/info/exclude` from
|
|
105
|
-
SessionStart, init, and the agent guard.
|
|
112
|
+
SessionStart, init, and the agent guard. Review-required and policy paths
|
|
113
|
+
remain visible.
|
|
106
114
|
3. **Git boundary** — the `pre-commit` hook scans staged paths; for any AI
|
|
107
115
|
artifact it removes it from the index, then lets the commit proceed only when
|
|
108
116
|
that repair succeeds. `commit-msg` removes complete exact high-confidence attribution lines
|
|
@@ -133,8 +141,10 @@ means local rules only add restrictions and can never override a core block.
|
|
|
133
141
|
## Implementation notes
|
|
134
142
|
|
|
135
143
|
- **Category-aware failure.** On `clean`, a malformed local rule pack is skipped
|
|
136
|
-
with a warning because built-in rules remain active. Corrupt override state
|
|
137
|
-
unreadable Git inputs
|
|
144
|
+
with a warning because built-in rules remain active. Corrupt override state and
|
|
145
|
+
unreadable Git inputs stop every profile. An incomplete staged-content scan warns
|
|
146
|
+
on `clean`/`compliance` and stops `strict`; the final ref guard fails closed on it
|
|
147
|
+
at every profile.
|
|
138
148
|
Any automatic unstage failure stops; allowing it through would only defer the
|
|
139
149
|
same block to the pinned-tree or final-ref scan. `strict` also vetoes policy
|
|
140
150
|
findings and reviews.
|
|
@@ -146,7 +156,7 @@ means local rules only add restrictions and can never override a core block.
|
|
|
146
156
|
- **HEAD-safe unstage.** `git restore --staged` needs HEAD; on a repository's
|
|
147
157
|
initial commit it falls back to `git rm --cached --ignore-unmatch`.
|
|
148
158
|
- **Repair never mints an empty commit.** When the pre-commit repair unstages the last
|
|
149
|
-
staged path (stage only a `.
|
|
159
|
+
staged path (stage only a `.claude.json`, then `git commit`), it exits 10 so Git stops, the
|
|
150
160
|
same outcome as committing with nothing staged. Carrying on would create a commit Git
|
|
151
161
|
itself would have refused and leave the developer a junk commit to `git reset --hard`.
|
|
152
162
|
- **Anchored attribution rules.** The AI-noreply rule is anchored to trailer
|
package/hooks/hooks.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI works. Hoomans ship. Keep AI session files
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "AI works. Hoomans ship. Keep AI session files and attribution out of your commits.",
|
|
5
5
|
"homepage": "https://github.com/rmyndharis/aimhooman#readme",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"hooks/",
|
|
22
22
|
"schemas/",
|
|
23
23
|
"docs/hosts.json",
|
|
24
|
+
"docs/ai-artifacts.gitignore",
|
|
25
|
+
"docs/catalog.md",
|
|
24
26
|
"docs/design/",
|
|
25
27
|
"docs/logo/",
|
|
26
28
|
".claude-plugin/",
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
"check": "node scripts/validate.mjs",
|
|
48
50
|
"sync:hosts": "node scripts/sync-hosts.mjs",
|
|
49
51
|
"sync:ruleset": "node scripts/sync-ruleset.mjs",
|
|
52
|
+
"sync:catalog": "node scripts/sync-catalog.mjs",
|
|
50
53
|
"test": "node --import ./tests/test-env.mjs --test",
|
|
51
54
|
"test:coverage": "node --import ./tests/test-env.mjs --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-include=\"bin/**\" --test-coverage-include=\"scripts/authorize-owner-paths.mjs\" --test-coverage-include=\"scripts/github-owner-authority.mjs\" --test-coverage-include=\"scripts/package-manifest.mjs\" --test-coverage-include=\"scripts/scan-ci-history.mjs\" --test-coverage-exclude=\"tests/**\" --test-coverage-lines=75 --test-coverage-branches=60 --test-coverage-functions=85 --test-reporter=spec --test-reporter=./scripts/coverage-threshold.mjs --test-reporter-destination=stdout --test-reporter-destination=stderr --test",
|
|
52
55
|
"check:static": "node scripts/static-gates.mjs",
|
package/rules/paths.json
CHANGED
|
@@ -216,120 +216,6 @@
|
|
|
216
216
|
"git restore --staged <path>"
|
|
217
217
|
]
|
|
218
218
|
},
|
|
219
|
-
{
|
|
220
|
-
"id": "secret.dotenv",
|
|
221
|
-
"version": 4,
|
|
222
|
-
"provider": "generic",
|
|
223
|
-
"category": "secret",
|
|
224
|
-
"confidence": "high",
|
|
225
|
-
"kind": "path",
|
|
226
|
-
"match": {
|
|
227
|
-
"path_case": "insensitive",
|
|
228
|
-
"paths": [
|
|
229
|
-
".env",
|
|
230
|
-
"**/.env",
|
|
231
|
-
".env.*",
|
|
232
|
-
"**/.env.*"
|
|
233
|
-
],
|
|
234
|
-
"except": [
|
|
235
|
-
"**/*.example",
|
|
236
|
-
"**/*.sample",
|
|
237
|
-
"**/*.template",
|
|
238
|
-
"**/*.dist",
|
|
239
|
-
"**/*.defaults",
|
|
240
|
-
"**/*.minimal"
|
|
241
|
-
]
|
|
242
|
-
},
|
|
243
|
-
"actions": {
|
|
244
|
-
"clean": "block",
|
|
245
|
-
"strict": "block",
|
|
246
|
-
"compliance": "block"
|
|
247
|
-
},
|
|
248
|
-
"reason": "Environment files often carry secrets and should not be committed.",
|
|
249
|
-
"remediation": [
|
|
250
|
-
"git restore --staged <path>",
|
|
251
|
-
"commit a .env.example with placeholders instead"
|
|
252
|
-
]
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
"id": "secret.private-key",
|
|
256
|
-
"version": 3,
|
|
257
|
-
"provider": "generic",
|
|
258
|
-
"category": "secret",
|
|
259
|
-
"confidence": "high",
|
|
260
|
-
"kind": "path",
|
|
261
|
-
"match": {
|
|
262
|
-
"path_case": "insensitive",
|
|
263
|
-
"paths": [
|
|
264
|
-
"**/id_rsa",
|
|
265
|
-
"**/id_dsa",
|
|
266
|
-
"**/id_ecdsa",
|
|
267
|
-
"**/id_ed25519",
|
|
268
|
-
"**/*.p12",
|
|
269
|
-
"**/*.pfx"
|
|
270
|
-
]
|
|
271
|
-
},
|
|
272
|
-
"actions": {
|
|
273
|
-
"clean": "block",
|
|
274
|
-
"strict": "block",
|
|
275
|
-
"compliance": "block"
|
|
276
|
-
},
|
|
277
|
-
"reason": "A path matching a private-key filename (id_rsa, id_ed25519, *.p12, *.pfx, ...) is blocked by name — content is not inspected. If this is a real key, it must never be committed; if the name is coincidental, rename the file.",
|
|
278
|
-
"remediation": [
|
|
279
|
-
"git restore --staged <path>",
|
|
280
|
-
"if this is a real key, rotate it if it was ever exposed",
|
|
281
|
-
"if the name is coincidental (a fixture, a doc), rename the file so it no longer matches a private-key pattern"
|
|
282
|
-
]
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
"id": "secret.aws-credentials",
|
|
286
|
-
"version": 2,
|
|
287
|
-
"provider": "generic",
|
|
288
|
-
"category": "secret",
|
|
289
|
-
"confidence": "high",
|
|
290
|
-
"kind": "path",
|
|
291
|
-
"match": {
|
|
292
|
-
"path_case": "insensitive",
|
|
293
|
-
"paths": [
|
|
294
|
-
".aws/credentials",
|
|
295
|
-
"**/.aws/credentials"
|
|
296
|
-
]
|
|
297
|
-
},
|
|
298
|
-
"actions": {
|
|
299
|
-
"clean": "block",
|
|
300
|
-
"strict": "block",
|
|
301
|
-
"compliance": "block"
|
|
302
|
-
},
|
|
303
|
-
"reason": "AWS credentials must never be committed.",
|
|
304
|
-
"remediation": [
|
|
305
|
-
"git restore --staged <path>"
|
|
306
|
-
]
|
|
307
|
-
},
|
|
308
|
-
{
|
|
309
|
-
"id": "secret.claude-credentials",
|
|
310
|
-
"version": 1,
|
|
311
|
-
"provider": "claude-code",
|
|
312
|
-
"category": "secret",
|
|
313
|
-
"confidence": "high",
|
|
314
|
-
"kind": "path",
|
|
315
|
-
"match": {
|
|
316
|
-
"path_case": "insensitive",
|
|
317
|
-
"paths": [
|
|
318
|
-
".claude/.credentials.json",
|
|
319
|
-
"**/.claude/.credentials.json"
|
|
320
|
-
]
|
|
321
|
-
},
|
|
322
|
-
"actions": {
|
|
323
|
-
"clean": "block",
|
|
324
|
-
"strict": "block",
|
|
325
|
-
"compliance": "block"
|
|
326
|
-
},
|
|
327
|
-
"reason": "This file holds an OAuth access token and must never be committed.",
|
|
328
|
-
"remediation": [
|
|
329
|
-
"git restore --staged <path>",
|
|
330
|
-
"Revoke the token if it already reached a commit."
|
|
331
|
-
]
|
|
332
|
-
},
|
|
333
219
|
{
|
|
334
220
|
"id": "generic.agent-instructions",
|
|
335
221
|
"version": 2,
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"required": ["target"],
|
|
15
15
|
"properties": {
|
|
16
16
|
"target": { "type": "string", "minLength": 1 },
|
|
17
|
-
"scope": {
|
|
17
|
+
"scope": {
|
|
18
|
+
"enum": ["path", "rule"],
|
|
19
|
+
"$comment": "v0.3.0 retired the secret-path scope with built-in secret scanning; the loader drops legacy entries with a warning instead of failing the file."
|
|
20
|
+
},
|
|
18
21
|
"reason": { "type": "string" },
|
|
19
22
|
"actor": { "type": "string" },
|
|
20
23
|
"at": { "type": "string", "format": "date-time" }
|
|
@@ -3,8 +3,8 @@ name: aimhooman
|
|
|
3
3
|
description: >
|
|
4
4
|
Keep AI tooling artifacts out of Git. Use whenever staging, committing, or
|
|
5
5
|
pushing changes: block AI session/state files (.claude/session.json,
|
|
6
|
-
.codex/history, .copilot, .cursor/session, .aider.*, .specstory, .agent)
|
|
7
|
-
|
|
6
|
+
.codex/history, .copilot, .cursor/session, .aider.*, .specstory, .agent)
|
|
7
|
+
and unwanted AI attribution in
|
|
8
8
|
commit messages (Co-authored-by an AI, "Generated with AI"). Also use when the user says
|
|
9
9
|
"aimhooman", "ship it like a hooman", or asks to clean AI residue before a commit.
|
|
10
10
|
license: MIT
|
|
@@ -24,13 +24,16 @@ Its policy:
|
|
|
24
24
|
`.copilot/*`, `.cursor/session*`, `.cursor/chats/*`, `.cursor/logs/*`,
|
|
25
25
|
`.aider.*`, `.specstory/*`, `.continue/sessions/*`, `.playwright-mcp/*`,
|
|
26
26
|
`.remember/*`, `.superpowers/*`, and `.agent/*`. The examples are not
|
|
27
|
-
exhaustive; the packaged `rules/paths.json` is the detection source of truth
|
|
28
|
-
If one is staged, unstage it and keep it out of Git
|
|
27
|
+
exhaustive; the packaged `rules/paths.json` is the detection source of truth
|
|
28
|
+
for these artifacts. If one is staged, unstage it and keep it out of Git
|
|
29
|
+
instead.
|
|
29
30
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
30
31
|
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
31
32
|
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
32
33
|
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
33
|
-
SendGrid). Public certificates are allowed.
|
|
34
|
+
SendGrid). Public certificates are allowed. aimhooman does not scan for
|
|
35
|
+
secrets — this rule is on you, and a dedicated scanner such as gitleaks is
|
|
36
|
+
the right backstop for it.
|
|
34
37
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
35
38
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
36
39
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -61,7 +64,7 @@ npm test
|
|
|
61
64
|
npm run test:coverage
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
aimhooman also enforces
|
|
65
|
-
violation to fix, not a check to bypass. The rule
|
|
66
|
-
ship.
|
|
67
|
+
aimhooman also enforces the artifact and attribution rules at commit time, so
|
|
68
|
+
a blocked commit means a real violation to fix, not a check to bypass. The rule
|
|
69
|
+
is simple: AI works, hoomans ship.
|
|
67
70
|
<!-- aimhooman:ruleset-end -->
|
package/src/exclude.mjs
CHANGED
|
@@ -8,8 +8,8 @@ const END = '# <<< aimhooman managed excludes';
|
|
|
8
8
|
|
|
9
9
|
// Local AI residue kept out of `git status`. Deriving this list from the rule
|
|
10
10
|
// catalog prevents the prevention layer from drifting behind enforcement. We
|
|
11
|
-
// intentionally exclude only unambiguous tooling state/settings:
|
|
12
|
-
//
|
|
11
|
+
// intentionally exclude only unambiguous tooling state/settings: review-
|
|
12
|
+
// required and policy rules must remain visible to users.
|
|
13
13
|
const AUTO_EXCLUDE_CATEGORIES = new Set(['ephemeral-state', 'local-settings']);
|
|
14
14
|
|
|
15
15
|
export function patternsForRules(rules) {
|
package/src/hook.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { newEngineWithDiagnostics } from './scan.mjs';
|
|
|
8
8
|
import { openRepo, stagedEntries } from './gitx.mjs';
|
|
9
9
|
import { applyExclude, patternsForRules } from './exclude.mjs';
|
|
10
10
|
import { activeGitHook, installedHooks } from './githooks.mjs';
|
|
11
|
+
import { loadConfig } from './state.mjs';
|
|
11
12
|
import { visible } from './report.mjs';
|
|
12
13
|
import { extractRuleset } from './ruleset-text.mjs';
|
|
13
14
|
import { resolvePolicy } from './policy-resolver.mjs';
|
|
@@ -84,7 +85,14 @@ export function hookSessionStart() {
|
|
|
84
85
|
const repo = openRepo();
|
|
85
86
|
const policy = resolvePolicy(repo, { target: 'worktree' });
|
|
86
87
|
const { engine: eng } = newEngineWithDiagnostics(policy.profile, repo.stateDir);
|
|
87
|
-
|
|
88
|
+
const patterns = patternsForRules(eng.rules);
|
|
89
|
+
applyExclude(repo.excludeFile, patterns);
|
|
90
|
+
// A clone that opted into the committed variant gets the same refresh in
|
|
91
|
+
// its worktree .gitignore; every failure degrades silently, same as the
|
|
92
|
+
// exclude write above.
|
|
93
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
94
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
95
|
+
}
|
|
88
96
|
} catch {
|
|
89
97
|
/* not a repo; nothing to exclude */
|
|
90
98
|
}
|
|
@@ -383,11 +391,23 @@ function hookPreToolUse(input) {
|
|
|
383
391
|
// above so a read-only .git/info (CI checkout, read-only volume, a
|
|
384
392
|
// repository owned by another user) cannot decide what is allowed.
|
|
385
393
|
if (repo) {
|
|
394
|
+
const patterns = patternsForRules(eng.rules);
|
|
386
395
|
try {
|
|
387
|
-
applyExclude(repo.excludeFile,
|
|
396
|
+
applyExclude(repo.excludeFile, patterns);
|
|
388
397
|
} catch (e) {
|
|
389
398
|
hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
|
|
390
399
|
}
|
|
400
|
+
// The committed variant of the block gets the same best-effort refresh
|
|
401
|
+
// when this clone opted into it; a failure there is housekeeping too,
|
|
402
|
+
// never part of the verdict.
|
|
403
|
+
try {
|
|
404
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
405
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
const gitignoreWarning = `could not refresh ${join(repo.root, '.gitignore')}: ${e.message}`;
|
|
409
|
+
hygieneWarning = hygieneWarning ? `${hygieneWarning}; ${gitignoreWarning}` : gitignoreWarning;
|
|
410
|
+
}
|
|
391
411
|
}
|
|
392
412
|
// A strict policy cannot make a meaningful guarantee if Git's own guards
|
|
393
413
|
// are explicitly bypassed. Deny before the shell can stage-and-commit in a
|
|
@@ -402,7 +422,7 @@ function hookPreToolUse(input) {
|
|
|
402
422
|
// A preceding command that may have changed the repository or its hooks is
|
|
403
423
|
// also a potential pre-commit bypass. Strict rejects it above; clean and
|
|
404
424
|
// compliance still need the staged-content backstop so a hook mutation
|
|
405
|
-
// cannot
|
|
425
|
+
// cannot sneak a blocked staged file past the guard.
|
|
406
426
|
const commitPrefixRisk = parsed.commands.some((candidate) => (
|
|
407
427
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
408
428
|
&& candidate.prefixRisk
|
|
@@ -426,8 +446,8 @@ function hookPreToolUse(input) {
|
|
|
426
446
|
// them. A build, a test run, ls — those leave pre-commit to answer, and
|
|
427
447
|
// refusing them taught agents to drop the `&&` gate rather than to run the
|
|
428
448
|
// command separately. hiddenBypass stays wider on purpose; it is what keeps
|
|
429
|
-
// the staged-content backstop reading the blobs, so a
|
|
430
|
-
// index still stops the commit here.
|
|
449
|
+
// the staged-content backstop reading the blobs, so a blocked file already
|
|
450
|
+
// in the index still stops the commit here.
|
|
431
451
|
const prefixHookBypass = opaqueCommitRisk
|
|
432
452
|
|| (commitPrefixRisk && (noVerify || bypassHooks || parsed.prefixHooksRisk));
|
|
433
453
|
// The same distinction, for the deny paths that ask "will anything scan this
|
|
@@ -460,7 +480,7 @@ function hookPreToolUse(input) {
|
|
|
460
480
|
const blocks = [];
|
|
461
481
|
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
462
482
|
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
463
|
-
// (gh ... | tail) was scanned and denied as if it staged a
|
|
483
|
+
// (gh ... | tail) was scanned and denied as if it staged a blocked file.
|
|
464
484
|
// opaqueCommitHiding keeps every commit-hiding shape (subshells,
|
|
465
485
|
// substitution, script-feeds, code-executing/unlisted pipe segments) while
|
|
466
486
|
// excluding pipelines of known read-only commands.
|
|
@@ -503,8 +523,8 @@ function hookPreToolUse(input) {
|
|
|
503
523
|
// disables the real pre-commit guard, leaving this hook the only check.
|
|
504
524
|
// When the commit also stages files at commit time (-a/--all/-u/--patch, or
|
|
505
525
|
// a preceding `git add`), those files are neither in stagedPaths(repo) now
|
|
506
|
-
// nor in addPaths, so they were never scanned. Rather than let
|
|
507
|
-
//
|
|
526
|
+
// nor in addPaths, so they were never scanned. Rather than let unscanned
|
|
527
|
+
// content through, deny. (Strict already denied the bypass above.) The rare
|
|
508
528
|
// false-positive on an explicit `git add <path> && git commit --no-verify`
|
|
509
529
|
// is safe-side; drop --no-verify to proceed.
|
|
510
530
|
//
|
|
@@ -512,7 +532,8 @@ function hookPreToolUse(input) {
|
|
|
512
532
|
// a --no-verify inside the inner command, where parsed.noVerify cannot see
|
|
513
533
|
// it (the parser only inspects literal argv). Such an uncertain commit is
|
|
514
534
|
// therefore treated as a potential hook bypass for the clean/compliance
|
|
515
|
-
//
|
|
535
|
+
// staged-content backstop, so a blocked file cannot slip past the guard
|
|
536
|
+
// wrapped in eval.
|
|
516
537
|
const indexReplacement = parsed.commands.some((candidate) => (
|
|
517
538
|
candidate.verb === 'commit' && candidate.indexMutationRisk
|
|
518
539
|
));
|
|
@@ -553,9 +574,9 @@ function hookPreToolUse(input) {
|
|
|
553
574
|
}
|
|
554
575
|
// clean / compliance: advisory only (the git pre-commit unstage is the real
|
|
555
576
|
// enforcement) — except when --no-verify/core.hooksPath bypasses that hook.
|
|
556
|
-
// A bypassed guard is the one case clean cannot delegate to git, so a
|
|
557
|
-
// secret
|
|
558
|
-
//
|
|
577
|
+
// A bypassed guard is the one case clean cannot delegate to git, so a
|
|
578
|
+
// secret-category finding (a local rule pack can still declare one) is
|
|
579
|
+
// denied here; hygiene findings stay advisory.
|
|
559
580
|
const bypassed = Boolean(potentialCommit && hiddenBypass);
|
|
560
581
|
if (bypassed && blocks.some((f) => f.category === 'secret')) {
|
|
561
582
|
return emitDecision(
|
package/src/report.mjs
CHANGED
|
@@ -7,33 +7,6 @@
|
|
|
7
7
|
// for the rest. The JSON report (aimhooman review / --json) is never capped.
|
|
8
8
|
const HUMAN_FINDING_CAP = 20;
|
|
9
9
|
|
|
10
|
-
// UT-06: prefix -> provider for secret.provider-token findings. The rule packs
|
|
11
|
-
// every provider into one pattern set (rules/secrets.json), so the provider
|
|
12
|
-
// name is recovered from the raw matched line at report time — before the
|
|
13
|
-
// redaction below runs. Only the label is ever rendered, never the token.
|
|
14
|
-
// Mirrors the rule's patterns; keep the rule's alternation order.
|
|
15
|
-
const TOKEN_PROVIDERS = [
|
|
16
|
-
['ghp_', 'GitHub'], ['gho_', 'GitHub'], ['ghu_', 'GitHub'], ['ghs_', 'GitHub'],
|
|
17
|
-
['ghr_', 'GitHub'], ['github_pat_', 'GitHub'],
|
|
18
|
-
['glpat-', 'GitLab'],
|
|
19
|
-
['npm_', 'npm'],
|
|
20
|
-
['xoxb-', 'Slack'], ['xoxp-', 'Slack'], ['xoxa-', 'Slack'], ['xoxr-', 'Slack'], ['xoxs-', 'Slack'],
|
|
21
|
-
['sk-ant-', 'Anthropic'],
|
|
22
|
-
['sk-proj-', 'OpenAI'],
|
|
23
|
-
['AIza', 'Google'],
|
|
24
|
-
['sk_live_', 'Stripe'], ['rk_live_', 'Stripe'],
|
|
25
|
-
['hf_', 'Hugging Face'],
|
|
26
|
-
['SG.', 'SendGrid'],
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
function tokenProvider(text) {
|
|
30
|
-
if (!text) return undefined;
|
|
31
|
-
for (const [prefix, provider] of TOKEN_PROVIDERS) {
|
|
32
|
-
if (text.includes(prefix)) return provider;
|
|
33
|
-
}
|
|
34
|
-
return undefined;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
10
|
export function human(findings, tone) {
|
|
38
11
|
if (!findings.length) return '';
|
|
39
12
|
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
@@ -56,14 +29,7 @@ export function human(findings, tone) {
|
|
|
56
29
|
const related = (f.matchedRuleIds || []).filter((id) => id !== f.ruleId);
|
|
57
30
|
const identity = related.length ? `${f.ruleId} (+ ${related.join(', ')})` : f.ruleId;
|
|
58
31
|
const commit = f.commit ? ` [commit ${String(f.commit).slice(0, 12)}]` : '';
|
|
59
|
-
|
|
60
|
-
// token", leaving the developer to guess which credential to revoke.
|
|
61
|
-
// Name the provider in the reason; the token itself stays redacted.
|
|
62
|
-
const provider = f.ruleId === 'secret.provider-token' ? tokenProvider(f.text) : undefined;
|
|
63
|
-
const reason = provider
|
|
64
|
-
? f.reason.replace('provider access token', `provider access token (${provider})`)
|
|
65
|
-
: f.reason;
|
|
66
|
-
out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${reason}\n`;
|
|
32
|
+
out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${f.reason}\n`;
|
|
67
33
|
if (f.text && f.text.trim()) {
|
|
68
34
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
69
35
|
}
|
|
@@ -111,13 +77,22 @@ export function jsonReport(findings, metadata = {}) {
|
|
|
111
77
|
return JSON.stringify({ schema_version: 1, ...metadata, findings: safe }, null, 2);
|
|
112
78
|
}
|
|
113
79
|
|
|
80
|
+
// Built-in secret scanning is gone, but a local rule pack can still declare
|
|
81
|
+
// category "secret"; findings from such a rule keep their matched text out of
|
|
82
|
+
// every report, the same courtesy the built-in rules got.
|
|
114
83
|
function isSensitive(finding) {
|
|
115
84
|
return finding?.category === 'secret'
|
|
116
85
|
|| finding?.matchedRules?.some((match) => match.category === 'secret') === true;
|
|
117
86
|
}
|
|
118
87
|
|
|
119
88
|
// Exit codes: 0 clean, 10 blocked, 11 review required, 31 incomplete scan.
|
|
120
|
-
|
|
89
|
+
// An incomplete scan stops the operation only where no later guard can vouch
|
|
90
|
+
// for the skipped content: on the strict profile, or when the caller is the
|
|
91
|
+
// final ref boundary (failClosedIncomplete). Frictionless profiles warn and
|
|
92
|
+
// continue — the reference-transaction guard still scans introduced commits
|
|
93
|
+
// with failClosedIncomplete set, so the skipped content is checked before any
|
|
94
|
+
// ref moves.
|
|
95
|
+
export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false } = {}) {
|
|
121
96
|
let block = false;
|
|
122
97
|
let review = false;
|
|
123
98
|
for (const f of findings) {
|
|
@@ -125,7 +100,7 @@ export function exitCode(findings, profile, complete = true) {
|
|
|
125
100
|
else if (f.decision === 'review') review = true;
|
|
126
101
|
}
|
|
127
102
|
if (block) return 10;
|
|
128
|
-
if (!complete) return 31;
|
|
103
|
+
if (!complete && (profile === 'strict' || failClosedIncomplete)) return 31;
|
|
129
104
|
if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
|
|
130
105
|
return 0;
|
|
131
106
|
}
|
package/src/scan-session.mjs
CHANGED
|
@@ -52,10 +52,10 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Oversized files: probe the first 8 KB to separate binary from text.
|
|
55
|
-
// A binary file (PSD, WOFF, PNG)
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
55
|
+
// A binary file (PSD, WOFF, PNG) holds no text lines the content rules
|
|
56
|
+
// could match, so it skips as 'binary' (complete). A text file that
|
|
57
|
+
// exceeds the budget is a genuine 'size-limit' skip (incomplete) and the
|
|
58
|
+
// caller must raise the limit to cover it.
|
|
59
59
|
// Cap probing at 16 MiB: cat-file --batch reads the full blob into memory,
|
|
60
60
|
// so probing a 500 MB file just to check for NULs is wasteful. Files above
|
|
61
61
|
// the cap are classified as size-limit without probing.
|
|
@@ -100,25 +100,18 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
100
100
|
// Binary detection still reads the blob. Count every examined byte so
|
|
101
101
|
// later commits cannot reset the total budget merely by using NUL data.
|
|
102
102
|
stats.bytes_scanned += blob.length;
|
|
103
|
-
let matched;
|
|
104
103
|
if (isBinary(blob)) {
|
|
104
|
+
// Content rules match text lines, so a binary blob carries nothing
|
|
105
|
+
// they could fire on. Skipping it loses no coverage: the skip is
|
|
106
|
+
// recorded as 'binary', which does not mark the scan incomplete.
|
|
105
107
|
increment(stats.skipped, 'binary');
|
|
106
108
|
appendPath(stats.skippedPaths, 'binary', entry.path, blob.length);
|
|
107
|
-
|
|
108
|
-
// signatures are ASCII byte sequences, so latin1 preserves a
|
|
109
|
-
// one-byte-to-one-code-unit view and keeps the existing byte limits.
|
|
110
|
-
// Stripping NULs is what defeats hiding credential material behind
|
|
111
|
-
// them: one injected NUL breaks a signature, and a multi-byte
|
|
112
|
-
// encoding like UTF-16 injects one per character.
|
|
113
|
-
matched = engine.checkContent(entry.path, blob.toString('latin1').replace(/\0/g, ''), {
|
|
114
|
-
categories: ['secret'],
|
|
115
|
-
});
|
|
116
|
-
} else {
|
|
117
|
-
stats.files_scanned += 1;
|
|
118
|
-
const ranges = lineRanges.get(entry.path);
|
|
119
|
-
matched = engine.checkContent(entry.path, blob.toString('utf8'),
|
|
120
|
-
ranges && ranges.length ? { lineRanges: ranges } : {});
|
|
109
|
+
continue;
|
|
121
110
|
}
|
|
111
|
+
stats.files_scanned += 1;
|
|
112
|
+
const ranges = lineRanges.get(entry.path);
|
|
113
|
+
const matched = engine.checkContent(entry.path, blob.toString('utf8'),
|
|
114
|
+
ranges && ranges.length ? { lineRanges: ranges } : {});
|
|
122
115
|
stats.findings_total += matched.length;
|
|
123
116
|
for (const finding of matched) {
|
|
124
117
|
if (findings.length >= limits.maxFindings) continue;
|
|
@@ -253,8 +246,8 @@ function isBinary(buffer) {
|
|
|
253
246
|
//
|
|
254
247
|
// Entries without commit/parents (tracked snapshots, staged views, root
|
|
255
248
|
// commits) are omitted from the map; the caller treats a missing key as "scan
|
|
256
|
-
// the whole blob" — the safe side
|
|
257
|
-
//
|
|
249
|
+
// the whole blob" — the safe side is to scan MORE, not less, so a failure to
|
|
250
|
+
// compute hunks falls back to the pre-W4 behaviour.
|
|
258
251
|
function collectLineRanges(repo, entries) {
|
|
259
252
|
// Group text-candidate entries by their first parent to batch the diffs.
|
|
260
253
|
// Key by `${commit}\0${parent}` so a merge with multiple parents produces
|
package/src/scan-target.mjs
CHANGED
|
@@ -389,8 +389,8 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
389
389
|
// Content scanning can target a narrower set than the path check. When
|
|
390
390
|
// contentEntries is provided (e.g. only changed files in a commit), read
|
|
391
391
|
// blobs only for those entries instead of the full snapshot. Path-based
|
|
392
|
-
// rules already ran on the full tree above, so
|
|
393
|
-
//
|
|
392
|
+
// rules already ran on the full tree above, so a path-only finding still
|
|
393
|
+
// fires even when its blob isn't re-read.
|
|
394
394
|
const contentScannable = (options.contentEntries ?? entries)
|
|
395
395
|
.filter((entry) => entry.status !== 'D' && entry.type !== 'deleted');
|
|
396
396
|
const remaining = accumulator.remaining();
|
|
@@ -405,14 +405,14 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
405
405
|
function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
|
|
406
406
|
for (const entry of entries) {
|
|
407
407
|
// A deleted path is gone, so only structural policy rules (agent
|
|
408
|
-
// instructions, project policy) can still matter: deleting a
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// would slip past the destination scan, which only catches
|
|
413
|
-
//
|
|
414
|
-
// bytes now live) so clean-profile repair unstages the
|
|
415
|
-
// the
|
|
408
|
+
// instructions, project policy) can still matter: deleting a flagged
|
|
409
|
+
// file is hygiene, not a violation. A renamed-away path is different —
|
|
410
|
+
// its content survives under a new name, so a path-only secret-category
|
|
411
|
+
// rule from a local pack must still fire or a `git mv` to a neutral
|
|
412
|
+
// name would slip past the destination scan, which only catches
|
|
413
|
+
// content-shaped matches. That finding is reported on the destination
|
|
414
|
+
// path (where the bytes now live) so clean-profile repair unstages the
|
|
415
|
+
// blob that carries the match rather than the old name.
|
|
416
416
|
const deleted = entry.status === 'D' || entry.type === 'deleted';
|
|
417
417
|
const renamed = entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path;
|
|
418
418
|
if (!deleted && !renamed) continue;
|