@rmyndharis/aimhooman 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
package/rules/paths.json CHANGED
@@ -253,7 +253,7 @@
253
253
  },
254
254
  {
255
255
  "id": "secret.private-key",
256
- "version": 2,
256
+ "version": 3,
257
257
  "provider": "generic",
258
258
  "category": "secret",
259
259
  "confidence": "high",
@@ -274,10 +274,11 @@
274
274
  "strict": "block",
275
275
  "compliance": "block"
276
276
  },
277
- "reason": "Private key material must never be committed.",
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
278
  "remediation": [
279
279
  "git restore --staged <path>",
280
- "rotate the key if it was ever exposed"
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"
281
282
  ]
282
283
  },
283
284
  {
@@ -391,10 +392,22 @@
391
392
  "category": "ephemeral-state",
392
393
  "confidence": "high",
393
394
  "kind": "path",
394
- "match": { "path_case": "insensitive", "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
395
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
395
+ "match": {
396
+ "path_case": "insensitive",
397
+ "paths": [
398
+ ".playwright-mcp/**",
399
+ "**/.playwright-mcp/**"
400
+ ]
401
+ },
402
+ "actions": {
403
+ "clean": "block",
404
+ "strict": "block",
405
+ "compliance": "block"
406
+ },
396
407
  "reason": "Playwright MCP session artifacts are local, not repository content.",
397
- "remediation": ["git restore --staged <path>"]
408
+ "remediation": [
409
+ "git restore --staged <path>"
410
+ ]
398
411
  },
399
412
  {
400
413
  "id": "remember.state",
@@ -403,10 +416,22 @@
403
416
  "category": "ephemeral-state",
404
417
  "confidence": "high",
405
418
  "kind": "path",
406
- "match": { "path_case": "insensitive", "paths": [".remember/**", "**/.remember/**"] },
407
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
419
+ "match": {
420
+ "path_case": "insensitive",
421
+ "paths": [
422
+ ".remember/**",
423
+ "**/.remember/**"
424
+ ]
425
+ },
426
+ "actions": {
427
+ "clean": "block",
428
+ "strict": "block",
429
+ "compliance": "block"
430
+ },
408
431
  "reason": "Remember second-brain data is local, not repository content.",
409
- "remediation": ["git restore --staged <path>"]
432
+ "remediation": [
433
+ "git restore --staged <path>"
434
+ ]
410
435
  },
411
436
  {
412
437
  "id": "superpowers.state",
@@ -415,10 +440,22 @@
415
440
  "category": "ephemeral-state",
416
441
  "confidence": "high",
417
442
  "kind": "path",
418
- "match": { "path_case": "insensitive", "paths": [".superpowers/**", "**/.superpowers/**"] },
419
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
443
+ "match": {
444
+ "path_case": "insensitive",
445
+ "paths": [
446
+ ".superpowers/**",
447
+ "**/.superpowers/**"
448
+ ]
449
+ },
450
+ "actions": {
451
+ "clean": "block",
452
+ "strict": "block",
453
+ "compliance": "block"
454
+ },
420
455
  "reason": "Superpowers plugin state is local, not repository content.",
421
- "remediation": ["git restore --staged <path>"]
456
+ "remediation": [
457
+ "git restore --staged <path>"
458
+ ]
422
459
  },
423
460
  {
424
461
  "id": "agent.state",
@@ -427,9 +464,21 @@
427
464
  "category": "ephemeral-state",
428
465
  "confidence": "high",
429
466
  "kind": "path",
430
- "match": { "path_case": "insensitive", "paths": [".agent/**", "**/.agent/**"] },
431
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
467
+ "match": {
468
+ "path_case": "insensitive",
469
+ "paths": [
470
+ ".agent/**",
471
+ "**/.agent/**"
472
+ ]
473
+ },
474
+ "actions": {
475
+ "clean": "block",
476
+ "strict": "block",
477
+ "compliance": "block"
478
+ },
432
479
  "reason": "Generic agent state is local, not repository content.",
433
- "remediation": ["git restore --staged <path>"]
480
+ "remediation": [
481
+ "git restore --staged <path>"
482
+ ]
434
483
  }
435
- ]
484
+ ]
@@ -45,14 +45,14 @@
45
45
  },
46
46
  {
47
47
  "id": "secret.aws-key-content",
48
- "version": 2,
48
+ "version": 3,
49
49
  "provider": "aws",
50
50
  "category": "secret",
51
51
  "confidence": "high",
52
52
  "kind": "code",
53
53
  "match": {
54
54
  "content": [
55
- "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])",
55
+ "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?<!EXAMPLEKEY)(?![A-Za-z0-9/+=])",
56
56
  "\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
57
57
  ]
58
58
  },
@@ -89,4 +89,4 @@
89
89
  "Remove the token from the index, revoke or rotate it, and use the provider's secret store."
90
90
  ]
91
91
  }
92
- ]
92
+ ]
package/src/githooks.mjs CHANGED
@@ -43,14 +43,25 @@ function hooksDir(repo) {
43
43
  // Inside the worktree is not the same as ours. A hooks directory Git tracks
44
44
  // (husky, the vanilla .githooks pattern) is repository content: a dispatcher
45
45
  // written there stages this machine's absolute CLI, Node, and PATH for
46
- // everyone who clones.
47
- const tracked = inside && trackedPath(repo, dir);
46
+ // everyone who clones. An UNTRACKED hooks directory inside the worktree
47
+ // (a freshly created `.husky` before the first commit, a team-local
48
+ // `.team-hooks`) is repository content in waiting: the next `git add`
49
+ // stages it, carrying the same machine-local paths into history. Treat both
50
+ // as repository content so neither receives a dispatcher — UNLESS the team
51
+ // has explicitly excluded the path (.gitignore or .git/info/exclude), which
52
+ // is the opt-in that says "this hooks dir stays local". A hooks path inside
53
+ // `.git/` (the default, or a custom one) is local Git plumbing and is safe
54
+ // to install into regardless.
55
+ const worktreeContent = inside && !insideGitDir(repo, dir) && !gitIgnored(repo, dir);
56
+ const trackedByGit = inside && trackedPath(repo, dir);
57
+ const tracked = trackedByGit || worktreeContent;
48
58
  const repositoryOwned = inside && !tracked;
49
59
  const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
50
60
  if (shared) {
51
61
  const where = scope ? `${scope} scope` : 'a non-local scope';
52
62
  let reason = where;
53
- if (tracked) reason = `${where}, tracked by this repository`;
63
+ if (trackedByGit) reason = `${where}, tracked by this repository`;
64
+ else if (worktreeContent) reason = `${where}, inside the worktree so Git will track it on the next add (add it to .gitignore or .git/info/exclude to manage it locally)`;
54
65
  else if (localScope && !inside) reason = `${where}, outside this repository`;
55
66
  return {
56
67
  dir,
@@ -106,10 +117,34 @@ function trackedPath(repo, path) {
106
117
  }
107
118
  }
108
119
 
120
+ // gitIgnored reports whether Git would ignore path (via .gitignore,
121
+ // .git/info/exclude, or core.excludesfile). This is the opt-in that lets a team
122
+ // keep a worktree hooks dir local: an excluded path will not be staged by the
123
+ // next `git add`, so a dispatcher written there cannot leak machine-local
124
+ // absolute paths into history. Used to decide whether a worktree hooksPath is
125
+ // repository content in waiting (refuse) or intentionally local (install). A
126
+ // Git that cannot answer counts as NOT ignored, so we fail closed and refuse
127
+ // (the safer side for a leak that writes machine paths into shared history).
128
+ function gitIgnored(repo, path) {
129
+ try {
130
+ execFileSync(
131
+ 'git',
132
+ ['check-ignore', '--quiet', '--', String(path)],
133
+ { cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS },
134
+ );
135
+ // git check-ignore exits 0 when the path IS ignored, 1 when it is not.
136
+ return true;
137
+ } catch (error) {
138
+ // Non-zero exit means "not ignored" (exit 1) or "git failed". Either way,
139
+ // treat as not-ignored so the worktree-content guard stays on.
140
+ return false;
141
+ }
142
+ }
143
+
109
144
  function canonicalPath(path) {
110
145
  let current = resolve(path);
111
146
  const tail = [];
112
- for (;;) {
147
+ for (; ;) {
113
148
  try {
114
149
  return resolve(realpathSync(current), ...tail);
115
150
  } catch (error) {
@@ -631,9 +666,19 @@ function hookScript(name, cmd, cliPath, chainedPath) {
631
666
  ) || exit $?
632
667
  `
633
668
  : '';
669
+ // The chained predecessor is sourced in a subshell, not exec'd. Git runs the
670
+ // dispatcher with $0 set to the original hook path (the dispatcher replaced
671
+ // it in place), and a subshell inherits $0, so sourcing lets the predecessor
672
+ // see the original $0 and resolve $(dirname "$0") to its real directory —
673
+ // the pattern husky and vanilla .githooks hooks use to find sibling scripts.
674
+ // Exec'ing "$CHAINED" would set the predecessor's $0 to the backup path and
675
+ // break that resolution. Sourcing runs the predecessor in this dispatcher's
676
+ // shell (/bin/sh -p), so its shebang is honoured only for sh-compatible
677
+ // scripts; bash-only predecessors are out of scope. exit inside the
678
+ // predecessor exits the subshell and propagates via || exit $?.
634
679
  const chainedInvocation = name === 'reference-transaction'
635
- ? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | "$CHAINED" "$@" || exit $?`
636
- : ` "$CHAINED" "$@" || exit $?`;
680
+ ? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | ( . "$CHAINED" ) || exit $?`
681
+ : ` ( . "$CHAINED" ) || exit $?`;
637
682
  const aimCommand = name === 'commit-msg'
638
683
  ? 'commitmsg "$1" --tree "$AIMHOOMAN_COMMIT_TREE"'
639
684
  : name === 'reference-transaction'
package/src/gitx.mjs CHANGED
@@ -177,6 +177,16 @@ export function stagedPaths(repo) {
177
177
  ], repo.root));
178
178
  }
179
179
 
180
+ // stagedTreeSha writes the index to a tree object and returns its SHA, the same
181
+ // value `git write-tree` produces in the commit-msg dispatcher. Used by the
182
+ // pre-commit/commit-msg marker dedup (W5): pre-commit records this sha when it
183
+ // scans clean, and commit-msg skips its duplicate tree scan when the sha still
184
+ // matches. The index is unchanged between the two hooks in a normal commit, so
185
+ // the sha is stable; any staging mutation invalidates it automatically.
186
+ export function stagedTreeSha(repo) {
187
+ return gitStr(['write-tree'], repo.root);
188
+ }
189
+
180
190
  // Unmerged index entries have only stages 1-3 and are omitted by the ordinary
181
191
  // cached diff. Expose their paths so callers cannot mistake a conflicted index
182
192
  // for a complete, clean staged snapshot.
package/src/hook.mjs CHANGED
@@ -447,10 +447,16 @@ function hookPreToolUse(input) {
447
447
  // stdin) can hide a commit or run arbitrary commands, and there is no argv
448
448
  // to read a --no-verify out of. The earlier message reused the commit text
449
449
  // above, which told a developer to "retry the commit" for a command that
450
- // was not a commit at all — this names the real shape instead.
450
+ // was not a commit at all — this names the real shape instead. The message
451
+ // is conditional on whether a pipe is actually present: opaque syntax
452
+ // without a pipe (a subshell, a brace group, command substitution) should
453
+ // not tell the developer to "drop the `| bash` segment" on a command that
454
+ // has no `|`.
451
455
  const opaquePipelineReason = commit
452
456
  ? unmodelledPrefixReason
453
- : 'aimhooman cannot prove this pipeline is read-only: a shell or code-executing segment can hide a commit or run arbitrary commands. Drop that segment (for example the `| bash`) and run the pieces separately.';
457
+ : hasUnquotedPipe(executorCommand)
458
+ ? 'aimhooman cannot prove this pipeline is read-only: a shell or code-executing segment can hide a commit or run arbitrary commands. Drop that segment (for example the `| bash`) and run the pieces separately.'
459
+ : 'aimhooman cannot prove this command is read-only: it uses shell syntax (a subshell, command substitution, script-feed redirect, background job, or brace group) that can hide a commit or run arbitrary commands. Run the pieces separately.';
454
460
  const blocks = [];
455
461
  // potentialCommit treats a command as leading to a commit. uncertainShell
456
462
  // was too broad: it flagged any pipe, so a benign read-only pipeline
@@ -1961,7 +1967,18 @@ function hasUncertainShellSyntax(command) {
1961
1967
  quote = char;
1962
1968
  continue;
1963
1969
  }
1964
- if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}' || char === '<' || char === '>') return true;
1970
+ // `{`/`}` are ambiguous: brace expansion (`{a,b}`, `{owner}`, `x.{js,ts}`)
1971
+ // is argument-level and cannot hide a commit or feed code into a pipe
1972
+ // sink, while a brace group (`{ list; }`) is control flow and can. Brace
1973
+ // expansion touches the surrounding word (no separator before the `{`, no
1974
+ // space/`;` after it); a brace-group `{` stands as its own token. PowerShell
1975
+ // `{ }` script blocks are handled separately via the non-POSIX executor
1976
+ // path, so this POSIX distinction never loosens that.
1977
+ if (char === '{' || char === '}') {
1978
+ if (isBraceGroupToken(command, i)) return true;
1979
+ continue;
1980
+ }
1981
+ if (char === '`' || char === '(' || char === ')' || char === '<' || char === '>') return true;
1965
1982
  if (char === '|' && command[i + 1] !== '|') return true;
1966
1983
  if (char === '&' && command[i + 1] !== '&' && command[i - 1] !== '&') return true;
1967
1984
  }
@@ -1971,6 +1988,57 @@ function hasUncertainShellSyntax(command) {
1971
1988
  });
1972
1989
  }
1973
1990
 
1991
+ // BRACE_GROUP_LEAD chars may appear immediately before a brace-group token.
1992
+ // `:` is included so `cmd: { ... }` is handled, though it is not POSIX shell.
1993
+ const BRACE_GROUP_LEAD = new Set(['', ' ', '\t', '\n', ';', '&', '|', '(', ')']);
1994
+ // BRACE_GROUP_OPEN_TRAIL chars may follow a `{` that opens a brace group: the
1995
+ // reserved word `{` must be followed by whitespace, `;`, or end of string.
1996
+ const BRACE_GROUP_OPEN_TRAIL = new Set([' ', '\t', '\n', ';', '']);
1997
+ // BRACE_GROUP_CLOSE_LEAD chars precede a `}` that closes a brace group: the
1998
+ // closer must follow `;` or whitespace (the end of the group body).
1999
+ const BRACE_GROUP_CLOSE_LEAD = new Set([' ', '\t', '\n', ';', '']);
2000
+
2001
+ // isBraceGroupToken reports whether `{`/`}` at position i in command is a
2002
+ // POSIX brace-group token (reserved-word `{` or its closer `}`) rather than a
2003
+ // brace-expansion character. Brace expansion `{a,b}` / `{owner}` / `x.{js,ts}`
2004
+ // is argument-level and cannot feed code into a pipe sink or run a subcommand,
2005
+ // so it must not make a command "opaque". A brace group `{ list; }` is
2006
+ // control flow and can, so it stays opaque. PowerShell script blocks are
2007
+ // handled by the non-POSIX executor path, not here.
2008
+ function isBraceGroupToken(command, i) {
2009
+ const char = command[i];
2010
+ const prev = command[i - 1] ?? '';
2011
+ const next = command[i + 1] ?? '';
2012
+ if (char === '{') {
2013
+ if (!BRACE_GROUP_LEAD.has(prev)) return false;
2014
+ if (!BRACE_GROUP_OPEN_TRAIL.has(next)) return false;
2015
+ return true;
2016
+ }
2017
+ // `}` closer: must follow `;` or whitespace.
2018
+ return BRACE_GROUP_CLOSE_LEAD.has(prev);
2019
+ }
2020
+
2021
+ // hasUnquotedPipe reports whether command contains a pipe `|` that is not
2022
+ // `||`, outside of any quote. Used to decide whether the opaque-pipeline deny
2023
+ // message should mention a pipe at all — the original message always named
2024
+ // `| bash` even for commands with no pipe.
2025
+ function hasUnquotedPipe(command) {
2026
+ let quote = '';
2027
+ let escaped = false;
2028
+ for (let i = 0; i < command.length; i += 1) {
2029
+ const char = command[i];
2030
+ if (escaped) { escaped = false; continue; }
2031
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
2032
+ if (quote) {
2033
+ if (char === quote) quote = '';
2034
+ continue;
2035
+ }
2036
+ if (char === "'" || char === '"') { quote = char; continue; }
2037
+ if (char === '|' && command[i + 1] !== '|') return true;
2038
+ }
2039
+ return false;
2040
+ }
2041
+
1974
2042
  function shellUnits(s) {
1975
2043
  const out = [];
1976
2044
  let segment = '';
@@ -2146,7 +2214,14 @@ function containsOpaquePipeLexeme(command) {
2146
2214
  continue;
2147
2215
  }
2148
2216
  if (char === "'" || char === '"') { quote = char; continue; }
2149
- if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}') return true;
2217
+ // Brace expansion (`{a,b}`, `{owner}`) is not opaque: it only expands to
2218
+ // arguments for the same command and cannot feed code into a pipe sink.
2219
+ // Brace groups (`{ list; }`) are opaque. See isBraceGroupToken.
2220
+ if (char === '{' || char === '}') {
2221
+ if (isBraceGroupToken(command, i)) return true;
2222
+ continue;
2223
+ }
2224
+ if (char === '`' || char === '(' || char === ')') return true;
2150
2225
  if (char === '$' && (next === '(' || next === '{')) return true;
2151
2226
  if (char === '<' || char === ';') return true;
2152
2227
  if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
package/src/report.mjs CHANGED
@@ -1,5 +1,12 @@
1
1
  // Human-facing and JSON reporting for aimhooman findings.
2
2
 
3
+ // HUMAN_FINDING_CAP bounds the per-invocation stderr output. A scan that fires
4
+ // many findings of the same rule (a vendored OpenSSL corpus can produce 99) used
5
+ // to emit hundreds of near-identical lines with no truncation marker. The cap
6
+ // prints the first HUMAN_FINDING_CAP finding blocks, then a single summary line
7
+ // for the rest. The JSON report (aimhooman review / --json) is never capped.
8
+ const HUMAN_FINDING_CAP = 20;
9
+
3
10
  export function human(findings, tone) {
4
11
  if (!findings.length) return '';
5
12
  let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
@@ -8,6 +15,13 @@ export function human(findings, tone) {
8
15
  for (const f of findings) {
9
16
  if (f.decision === 'block') block += 1;
10
17
  else if (f.decision === 'review') review += 1;
18
+ }
19
+ // Render findings in order up to the cap, then collapse the rest into one
20
+ // summary line. Keeps the first findings fully visible (the actionable ones)
21
+ // while bounding stderr for repeated-rule scans.
22
+ const shown = findings.slice(0, HUMAN_FINDING_CAP);
23
+ const hidden = findings.length - shown.length;
24
+ for (const f of shown) {
11
25
  const loc = f.path
12
26
  ? `${visible(f.path)}${f.line ? `:${f.line}` : ''}`
13
27
  : (f.line ? `commit message line ${f.line}` : '');
@@ -18,10 +32,21 @@ export function human(findings, tone) {
18
32
  if (f.text && f.text.trim()) {
19
33
  out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
20
34
  }
21
- if (f.remediation?.length) out += ` fix: ${f.remediation[0]}\n`;
35
+ // Render the whole remediation array, not just the first entry. Several
36
+ // rules carry a second line (e.g. "rotate the key if it was ever exposed")
37
+ // that the previous single-index render dropped silently.
38
+ for (const remedy of (f.remediation || [])) {
39
+ out += ` fix: ${remedy}\n`;
40
+ }
22
41
  out += '\n';
23
42
  }
24
- out += `${findings.length} findings: ${block} block, ${review} review\n`;
43
+ if (hidden > 0) {
44
+ out += `… and ${hidden} more ${hidden === 1 ? 'finding' : 'findings'} (run 'aimhooman review' for the full list)\n\n`;
45
+ }
46
+ const findingWord = findings.length === 1 ? 'finding' : 'findings';
47
+ const blockWord = block === 1 ? 'block' : 'blocks';
48
+ const reviewWord = review === 1 ? 'review' : 'reviews';
49
+ out += `${findings.length} ${findingWord}: ${block} ${blockWord}, ${review} ${reviewWord}\n`;
25
50
  return out;
26
51
  }
27
52
 
@@ -86,6 +86,11 @@ export function scanEntries(repo, engine, entries, options = {}) {
86
86
  stats.objects_read = batch.objects.size;
87
87
  for (const failure of batch.failures) increment(stats.skipped, failure.reason);
88
88
 
89
+ // Precompute changed-line ranges for all text candidates in ONE batched
90
+ // diff per (commit, parent) pair (W4 + perf). A missing path key means
91
+ // "scan the whole blob" (staged/snapshot/root entries, or a diff failure).
92
+ const lineRanges = collectLineRanges(repo, candidates);
93
+
89
94
  const findings = [];
90
95
  for (const entry of candidates) {
91
96
  const blob = batch.objects.get(entry.oid);
@@ -110,7 +115,9 @@ export function scanEntries(repo, engine, entries, options = {}) {
110
115
  });
111
116
  } else {
112
117
  stats.files_scanned += 1;
113
- matched = engine.checkContent(entry.path, blob.toString('utf8'));
118
+ const ranges = lineRanges.get(entry.path);
119
+ matched = engine.checkContent(entry.path, blob.toString('utf8'),
120
+ ranges && ranges.length ? { lineRanges: ranges } : {});
114
121
  }
115
122
  stats.findings_total += matched.length;
116
123
  for (const finding of matched) {
@@ -236,3 +243,72 @@ function isBinary(buffer) {
236
243
  for (let index = 0; index < length; index++) if (buffer[index] === 0) return true;
237
244
  return false;
238
245
  }
246
+
247
+ // collectLineRanges builds a Map<path, ranges[]> for all entries whose content
248
+ // scan can be narrowed to changed hunks (W4, bug 12d-F1). It batches the work
249
+ // into ONE `git diff --unified=0` per (commit, parent) pair rather than one diff
250
+ // per file — a commit touching 50 files would otherwise pay ~650ms of per-file
251
+ // git subprocess startup. Entries are grouped by their first parent; a normal
252
+ // commit (single parent) is a single diff call covering every file.
253
+ //
254
+ // Entries without commit/parents (tracked snapshots, staged views, root
255
+ // 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.
258
+ function collectLineRanges(repo, entries) {
259
+ // Group text-candidate entries by their first parent to batch the diffs.
260
+ // Key by `${commit}\0${parent}` so a merge with multiple parents produces
261
+ // one diff per distinct (commit, parent) pair.
262
+ const groups = new Map();
263
+ for (const entry of entries) {
264
+ if (entry.type === 'blob' && !entry.commit) continue; // staged/snapshot: no diff
265
+ const parent = entry.parents?.[0];
266
+ if (!entry.commit || !parent) continue; // root commit or no parent: whole-blob
267
+ const key = `${entry.commit}\0${parent}`;
268
+ if (!groups.has(key)) groups.set(key, { commit: entry.commit, parent });
269
+ }
270
+ const byPath = new Map();
271
+ for (const { commit, parent } of groups.values()) {
272
+ let output;
273
+ try {
274
+ output = execFileSync('git', [
275
+ '-c', 'core.quotePath=false',
276
+ 'diff', '--unified=0', '--no-color', '--no-prefix',
277
+ parent, commit,
278
+ ], {
279
+ cwd: repo.root,
280
+ env: gitEnvironment(),
281
+ encoding: 'utf8',
282
+ timeout: GIT_TIMEOUT_MS,
283
+ maxBuffer: 16 * 1024 * 1024,
284
+ stdio: ['pipe', 'pipe', 'pipe'],
285
+ });
286
+ } catch {
287
+ continue; // diff failed → those paths stay absent → whole-blob scan
288
+ }
289
+ // Parse the multi-file diff: track the current file from the `+++ path`
290
+ // line, and attribute each `@@` hunk header to it. core.quotePath=false
291
+ // keeps spaces and non-ASCII paths literal so they match entry.path (which
292
+ // comes from git diff --raw -z, also unquoted). Paths containing newlines
293
+ // are not handled, but they fall back to a whole-blob scan (safe).
294
+ let currentPath = null;
295
+ for (const line of output.split('\n')) {
296
+ // With --no-prefix the +++ line is `+++ path` (no b/ prefix).
297
+ const plusMatch = /^\+\+\+ (.+)$/.exec(line);
298
+ if (plusMatch) {
299
+ currentPath = plusMatch[1] === '/dev/null' ? null : plusMatch[1];
300
+ continue;
301
+ }
302
+ const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
303
+ if (!match || !currentPath) continue;
304
+ const start = Number(match[1]);
305
+ const count = match[2] === undefined ? 1 : Number(match[2]);
306
+ if (start === 0 || count === 0) continue; // pure deletion, no new content
307
+ const range = { start, end: start + count - 1 };
308
+ const existing = byPath.get(currentPath);
309
+ if (existing) existing.push(range);
310
+ else byPath.set(currentPath, [range]);
311
+ }
312
+ }
313
+ return byPath;
314
+ }
package/src/scan.mjs CHANGED
@@ -51,13 +51,35 @@ export class Engine {
51
51
  decide(base, target, rule, context = {}) {
52
52
  if (this.denyPaths.has(target) || this.denyRules.has(rule.id)) return 'block';
53
53
  if (context.transientAllowRules?.has(rule.id)) return 'allow';
54
- if (this.scopedAllow.some((entry) => (
55
- entry.target === target
56
- && entry.ruleId === rule.id
57
- && entry.transition === context.transition
58
- && entry.newObjectId === context.objectId
59
- && entry.newMode === context.mode
60
- ))) return 'allow';
54
+ // W11 (F2): review binding is profile-dependent.
55
+ // - strict: review binds the EXACT reviewed blob. A changed/copied/
56
+ // symlinked/mode-flipped target is NOT covered — it must be re-
57
+ // reviewed. This is the security property the strict lifecycle tests
58
+ // pin, and strict is the profile that exits 11 on review.
59
+ // - clean/compliance: review is ADVISORY (a stderr message, exit 0),
60
+ // so re-surfacing it on every edit of a reviewed agent-instruction
61
+ // file (CLAUDE.md, AGENTS.md) is pure friction with no security
62
+ // payoff — the security boundary is secret scanning, strict blocks,
63
+ // and reference-transaction. A LIVE-FILE review (newObjectId set)
64
+ // therefore persists per path + rule across edits. A TOMBSTONE
65
+ // review (newObjectId null — a reviewed deletion) keeps the exact
66
+ // match in every profile: a deletion review must not silently cover
67
+ // a later re-adding of the file.
68
+ const strictReview = this.profile === 'strict';
69
+ if (this.scopedAllow.some((entry) => {
70
+ if (entry.target !== target || entry.ruleId !== rule.id) return false;
71
+ if (entry.newObjectId === null) {
72
+ // Tombstone: exact match in every profile.
73
+ return entry.transition === context.transition
74
+ && entry.newObjectId === context.objectId
75
+ && entry.newMode === context.mode;
76
+ }
77
+ // Live-file review: exact in strict; path-anchored in clean/compliance.
78
+ return !strictReview
79
+ || (entry.transition === context.transition
80
+ && entry.newObjectId === context.objectId
81
+ && entry.newMode === context.mode);
82
+ })) return 'allow';
61
83
  // A rule-level allow cannot bypass the secret-category guard that the
62
84
  // path-level allow below enforces: secret rules require an explicit
63
85
  // --scope secret-path override on a specific path, never a blanket rule allow.
@@ -119,7 +141,18 @@ export class Engine {
119
141
  const categories = Array.isArray(options.categories)
120
142
  ? new Set(options.categories)
121
143
  : null;
144
+ // lineRanges narrows content scanning to changed hunks (W4, bug 12d-F1).
145
+ // When provided (an array of inclusive 1-based {start,end} ranges), only
146
+ // lines falling in a range are evaluated; this stops a file that contains
147
+ // a secret-bearing line ELSEWHERE (a PEM header inside a test string on
148
+ // line 200) from blocking a commit that only edited line 50. Findings
149
+ // keep their real line numbers because lineRecords already anchors them.
150
+ // Absent = scan every line (the pre-W4 behaviour).
151
+ const ranges = Array.isArray(options.lineRanges) && options.lineRanges.length
152
+ ? options.lineRanges
153
+ : null;
122
154
  for (const record of lineRecords(String(content))) {
155
+ if (ranges && !ranges.some((range) => record.line >= range.start && record.line <= range.end)) continue;
123
156
  this.#markLocalInputSkip('code', p, record.text, categories);
124
157
  const result = this.#evaluate('code', p, record.text, { categories });
125
158
  if (result) out.push(finding(result, { path: p, line: record.line, text: record.text }));