@rmyndharis/aimhooman 0.1.6 → 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.6",
12
+ "version": "0.1.7",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.6",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.6",
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",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ 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
+
8
47
  ## [0.1.6] - 2026-07-18
9
48
 
10
49
  ### 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.6-blue" alt="v0.1.6">
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
@@ -599,8 +599,23 @@ function cmdRefcheck(args) {
599
599
  let commits;
600
600
  try {
601
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();
602
611
  for (const update of updates) {
603
- 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) {
604
619
  const contexts = contextsByCommit.get(revision) || [];
605
620
  contexts.push({
606
621
  head: update.newObjectId,
@@ -624,14 +639,18 @@ function cmdRefcheck(args) {
624
639
  contextsByCommit.set(revision, contexts);
625
640
  }
626
641
  }
627
- commits = [...contextsByCommit];
642
+ commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
643
+ revision,
644
+ reviewContexts,
645
+ localAuthorTips.has(revision),
646
+ ]);
628
647
  }
629
648
  catch (error) {
630
649
  console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
631
650
  return 30;
632
651
  }
633
652
  const limits = scanLimits();
634
- for (const [revision, reviewContexts] of commits) {
653
+ for (const [revision, reviewContexts, authoredLocally] of commits) {
635
654
  let scan;
636
655
  try {
637
656
  scan = scanGitTarget(repo, {
@@ -640,6 +659,7 @@ function cmdRefcheck(args) {
640
659
  reviewContexts,
641
660
  policyMigrationContexts: reviewContexts,
642
661
  limits,
662
+ messageScope: authoredLocally ? 'commit' : 'changes-only',
643
663
  });
644
664
  }
645
665
  catch (error) {
@@ -927,9 +947,17 @@ function cmdStatus(args) {
927
947
  return 30;
928
948
  }
929
949
  let policy;
950
+ let stagedPolicy;
930
951
  let overrides;
931
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.
932
959
  policy = resolvePolicy(repo, { target: 'worktree' });
960
+ stagedPolicy = resolvePolicy(repo, { target: 'staged' });
933
961
  overrides = loadOverrides(repo.stateDir);
934
962
  } catch (e) {
935
963
  console.error(`aimhooman: cannot load enforcement state: ${e.message}`);
@@ -952,9 +980,25 @@ function cmdStatus(args) {
952
980
  excludeError = e.message;
953
981
  excludes = { current: false, installed: false };
954
982
  }
955
- const policyLabel = policy.source === 'worktree-policy' ? 'project' : policy.source;
956
- console.log(`profile: ${policy.profile}`);
957
- 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
+ }
958
1002
  console.log(`hooks: ${hooksComplete ? installed.join(', ') : installed.length ? `${installed.join(', ')} (incomplete; run: aimhooman init)` : 'not installed (run: aimhooman init)'}`);
959
1003
  for (const hook of hooks) {
960
1004
  console.log(`hook ${hook.name}: ${hook.managed ? 'managed' : 'not managed'}, ${hook.executable ? 'executable' : 'not executable'}, ${hook.reachable ? 'reachable' : hook.reason}`);
@@ -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.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": {
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]);
@@ -215,21 +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,
220
228
  contentEntries: snapshot.changes,
221
229
  allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
222
230
  transition: snapshot.commit,
223
231
  });
224
- accumulator.add(loaded.engine.checkMessage(snapshot.message)
225
- .map((finding) => decorate(finding, snapshot, policy)));
226
- 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
+ }
227
245
  return result({
228
246
  target: `commit:${snapshot.commit}`,
229
247
  policy,
230
248
  accumulator,
231
249
  diagnostics,
232
- messageScanned: true,
250
+ messageScanned: scanMessageText,
233
251
  commit: snapshot.commit,
234
252
  });
235
253
  }