@rmyndharis/aimhooman 0.1.5 → 0.1.7

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.
@@ -9,7 +9,7 @@
9
9
  "name": "aimhooman",
10
10
  "source": "./",
11
11
  "description": "Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
12
- "version": "0.1.5",
12
+ "version": "0.1.7",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -20,4 +20,4 @@
20
20
  ]
21
21
  }
22
22
  ]
23
- }
23
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman"
7
7
  },
8
8
  "hooks": "./hooks/hooks.json"
9
- }
9
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman maintainers",
@@ -34,4 +34,4 @@
34
34
  ],
35
35
  "brandColor": "#111827"
36
36
  }
37
- }
37
+ }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,81 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.7] - 2026-07-18
9
+
10
+ ### Fixed
11
+
12
+ - The final reference guard no longer holds a branch hostage to a file already
13
+ in its history. A commit was scanned against the full tree it inherited, so a
14
+ path allowed in once and then un-allowed blocked every later commit on the
15
+ branch, even one-line edits to an unrelated file — and the only way out was to
16
+ rewrite history. The guard now judges a commit by what it changes: a newly
17
+ staged `.env` still stops the commit, while a file the commit merely carries
18
+ forward from its parent stays silent. A secret already in history is not
19
+ forgotten — `aimhooman check --tracked` still names it — but it no longer
20
+ bricks the branch.
21
+ - Imported history no longer trips the attribution guard. `gh pr checkout` and
22
+ `git fetch` bring in other people's commits, whose messages a local developer
23
+ cannot edit; scanning them for AI co-author trailers blocked the checkout
24
+ whenever a PR commit carried one. The guard now scopes attribution and marker
25
+ rules to commits written in the repository (a plain commit, an `--amend`, a
26
+ local merge), and leaves imported commits' messages alone. A locally authored
27
+ `git commit --no-verify` that smuggles the same trailer is still stopped at the
28
+ final guard.
29
+ - `aimhooman status` no longer advertises a profile the hooks are not applying.
30
+ The enforcing guards resolve the project policy from the index, so a worktree
31
+ `.aimhooman.json` that has not been `git add`ed was invisible to them — yet
32
+ `status` reported its profile as active. `status` now shows the staged profile
33
+ the hooks actually enforce, prints the worktree value alongside when the two
34
+ differ, and names the remedy (`git add .aimhooman.json`).
35
+ - A pipeline into a shell with no commit in it is still refused, but the reason
36
+ no longer tells the developer to retry a commit they never wrote. The deny
37
+ text for `echo x | bash` and similar now names the real shape — a pipeline
38
+ whose sink can run arbitrary commands — instead of reusing the commit-themed
39
+ message.
40
+ - A timing or scheduling prefix no longer reads as a hook bypass. `time`,
41
+ `timeout`, `nice`, and `ionice` run the inner command with the same argv in the
42
+ same place, so `time git commit` is judged like `git commit` instead of being
43
+ refused as opaque shell indirection. The carve-out only applies when nothing
44
+ else injects risk; a `--no-verify` or a hooks-path override wrapped in the
45
+ prefix is still caught.
46
+
47
+ ## [0.1.6] - 2026-07-18
48
+
49
+ ### Fixed
50
+
51
+ - A tracked file over the per-file scan budget no longer blocks every commit in
52
+ the repository. 0.1.5 content-scanned the whole tree on every commit, so a 3 MiB
53
+ vendored bundle that crossed the 2 MiB default budget wedged every later commit
54
+ with `scan incomplete (size-limit=1)` — even one-line edits to an unrelated file
55
+ — and the only way out was to raise the budget above the largest tracked file.
56
+ Path rules still run on the full tree, so a staged `.env` stays blocked, but the
57
+ content scan now reads only the files a commit actually changes. Editing the
58
+ oversized file itself still fails the commit until the budget is raised.
59
+ - An oversized file that is binary now skips as `binary` (complete) instead of
60
+ `size-limit` (incomplete). A PSD, WOFF, or PNG cannot hide the text-pattern
61
+ secrets the content scan looks for, so it was never really an incomplete scan.
62
+ The first 8 KB of each oversized file is probed for a NUL byte to tell binary
63
+ from text; files above 16 MiB stay `size-limit` without probing, because
64
+ `cat-file --batch` reads the whole blob into memory and probing a 500 MB file
65
+ just to check for NULs is not worth it. A text file over budget stays a real
66
+ `size-limit` skip, and the owner must raise `AIMHOOMAN_MAX_FILE_BYTES` to cover
67
+ it.
68
+ - `secret.dotenv` now excepts `*.minimal`. A `.env.minimal` shipped as a template
69
+ was blocked by the same rule that catches a real `.env`, because `*.sample`,
70
+ `*.template`, `*.dist`, and `*.defaults` were excepted but `*.minimal` was not.
71
+ The rule's version bumps to 4.
72
+
73
+ ### Added
74
+
75
+ - Incomplete-scan messages name the files that were dropped and why. A budget
76
+ miss used to print a count (`size-limit=3`) with no path, so the owner had to
77
+ hunt for the offender. The CLI now lists up to five skipped paths with their
78
+ sizes and reason, and the JSON scan report carries a `skippedPaths` object so a
79
+ caller can see every dropped file the same way. The schema documents the field.
80
+ - `aimhooman explain` prints a rule's `except` clause when it has one, so an
81
+ `allow`/`deny` override can be reasoned about without opening the rule pack.
82
+
8
83
  ## [0.1.5] - 2026-07-18
9
84
 
10
85
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <p align="center">
11
11
  <img src="https://img.shields.io/github/actions/workflow/status/rmyndharis/aimhooman/test.yml?branch=main&label=CI" alt="CI">
12
- <img src="https://img.shields.io/badge/version-v0.1.5-blue" alt="v0.1.5">
12
+ <img src="https://img.shields.io/badge/version-v0.1.7-blue" alt="v0.1.7">
13
13
  <img src="https://img.shields.io/badge/node-%E2%89%A522.8-339933?logo=node.js&logoColor=white" alt="Node 22.8+">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
15
15
  <img src="https://img.shields.io/badge/license-MIT-111111" alt="MIT">
@@ -76,7 +76,7 @@ flowchart TD
76
76
  DIRECT([Sequencer or direct ref path<br/>cherry-pick · rebase · fetch · worktree · update-ref]) --> REF
77
77
  PRE -->|clean: safe repair| MSG["commit-msg snapshots would-be tree,<br/>runs predecessor, then checks<br/>the message and pinned full tree"]
78
78
  PRE -->|strict violation, incomplete scan,<br/>or failed repair| BLOCK([Operation stops])
79
- MSG -->|message and tree accepted| REF["reference-transaction prepared<br/>full-scans every introduced commit"]
79
+ MSG -->|message and tree accepted| REF["reference-transaction prepared<br/>scans what each introduced commit changes<br/>(messages of locally authored commits only)"]
80
80
  MSG -->|unsafe or unrepairable| BLOCK
81
81
  REF -->|accepted| SHIP([Ref update commits])
82
82
  REF -->|violation or incomplete scan| BLOCK
package/bin/aimhooman.mjs CHANGED
@@ -212,7 +212,24 @@ function incompleteMessage(scan) {
212
212
  : budgeted
213
213
  ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
214
214
  : 'reduce the target or limits and retry';
215
- return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
215
+ let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
216
+ const skippedPaths = scan.stats?.skippedPaths || {};
217
+ const pathLines = [];
218
+ for (const [reason, entries] of Object.entries(skippedPaths)) {
219
+ for (const entry of entries.slice(0, 5)) {
220
+ const sizeStr = formatBytes(entry.size);
221
+ pathLines.push(` skipped: ${entry.path} (${reason}, ${sizeStr})\n`);
222
+ }
223
+ if (entries.length > 5) pathLines.push(` ... and ${entries.length - 5} more\n`);
224
+ }
225
+ return message + pathLines.join('');
226
+ }
227
+
228
+ function formatBytes(bytes) {
229
+ if (bytes == null || bytes < 0) return '?';
230
+ if (bytes < 1024) return `${bytes} B`;
231
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
232
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
216
233
  }
217
234
 
218
235
  function snapshotFile(path) {
@@ -582,8 +599,23 @@ function cmdRefcheck(args) {
582
599
  let commits;
583
600
  try {
584
601
  const contextsByCommit = new Map();
602
+ // A commit's message belongs to whoever wrote it. Attribution and marker
603
+ // rules police the text a local developer can edit, so they are scoped
604
+ // to commits authored here: an update that introduces exactly one new
605
+ // commit on top of a non-zero old tip (a plain commit, an --amend, or a
606
+ // local --no-ff merge of an already-gated branch). Anything else — a new
607
+ // branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
608
+ // history — imports other people's commit text the developer cannot
609
+ // change, and scanning it only blocks the review.
610
+ const localAuthorTips = new Set();
585
611
  for (const update of updates) {
586
- for (const revision of introducedCommits(repo, [update])) {
612
+ const introduced = introducedCommits(repo, [update]);
613
+ if (!/^0+$/.test(update.oldObjectId)
614
+ && introduced.length === 1
615
+ && introduced[0] === update.newObjectId) {
616
+ localAuthorTips.add(update.newObjectId);
617
+ }
618
+ for (const revision of introduced) {
587
619
  const contexts = contextsByCommit.get(revision) || [];
588
620
  contexts.push({
589
621
  head: update.newObjectId,
@@ -607,14 +639,18 @@ function cmdRefcheck(args) {
607
639
  contextsByCommit.set(revision, contexts);
608
640
  }
609
641
  }
610
- commits = [...contextsByCommit];
642
+ commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
643
+ revision,
644
+ reviewContexts,
645
+ localAuthorTips.has(revision),
646
+ ]);
611
647
  }
612
648
  catch (error) {
613
649
  console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
614
650
  return 30;
615
651
  }
616
652
  const limits = scanLimits();
617
- for (const [revision, reviewContexts] of commits) {
653
+ for (const [revision, reviewContexts, authoredLocally] of commits) {
618
654
  let scan;
619
655
  try {
620
656
  scan = scanGitTarget(repo, {
@@ -623,6 +659,7 @@ function cmdRefcheck(args) {
623
659
  reviewContexts,
624
660
  policyMigrationContexts: reviewContexts,
625
661
  limits,
662
+ messageScope: authoredLocally ? 'commit' : 'changes-only',
626
663
  });
627
664
  }
628
665
  catch (error) {
@@ -910,9 +947,17 @@ function cmdStatus(args) {
910
947
  return 30;
911
948
  }
912
949
  let policy;
950
+ let stagedPolicy;
913
951
  let overrides;
914
952
  try {
953
+ // `policy` describes the worktree file as written; `stagedPolicy` is the
954
+ // one the enforcing hooks actually read (they resolve from the index, so
955
+ // a worktree profile that has not been staged is invisible to them).
956
+ // Reporting the worktree value alone used to advertise a profile the
957
+ // guard was not applying, so the two are compared below and the
958
+ // mismatch is named when they disagree.
915
959
  policy = resolvePolicy(repo, { target: 'worktree' });
960
+ stagedPolicy = resolvePolicy(repo, { target: 'staged' });
916
961
  overrides = loadOverrides(repo.stateDir);
917
962
  } catch (e) {
918
963
  console.error(`aimhooman: cannot load enforcement state: ${e.message}`);
@@ -935,9 +980,25 @@ function cmdStatus(args) {
935
980
  excludeError = e.message;
936
981
  excludes = { current: false, installed: false };
937
982
  }
938
- const policyLabel = policy.source === 'worktree-policy' ? 'project' : policy.source;
939
- console.log(`profile: ${policy.profile}`);
940
- console.log(`policy: ${policyLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})`);
983
+ // The pre-commit and reference-transaction guards resolve the policy from
984
+ // the index, so the staged profile is what is actually enforced. Report it
985
+ // first; the worktree file is shown alongside so an edit that has not been
986
+ // staged (or a file that was never `git add`ed) cannot masquerade as active.
987
+ const policyLabelOf = (resolved) => (
988
+ resolved.source === 'worktree-policy'
989
+ || resolved.source === 'staged-policy'
990
+ || resolved.source === 'commit-policy'
991
+ ) ? 'project' : resolved.source;
992
+ const enforced = stagedPolicy.profile;
993
+ const enforcedLabel = policyLabelOf(stagedPolicy);
994
+ const worktreeLabel = policyLabelOf(policy);
995
+ const policyDrift = policy.profile !== stagedPolicy.profile
996
+ || policy.policy_object_id !== stagedPolicy.policy_object_id;
997
+ console.log(`profile: ${enforced}${policyDrift ? ` (worktree: ${policy.profile})` : ''}`);
998
+ console.log(`policy: ${enforcedLabel} (${stagedPolicy.source}, object=${stagedPolicy.policy_object_id || 'none'})${policyDrift ? `; worktree ${worktreeLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})` : ''}`);
999
+ if (policyDrift) {
1000
+ console.log(`warning: worktree .aimhooman.json is not staged, so the hooks enforce the staged profile (${enforced}); run \`git add .aimhooman.json\` to apply ${policy.profile}`);
1001
+ }
941
1002
  console.log(`hooks: ${hooksComplete ? installed.join(', ') : installed.length ? `${installed.join(', ')} (incomplete; run: aimhooman init)` : 'not installed (run: aimhooman init)'}`);
942
1003
  for (const hook of hooks) {
943
1004
  console.log(`hook ${hook.name}: ${hook.managed ? 'managed' : 'not managed'}, ${hook.executable ? 'executable' : 'not executable'}, ${hook.reachable ? 'reachable' : hook.reason}`);
@@ -978,6 +1039,7 @@ function cmdExplain(args) {
978
1039
  console.log(`Actions: clean=${af('clean')} strict=${af('strict')} compliance=${af('compliance')}`);
979
1040
  console.log(`Reason: ${r.reason}`);
980
1041
  if (r.match?.paths) console.log(`Paths: ${r.match.paths.join(', ')}`);
1042
+ if (r.match?.except) console.log(`Except: ${r.match.except.join(', ')}`);
981
1043
  if (r.match?.content) console.log(`Content: ${r.match.content.join(', ')}`);
982
1044
  if (r.remediation?.length) {
983
1045
  console.log('Remediation:');
@@ -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.5",
3
+ "version": "0.1.7",
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": {
@@ -74,4 +74,4 @@
74
74
  "developer-tools",
75
75
  "pre-commit"
76
76
  ]
77
- }
77
+ }
package/rules/paths.json CHANGED
@@ -218,7 +218,7 @@
218
218
  },
219
219
  {
220
220
  "id": "secret.dotenv",
221
- "version": 3,
221
+ "version": 4,
222
222
  "provider": "generic",
223
223
  "category": "secret",
224
224
  "confidence": "high",
@@ -236,7 +236,8 @@
236
236
  "**/*.sample",
237
237
  "**/*.template",
238
238
  "**/*.dist",
239
- "**/*.defaults"
239
+ "**/*.defaults",
240
+ "**/*.minimal"
240
241
  ]
241
242
  },
242
243
  "actions": {
@@ -82,6 +82,21 @@
82
82
  "skipped": {
83
83
  "type": "object",
84
84
  "additionalProperties": { "type": "integer", "minimum": 1 }
85
+ },
86
+ "skippedPaths": {
87
+ "type": "object",
88
+ "additionalProperties": {
89
+ "type": "array",
90
+ "items": {
91
+ "type": "object",
92
+ "required": ["path", "size"],
93
+ "properties": {
94
+ "path": { "type": "string" },
95
+ "size": { "type": ["integer", "null"], "minimum": 0 }
96
+ },
97
+ "additionalProperties": false
98
+ }
99
+ }
85
100
  }
86
101
  },
87
102
  "additionalProperties": false
package/src/hook.mjs CHANGED
@@ -443,6 +443,14 @@ 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.
451
+ const opaquePipelineReason = commit
452
+ ? 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.';
446
454
  const blocks = [];
447
455
  // potentialCommit treats a command as leading to a commit. uncertainShell
448
456
  // was too broad: it flagged any pipe, so a benign read-only pipeline
@@ -522,7 +530,7 @@ function hookPreToolUse(input) {
522
530
  if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
523
531
  return emitDecision(
524
532
  'deny',
525
- unmodelledPrefixReason,
533
+ opaquePipelineReason,
526
534
  );
527
535
  }
528
536
  // Nothing found and nothing to object to, so emit nothing and leave the
@@ -552,7 +560,7 @@ function hookPreToolUse(input) {
552
560
  if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
553
561
  return emitDecision(
554
562
  'deny',
555
- unmodelledPrefixReason,
563
+ opaquePipelineReason,
556
564
  );
557
565
  }
558
566
  const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
@@ -861,6 +869,41 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
861
869
  environmentRisk,
862
870
  'git',
863
871
  );
872
+ // A passthrough prefix (time, timeout, nice, ...) execs the inner
873
+ // command with the same argv in the same place, so it cannot
874
+ // inject a flag or hide a --no-verify the way eval/sudo/bash -c
875
+ // can. Re-parse the inner command from its `git` token so the
876
+ // commit is judged exactly as if the prefix were not there
877
+ // (--no-verify still denied, a clean commit still allowed). The
878
+ // carve-out only applies when nothing else injects risk;
879
+ // otherwise the wrapper falls back to the closed uncertain path.
880
+ const passthroughExecutable = PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))
881
+ && environmentRisk.length === 0
882
+ && !targetEnvironmentRisk
883
+ && !indirectTargetEnvironmentRisk
884
+ && !commandTargetUncertain
885
+ && !indirect.targetUncertain
886
+ && !indirect.pathDialectUncertain
887
+ && !commandPathDialectUncertain
888
+ && !unwrapped.uncertain
889
+ ? shellExecutable(g0)
890
+ : null;
891
+ if (passthroughExecutable) {
892
+ const gitIndex = toks.findIndex((token, index) => (
893
+ index > 0 && isGitExecutable(token.replace(/^\(+/, '').replace(/\)+$/, ''))
894
+ ));
895
+ if (gitIndex > 0) {
896
+ const inner = parseGit(toks.slice(gitIndex).join(' '), unwrapped.cwd);
897
+ commit ||= inner.commit;
898
+ noVerify ||= inner.noVerify;
899
+ bypassHooks ||= inner.bypassHooks;
900
+ uncertainShell ||= inner.uncertainShell;
901
+ addPaths.push(...inner.addPaths);
902
+ commands.push(...inner.commands);
903
+ sawAdd ||= inner.addPaths.length > 0;
904
+ continue;
905
+ }
906
+ }
864
907
  commit = true;
865
908
  uncertainShell = true;
866
909
  commands.push({
@@ -883,7 +926,8 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
883
926
  || indirect.pathDialectUncertain,
884
927
  });
885
928
  }
886
- if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))) {
929
+ if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))
930
+ && !PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))) {
887
931
  prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
888
932
  prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
889
933
  prefixRisk = true;
@@ -2687,6 +2731,19 @@ const UNCERTAIN_TARGET_INDIRECT_EXECUTORS = new Set([
2687
2731
  'systemd-run', 'unshare', 'watch', 'wsl', 'xargs',
2688
2732
  ]);
2689
2733
 
2734
+ // Passthrough prefixes exec the inner command verbatim: the same argv, the same
2735
+ // cwd, the same target. `time`, `timeout`, `nice`, `ionice` and friends only
2736
+ // measure or schedule — they cannot inject a flag, rewrite the command line, or
2737
+ // hide a --no-verify the way eval/sudo/bash -c can. An indirect commit seen
2738
+ // through one of these is therefore treated as direct, so `time git commit` is
2739
+ // judged like `git commit` instead of being refused as opaque indirection. The
2740
+ // carve-out only applies when nothing else is risky (no environment override,
2741
+ // no uncertain target); otherwise the wrapper falls back to the closed path.
2742
+ const PASSTHROUGH_PREFIX_EXECUTORS = new Set([
2743
+ 'time', 'timeout', 'nice', 'ionice', 'chrt', 'taskset', 'numactl',
2744
+ 'stdbuf', 'setsid', 'nohup', 'prlimit', 'faketime',
2745
+ ]);
2746
+
2690
2747
  function indirectCommitInvocation(words, initialCwd) {
2691
2748
  if (!words.length) return false;
2692
2749
  const executable = shellExecutable(words[0]);
@@ -18,9 +18,11 @@ export function scanEntries(repo, engine, entries, options = {}) {
18
18
  findings_total: 0,
19
19
  findings_returned: 0,
20
20
  skipped: {},
21
+ skippedPaths: {},
21
22
  };
22
23
  const candidates = [];
23
24
  let selectedBytes = 0;
25
+ const oversized = [];
24
26
 
25
27
  for (const entry of entries) {
26
28
  if (entry.type !== 'blob') {
@@ -37,17 +39,48 @@ export function scanEntries(repo, engine, entries, options = {}) {
37
39
  continue;
38
40
  }
39
41
  if (entry.size > limits.maxFileBytes) {
40
- increment(stats.skipped, 'size-limit');
42
+ oversized.push(entry);
41
43
  continue;
42
44
  }
43
45
  if (selectedBytes + entry.size > limits.maxTotalBytes) {
44
46
  increment(stats.skipped, 'total-byte-limit');
47
+ appendPath(stats.skippedPaths, 'total-byte-limit', entry.path, entry.size);
45
48
  continue;
46
49
  }
47
50
  selectedBytes += entry.size;
48
51
  candidates.push(entry);
49
52
  }
50
53
 
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.
59
+ // Cap probing at 16 MiB: cat-file --batch reads the full blob into memory,
60
+ // so probing a 500 MB file just to check for NULs is wasteful. Files above
61
+ // the cap are classified as size-limit without probing.
62
+ const PROBE_CAP = 16 * 1024 * 1024;
63
+ const probeable = oversized.filter((entry) => entry.size <= PROBE_CAP);
64
+ const tooBig = oversized.filter((entry) => entry.size > PROBE_CAP);
65
+ for (const entry of tooBig) {
66
+ increment(stats.skipped, 'size-limit');
67
+ appendPath(stats.skippedPaths, 'size-limit', entry.path, entry.size);
68
+ }
69
+ if (probeable.length) {
70
+ const probeOids = [...new Set(probeable.map((entry) => entry.oid))];
71
+ const probes = probeObjects(repo, probeOids, probeable);
72
+ for (const entry of probeable) {
73
+ const header = probes.get(entry.oid);
74
+ if (header && isBinary(header)) {
75
+ increment(stats.skipped, 'binary');
76
+ appendPath(stats.skippedPaths, 'binary', entry.path, entry.size);
77
+ } else {
78
+ increment(stats.skipped, 'size-limit');
79
+ appendPath(stats.skippedPaths, 'size-limit', entry.path, entry.size);
80
+ }
81
+ }
82
+ }
83
+
51
84
  const objectIds = [...new Set(candidates.map((entry) => entry.oid))];
52
85
  const batch = readObjects(repo, objectIds, selectedBytes);
53
86
  stats.objects_read = batch.objects.size;
@@ -65,6 +98,7 @@ export function scanEntries(repo, engine, entries, options = {}) {
65
98
  let matched;
66
99
  if (isBinary(blob)) {
67
100
  increment(stats.skipped, 'binary');
101
+ appendPath(stats.skippedPaths, 'binary', entry.path, blob.length);
68
102
  // Binary classification only skips text-oriented policy rules. Secret
69
103
  // signatures are ASCII byte sequences, so latin1 preserves a
70
104
  // one-byte-to-one-code-unit view and keeps the existing byte limits.
@@ -148,10 +182,55 @@ function readObjects(repo, objectIds, expectedBytes = 0) {
148
182
  return { objects, failures };
149
183
  }
150
184
 
185
+ // probeObjects reads the first 8000 bytes of each blob to decide whether it is
186
+ // binary (contains a NUL). cat-file --batch outputs full blobs, so the buffer
187
+ // must accommodate total blob sizes, but we only inspect the leading bytes.
188
+ function probeObjects(repo, objectIds, entries) {
189
+ const unique = [...new Set(objectIds.filter(Boolean))];
190
+ if (!unique.length) return new Map();
191
+ const PROBE_BYTES = 8000;
192
+ // Sum the actual sizes so the buffer can hold the full output.
193
+ const sizeByOid = new Map();
194
+ for (const entry of entries) {
195
+ if (entry.oid && entry.size > 0) sizeByOid.set(entry.oid, Math.max(sizeByOid.get(entry.oid) || 0, entry.size));
196
+ }
197
+ const totalExpected = unique.reduce((sum, oid) => sum + (sizeByOid.get(oid) || 0), 0);
198
+ const output = execFileSync('git', ['cat-file', '--batch'], {
199
+ cwd: repo.root,
200
+ env: gitEnvironment(),
201
+ input: Buffer.from(unique.join('\n') + '\n'),
202
+ encoding: 'buffer',
203
+ maxBuffer: Math.max(2 * 1024 * 1024, totalExpected + unique.length * 256 + 1024),
204
+ timeout: GIT_TIMEOUT_MS,
205
+ stdio: ['pipe', 'pipe', 'pipe'],
206
+ });
207
+ const probes = new Map();
208
+ let offset = 0;
209
+ for (const requested of unique) {
210
+ const newline = output.indexOf(0x0a, offset);
211
+ if (newline < 0) break;
212
+ const header = output.subarray(offset, newline).toString('utf8');
213
+ offset = newline + 1;
214
+ const fields = header.split(' ');
215
+ if (fields[1] === 'missing' || fields.length !== 3 || fields[1] !== 'blob') continue;
216
+ const size = Number(fields[2]);
217
+ const end = offset + size;
218
+ if (end > output.length || output[end] !== 0x0a) break;
219
+ probes.set(requested, output.subarray(offset, offset + Math.min(size, PROBE_BYTES)));
220
+ offset = end + 1;
221
+ }
222
+ return probes;
223
+ }
224
+
151
225
  function increment(record, key) {
152
226
  record[key] = (record[key] || 0) + 1;
153
227
  }
154
228
 
229
+ function appendPath(record, reason, path, size) {
230
+ if (!record[reason]) record[reason] = [];
231
+ if (record[reason].length < 10) record[reason].push({ path, size });
232
+ }
233
+
155
234
  function isBinary(buffer) {
156
235
  const length = Math.min(buffer.length, 8000);
157
236
  for (let index = 0; index < length; index++) if (buffer[index] === 0) return true;
@@ -215,20 +215,39 @@ function scanCommit(repo, options) {
215
215
  if (floor && !isVersionedStrict(rawPolicy)) {
216
216
  accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot)]);
217
217
  }
218
- scanEntryGroup(repo, loaded.engine, snapshot.entries, policy, accumulator, {
218
+ // A commit is judged by what it changes, not by the whole tree it inherits.
219
+ // Scanning snapshot.entries (a full `ls-tree`) re-applied path rules to
220
+ // every file the commit merely carried forward from its parent, so a path
221
+ // allowed into history once (under an override that was later removed, or
222
+ // before the guard existed) blocked every later commit on the branch. The
223
+ // change set already drives content scanning; routing path rules through it
224
+ // too means a newly added `.env` still fires while an inherited one stays
225
+ // silent, matching how scanRange has always judged history.
226
+ scanEntryGroup(repo, loaded.engine, snapshot.changes, policy, accumulator, {
219
227
  reviewPathEntries: snapshot.changes,
228
+ contentEntries: snapshot.changes,
220
229
  allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
221
230
  transition: snapshot.commit,
222
231
  });
223
- accumulator.add(loaded.engine.checkMessage(snapshot.message)
224
- .map((finding) => decorate(finding, snapshot, policy)));
225
- accumulator.addSkipped(loaded.engine.takeSkipped());
232
+ // The message is the author's words, so it is scanned for attribution and
233
+ // markers only when the commit was written here. Fetched history (a PR
234
+ // checked out for review, a branch pulled from a remote) carries other
235
+ // people's commit text that a local developer cannot edit, so flagging it
236
+ // only blocks the review. cmdRefcheck passes messageScope='changes-only'
237
+ // for commits that were imported rather than authored; a direct `aimhooman
238
+ // check --commit` still defaults to scanning the message.
239
+ const scanMessageText = options.messageScope !== 'changes-only';
240
+ if (scanMessageText) {
241
+ accumulator.add(loaded.engine.checkMessage(snapshot.message)
242
+ .map((finding) => decorate(finding, snapshot, policy)));
243
+ accumulator.addSkipped(loaded.engine.takeSkipped());
244
+ }
226
245
  return result({
227
246
  target: `commit:${snapshot.commit}`,
228
247
  policy,
229
248
  accumulator,
230
249
  diagnostics,
231
- messageScanned: true,
250
+ messageScanned: scanMessageText,
232
251
  commit: snapshot.commit,
233
252
  });
234
253
  }
@@ -367,8 +386,15 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
367
386
  })
368
387
  .map((finding) => decorate(finding, entry, policy)));
369
388
  }
389
+ // Content scanning can target a narrower set than the path check. When
390
+ // contentEntries is provided (e.g. only changed files in a commit), read
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.
394
+ const contentScannable = (options.contentEntries ?? entries)
395
+ .filter((entry) => entry.status !== 'D' && entry.type !== 'deleted');
370
396
  const remaining = accumulator.remaining();
371
- const scanned = scanEntries(repo, engine, scannable, {
397
+ const scanned = scanEntries(repo, engine, contentScannable, {
372
398
  maxFileBytes: accumulator.limits.maxFileBytes,
373
399
  maxTotalBytes: accumulator.remainingBytes(),
374
400
  maxFindings: remaining,
@@ -628,6 +654,7 @@ function createAccumulator(limits = {}) {
628
654
  findings_total: 0,
629
655
  findings_returned: 0,
630
656
  skipped: {},
657
+ skippedPaths: {},
631
658
  };
632
659
  let complete = true;
633
660
 
@@ -665,6 +692,12 @@ function createAccumulator(limits = {}) {
665
692
  for (const [reason, count] of Object.entries(scanned.stats.skipped || {})) {
666
693
  stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
667
694
  }
695
+ for (const [reason, paths] of Object.entries(scanned.stats.skippedPaths || {})) {
696
+ if (!stats.skippedPaths[reason]) stats.skippedPaths[reason] = [];
697
+ for (const entry of paths) {
698
+ if (stats.skippedPaths[reason].length < 10) stats.skippedPaths[reason].push(entry);
699
+ }
700
+ }
668
701
  if (!scanned.complete) complete = false;
669
702
  },
670
703
  addSkipped(skipped = {}) {