@rmyndharis/aimhooman 0.1.6 → 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.
@@ -55,15 +55,18 @@ enforcement requires local or global hook setup.
55
55
  | 2. Agent guard | PreToolUse reports paths and rejects unprovable protected Git mutations | plugin | advisory for paths; fail-closed for boundary bypass |
56
56
  | 3. Strict policy | `strict` profile blocks instead of repairing (exit 10) | both | block |
57
57
  | 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation or incomplete scan |
58
- | 5. Final ref check | prepared `reference-transaction` full-scans every commit introduced to `HEAD` or a branch | git hook | blocks a violation or incomplete scan |
58
+ | 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
59
 
60
60
  Default profile `clean` uses layers 0, 1, 2, 4, and 5. Successful repair keeps the
61
61
  ordinary path low-friction. The pinned-tree and final-ref scans deliberately stop if
62
- a block remains, including a pre-existing tracked block that was not part of the
63
- current diff, or if any scan is incomplete.
62
+ a block remains on a path the commit actually changes, or if any scan is incomplete.
63
+ A file already in history is not re-tried on every later commit: the final ref check
64
+ judges a commit by its change set, so an inherited path no longer blocks unrelated
65
+ work. Use `aimhooman check --tracked` (or scan the PR range in CI) to surface legacy
66
+ secrets without bricking the branch.
64
67
  Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
65
68
  hook checks dispatcher integrity there without scanning unresolved references,
66
- then keeps the full-scan veto at `prepared`, after Git has locked them.
69
+ then keeps the scan veto at `prepared`, after Git has locked them.
67
70
 
68
71
  ### Frictionless agent guard (the everyday commands run)
69
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.6",
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
@@ -443,6 +443,20 @@ function hookPreToolUse(input) {
443
443
  : '--no-verify or shell indirection bypasses the pre-commit guard';
444
444
  const unmodelledPrefixReason =
445
445
  'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
446
+ // A pipeline whose sink can execute code (a shell, an interpreter fed on
447
+ // stdin) can hide a commit or run arbitrary commands, and there is no argv
448
+ // to read a --no-verify out of. The earlier message reused the commit text
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. 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 `|`.
455
+ const opaquePipelineReason = commit
456
+ ? unmodelledPrefixReason
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.';
446
460
  const blocks = [];
447
461
  // potentialCommit treats a command as leading to a commit. uncertainShell
448
462
  // was too broad: it flagged any pipe, so a benign read-only pipeline
@@ -522,7 +536,7 @@ function hookPreToolUse(input) {
522
536
  if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
523
537
  return emitDecision(
524
538
  'deny',
525
- unmodelledPrefixReason,
539
+ opaquePipelineReason,
526
540
  );
527
541
  }
528
542
  // Nothing found and nothing to object to, so emit nothing and leave the
@@ -552,7 +566,7 @@ function hookPreToolUse(input) {
552
566
  if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
553
567
  return emitDecision(
554
568
  'deny',
555
- unmodelledPrefixReason,
569
+ opaquePipelineReason,
556
570
  );
557
571
  }
558
572
  const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
@@ -861,6 +875,41 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
861
875
  environmentRisk,
862
876
  'git',
863
877
  );
878
+ // A passthrough prefix (time, timeout, nice, ...) execs the inner
879
+ // command with the same argv in the same place, so it cannot
880
+ // inject a flag or hide a --no-verify the way eval/sudo/bash -c
881
+ // can. Re-parse the inner command from its `git` token so the
882
+ // commit is judged exactly as if the prefix were not there
883
+ // (--no-verify still denied, a clean commit still allowed). The
884
+ // carve-out only applies when nothing else injects risk;
885
+ // otherwise the wrapper falls back to the closed uncertain path.
886
+ const passthroughExecutable = PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))
887
+ && environmentRisk.length === 0
888
+ && !targetEnvironmentRisk
889
+ && !indirectTargetEnvironmentRisk
890
+ && !commandTargetUncertain
891
+ && !indirect.targetUncertain
892
+ && !indirect.pathDialectUncertain
893
+ && !commandPathDialectUncertain
894
+ && !unwrapped.uncertain
895
+ ? shellExecutable(g0)
896
+ : null;
897
+ if (passthroughExecutable) {
898
+ const gitIndex = toks.findIndex((token, index) => (
899
+ index > 0 && isGitExecutable(token.replace(/^\(+/, '').replace(/\)+$/, ''))
900
+ ));
901
+ if (gitIndex > 0) {
902
+ const inner = parseGit(toks.slice(gitIndex).join(' '), unwrapped.cwd);
903
+ commit ||= inner.commit;
904
+ noVerify ||= inner.noVerify;
905
+ bypassHooks ||= inner.bypassHooks;
906
+ uncertainShell ||= inner.uncertainShell;
907
+ addPaths.push(...inner.addPaths);
908
+ commands.push(...inner.commands);
909
+ sawAdd ||= inner.addPaths.length > 0;
910
+ continue;
911
+ }
912
+ }
864
913
  commit = true;
865
914
  uncertainShell = true;
866
915
  commands.push({
@@ -883,7 +932,8 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
883
932
  || indirect.pathDialectUncertain,
884
933
  });
885
934
  }
886
- if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))) {
935
+ if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))
936
+ && !PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))) {
887
937
  prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
888
938
  prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
889
939
  prefixRisk = true;
@@ -1917,7 +1967,18 @@ function hasUncertainShellSyntax(command) {
1917
1967
  quote = char;
1918
1968
  continue;
1919
1969
  }
1920
- 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;
1921
1982
  if (char === '|' && command[i + 1] !== '|') return true;
1922
1983
  if (char === '&' && command[i + 1] !== '&' && command[i - 1] !== '&') return true;
1923
1984
  }
@@ -1927,6 +1988,57 @@ function hasUncertainShellSyntax(command) {
1927
1988
  });
1928
1989
  }
1929
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
+
1930
2042
  function shellUnits(s) {
1931
2043
  const out = [];
1932
2044
  let segment = '';
@@ -2102,7 +2214,14 @@ function containsOpaquePipeLexeme(command) {
2102
2214
  continue;
2103
2215
  }
2104
2216
  if (char === "'" || char === '"') { quote = char; continue; }
2105
- 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;
2106
2225
  if (char === '$' && (next === '(' || next === '{')) return true;
2107
2226
  if (char === '<' || char === ';') return true;
2108
2227
  if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
@@ -2687,6 +2806,19 @@ const UNCERTAIN_TARGET_INDIRECT_EXECUTORS = new Set([
2687
2806
  'systemd-run', 'unshare', 'watch', 'wsl', 'xargs',
2688
2807
  ]);
2689
2808
 
2809
+ // Passthrough prefixes exec the inner command verbatim: the same argv, the same
2810
+ // cwd, the same target. `time`, `timeout`, `nice`, `ionice` and friends only
2811
+ // measure or schedule — they cannot inject a flag, rewrite the command line, or
2812
+ // hide a --no-verify the way eval/sudo/bash -c can. An indirect commit seen
2813
+ // through one of these is therefore treated as direct, so `time git commit` is
2814
+ // judged like `git commit` instead of being refused as opaque indirection. The
2815
+ // carve-out only applies when nothing else is risky (no environment override,
2816
+ // no uncertain target); otherwise the wrapper falls back to the closed path.
2817
+ const PASSTHROUGH_PREFIX_EXECUTORS = new Set([
2818
+ 'time', 'timeout', 'nice', 'ionice', 'chrt', 'taskset', 'numactl',
2819
+ 'stdbuf', 'setsid', 'nohup', 'prlimit', 'faketime',
2820
+ ]);
2821
+
2690
2822
  function indirectCommitInvocation(words, initialCwd) {
2691
2823
  if (!words.length) return false;
2692
2824
  const executable = shellExecutable(words[0]);
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
+ }