@rmyndharis/aimhooman 0.2.0 → 0.3.1

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/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/args.mjs CHANGED
@@ -27,7 +27,7 @@ export function parseArguments(args, definition = {}) {
27
27
  const inlineValue = equal > 0 ? argument.slice(equal + 1) : undefined;
28
28
  const option = byName.get(name);
29
29
  if (!option) throw new ArgumentError(`unknown option "${name}"`);
30
- if (seen.has(option.key) && !option.repeatable) {
30
+ if (seen.has(option.key)) {
31
31
  throw new ArgumentError(`option "${name}" may only be used once`);
32
32
  }
33
33
  seen.add(option.key);
@@ -49,12 +49,7 @@ export function parseArguments(args, definition = {}) {
49
49
  if (option.choices && !option.choices.includes(value)) {
50
50
  throw new ArgumentError(`invalid value for ${name}: "${value}"`);
51
51
  }
52
- if (option.repeatable) {
53
- if (!options[option.key]) options[option.key] = [];
54
- options[option.key].push(value);
55
- } else {
56
- options[option.key] = value;
57
- }
52
+ options[option.key] = value;
58
53
  continue;
59
54
  }
60
55
  positionals.push(argument);
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/gitx.mjs CHANGED
@@ -1,17 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import {
3
- cpSync,
4
- lstatSync,
5
- mkdirSync,
6
- mkdtempSync,
7
- readFileSync,
8
- realpathSync,
9
- readdirSync,
10
- readlinkSync,
11
- renameSync,
12
- rmSync,
13
- symlinkSync,
14
- } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
15
3
  import { isAbsolute, join, resolve } from 'node:path';
16
4
  import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
17
5
 
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,25 @@ 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
+ // Each refresh is housekeeping with its own silent failure, mirroring
90
+ // the pre-tool-use path: a read-only .git/info must not skip the
91
+ // worktree .gitignore refresh below, and neither may decide anything.
92
+ try {
93
+ applyExclude(repo.excludeFile, patterns);
94
+ } catch {
95
+ /* exclude refresh is best effort */
96
+ }
97
+ // A clone that opted into the committed variant gets the same refresh in
98
+ // its worktree .gitignore; every failure degrades silently, same as the
99
+ // exclude write above.
100
+ try {
101
+ if (loadConfig(repo.stateDir).gitignore?.enabled) {
102
+ applyExclude(join(repo.root, '.gitignore'), patterns);
103
+ }
104
+ } catch {
105
+ /* gitignore refresh is best effort */
106
+ }
88
107
  } catch {
89
108
  /* not a repo; nothing to exclude */
90
109
  }
@@ -383,11 +402,23 @@ function hookPreToolUse(input) {
383
402
  // above so a read-only .git/info (CI checkout, read-only volume, a
384
403
  // repository owned by another user) cannot decide what is allowed.
385
404
  if (repo) {
405
+ const patterns = patternsForRules(eng.rules);
386
406
  try {
387
- applyExclude(repo.excludeFile, patternsForRules(eng.rules));
407
+ applyExclude(repo.excludeFile, patterns);
388
408
  } catch (e) {
389
409
  hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
390
410
  }
411
+ // The committed variant of the block gets the same best-effort refresh
412
+ // when this clone opted into it; a failure there is housekeeping too,
413
+ // never part of the verdict.
414
+ try {
415
+ if (loadConfig(repo.stateDir).gitignore?.enabled) {
416
+ applyExclude(join(repo.root, '.gitignore'), patterns);
417
+ }
418
+ } catch (e) {
419
+ const gitignoreWarning = `could not refresh ${join(repo.root, '.gitignore')}: ${e.message}`;
420
+ hygieneWarning = hygieneWarning ? `${hygieneWarning}; ${gitignoreWarning}` : gitignoreWarning;
421
+ }
391
422
  }
392
423
  // A strict policy cannot make a meaningful guarantee if Git's own guards
393
424
  // are explicitly bypassed. Deny before the shell can stage-and-commit in a
@@ -402,7 +433,7 @@ function hookPreToolUse(input) {
402
433
  // A preceding command that may have changed the repository or its hooks is
403
434
  // also a potential pre-commit bypass. Strict rejects it above; clean and
404
435
  // compliance still need the staged-content backstop so a hook mutation
405
- // cannot expose a secret that was already in the index.
436
+ // cannot sneak a blocked staged file past the guard.
406
437
  const commitPrefixRisk = parsed.commands.some((candidate) => (
407
438
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
408
439
  && candidate.prefixRisk
@@ -426,8 +457,8 @@ function hookPreToolUse(input) {
426
457
  // them. A build, a test run, ls — those leave pre-commit to answer, and
427
458
  // refusing them taught agents to drop the `&&` gate rather than to run the
428
459
  // 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.
460
+ // the staged-content backstop reading the blobs, so a blocked file already
461
+ // in the index still stops the commit here.
431
462
  const prefixHookBypass = opaqueCommitRisk
432
463
  || (commitPrefixRisk && (noVerify || bypassHooks || parsed.prefixHooksRisk));
433
464
  // The same distinction, for the deny paths that ask "will anything scan this
@@ -460,7 +491,7 @@ function hookPreToolUse(input) {
460
491
  const blocks = [];
461
492
  // potentialCommit treats a command as leading to a commit. uncertainShell
462
493
  // 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.
494
+ // (gh ... | tail) was scanned and denied as if it staged a blocked file.
464
495
  // opaqueCommitHiding keeps every commit-hiding shape (subshells,
465
496
  // substitution, script-feeds, code-executing/unlisted pipe segments) while
466
497
  // excluding pipelines of known read-only commands.
@@ -503,8 +534,8 @@ function hookPreToolUse(input) {
503
534
  // disables the real pre-commit guard, leaving this hook the only check.
504
535
  // When the commit also stages files at commit time (-a/--all/-u/--patch, or
505
536
  // 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
537
+ // nor in addPaths, so they were never scanned. Rather than let unscanned
538
+ // content through, deny. (Strict already denied the bypass above.) The rare
508
539
  // false-positive on an explicit `git add <path> && git commit --no-verify`
509
540
  // is safe-side; drop --no-verify to proceed.
510
541
  //
@@ -512,7 +543,8 @@ function hookPreToolUse(input) {
512
543
  // a --no-verify inside the inner command, where parsed.noVerify cannot see
513
544
  // it (the parser only inspects literal argv). Such an uncertain commit is
514
545
  // 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.
546
+ // staged-content backstop, so a blocked file cannot slip past the guard
547
+ // wrapped in eval.
516
548
  const indexReplacement = parsed.commands.some((candidate) => (
517
549
  candidate.verb === 'commit' && candidate.indexMutationRisk
518
550
  ));
@@ -553,9 +585,9 @@ function hookPreToolUse(input) {
553
585
  }
554
586
  // clean / compliance: advisory only (the git pre-commit unstage is the real
555
587
  // 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.
588
+ // A bypassed guard is the one case clean cannot delegate to git, so a
589
+ // secret-category finding (a local rule pack can still declare one) is
590
+ // denied here; hygiene findings stay advisory.
559
591
  const bypassed = Boolean(potentialCommit && hiddenBypass);
560
592
  if (bypassed && blocks.some((f) => f.category === 'secret')) {
561
593
  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,22 +29,16 @@ 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
  }
70
36
  // Render the whole remediation array, not just the first entry. Several
71
- // rules carry a second line (e.g. "rotate the key if it was ever exposed")
72
- // that the previous single-index render dropped silently. UT-08: print a
73
- // rule's fix once — a repeated rule (20 hits of the same secret rule)
74
- // used to reprint the identical fix block for every finding.
37
+ // rules carry a second line (e.g. "or unstage if it is personal" on
38
+ // generic.agent-instructions) that the previous single-index render
39
+ // dropped silently. UT-08: print a rule's fix once — a repeated rule
40
+ // (20 hits of the same path rule) used to reprint the identical fix
41
+ // block for every finding.
75
42
  const remedies = f.remediation || [];
76
43
  if (remedies.length && fixesPrinted.has(f.ruleId)) {
77
44
  out += ` fix: as above for ${f.ruleId}\n`;
@@ -111,13 +78,22 @@ export function jsonReport(findings, metadata = {}) {
111
78
  return JSON.stringify({ schema_version: 1, ...metadata, findings: safe }, null, 2);
112
79
  }
113
80
 
81
+ // Built-in secret scanning is gone, but a local rule pack can still declare
82
+ // category "secret"; findings from such a rule keep their matched text out of
83
+ // every report, the same courtesy the built-in rules got.
114
84
  function isSensitive(finding) {
115
85
  return finding?.category === 'secret'
116
86
  || finding?.matchedRules?.some((match) => match.category === 'secret') === true;
117
87
  }
118
88
 
119
89
  // Exit codes: 0 clean, 10 blocked, 11 review required, 31 incomplete scan.
120
- export function exitCode(findings, profile, complete = true) {
90
+ // An incomplete scan stops the operation only where no later guard can vouch
91
+ // for the skipped content: on the strict profile, or when the caller is the
92
+ // final ref boundary (failClosedIncomplete). Frictionless profiles warn and
93
+ // continue — the reference-transaction guard still scans introduced commits
94
+ // with failClosedIncomplete set, so the skipped content is checked before any
95
+ // ref moves.
96
+ export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false } = {}) {
121
97
  let block = false;
122
98
  let review = false;
123
99
  for (const f of findings) {
@@ -125,7 +101,7 @@ export function exitCode(findings, profile, complete = true) {
125
101
  else if (f.decision === 'review') review = true;
126
102
  }
127
103
  if (block) return 10;
128
- if (!complete) return 31;
104
+ if (!complete && (profile === 'strict' || failClosedIncomplete)) return 31;
129
105
  if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
130
106
  return 0;
131
107
  }
@@ -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;
@@ -139,7 +132,7 @@ export function scanEntries(repo, engine, entries, options = {}) {
139
132
  }
140
133
 
141
134
  const incompleteReasons = new Set([
142
- 'metadata-unavailable', 'size-limit', 'total-byte-limit', 'object-read-failed',
135
+ 'metadata-unavailable', 'size-limit', 'total-byte-limit',
143
136
  'missing-object', 'unexpected-object', 'finding-limit', 'local-input-limit',
144
137
  ]);
145
138
  const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
@@ -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;