@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.
@@ -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`, and with repair before rejection on the default ordinary commit path.**
6
- Plugin startup provides best-effort prevention without `init`; complete Git-boundary
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, never the user's `.gitignore`).
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, remains in the
17
- pinned tree, or cannot be scanned completely, the operation stops.
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 `.git/info/exclude`
45
- (local, not committed, not `.gitignore`).
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 or incomplete scan |
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, or if any scan is incomplete.
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
- secrets without bricking the branch.
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`, `rules/secrets.json`. Covers Claude, Codex, Copilot, Cursor, Aider, SpecStory,
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. Secrets and review paths remain visible.
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, and incomplete staged-content scans stop every profile.
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 `.env`, then `git commit`), it exits 10 so Git stops, the
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "aimhooman: keep AI session files, secrets, and attribution out of commits",
2
+ "description": "aimhooman: keep AI session files and attribution out of commits",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.2.0",
4
- "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
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": { "enum": ["path", "rule", "secret-path"] },
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
- secrets (.env, private keys, .aws/credentials), and unwanted AI attribution in
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 instead.
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 this at commit time, so a blocked commit means a real
65
- violation to fix, not a check to bypass. The rule is simple: AI works, hoomans
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: secret and
12
- // review-required rules must remain visible to users.
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
- applyExclude(repo.excludeFile, patternsForRules(eng.rules));
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, patternsForRules(eng.rules));
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 expose a secret that was already in the index.
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 secret already in the
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 secret.
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 an unscanned
507
- // secret through, deny. (Strict already denied the bypass above.) The rare
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
- // secret backstop, so a secret cannot slip past the guard wrapped in eval.
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 blocked
557
- // secret is denied here, matching the documented "secret stops the commit when
558
- // automatic unstaging fails"; hygiene findings stay advisory.
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
- // UT-06: a provider-token finding used to say only "a provider access
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
- export function exitCode(findings, profile, complete = true) {
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
  }
@@ -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) can't hide text-pattern secrets that
56
- // the full content scan would find, so it skips as 'binary' (complete).
57
- // A text file that exceeds the budget is a genuine 'size-limit' skip
58
- // (incomplete) and the caller must raise the limit to cover it.
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
- // Binary classification only skips text-oriented policy rules. Secret
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 for a secret scanner is to scan MORE, not
257
- // less, so a failure to compute hunks falls back to the pre-W4 behaviour.
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
@@ -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 secrets like .env are still
393
- // caught even when their blob isn't re-read.
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 secret or a
409
- // session file is hygiene, not a violation. A renamed-away path is
410
- // different — its content survives under a new name, so a path-only
411
- // secret (e.g. .env) must still fire or a `git mv` to a neutral name
412
- // would slip past the destination scan, which only catches content-shaped
413
- // secrets. That finding is reported on the destination path (where the
414
- // bytes now live) so clean-profile repair unstages the blob that carries
415
- // the secret rather than the old name.
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;