@rmyndharis/aimhooman 0.1.3 → 0.1.5

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.3",
12
+ "version": "0.1.5",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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.3",
3
+ "version": "0.1.5",
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,70 @@ 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.5] - 2026-07-18
9
+
10
+ ### Fixed
11
+
12
+ - AWS's own published example key no longer blocks a commit. 0.1.4 began matching bare
13
+ `AKIA…`/`ASIA…` access key IDs, which also matched `AKIAIOSFODNN7EXAMPLE` — the value AWS
14
+ prints in its own documentation, and one that lands in READMEs, Terraform samples, and test
15
+ fixtures. Because the rule's category is `secret`, neither a rule allow nor a plain path
16
+ allow is accepted, so a documentation example became an unconditional block on every profile
17
+ with only a per-file escape. Every AWS example key ends in `EXAMPLE`, so the pattern now
18
+ excludes that suffix and keeps blocking real keys. Path scoping was deliberately not used:
19
+ excluding `docs/` or `tests/` from secret scanning would hide genuine keys in the places they
20
+ most often leak.
21
+ - `uninstall --purge-state` no longer leaves a backup behind in a linked worktree. Git writes
22
+ the commit message file into the per-worktree Git directory, so 0.1.4's sweep — which looked
23
+ only in the common directory — printed "state purged" with `COMMIT_EDITMSG.aimhooman-bak`
24
+ still on disk. Uninstall disarms every worktree at once, so it now sweeps the main Git
25
+ directory and each linked one, and matches the `.aimhooman-bak` suffix so a backup left by a
26
+ merge goes with it.
27
+ - A plain `uninstall` keeps the attribution backup. It printed "state kept" and then deleted
28
+ the one file holding the lines stripped from the last commit message. Only `--purge-state`,
29
+ which promises to remove everything, takes it now.
30
+ - Sweeping an empty lock queue can no longer stop a concurrent `aimhooman init`. Between its
31
+ `mkdir` and its first publication a lock contender owns no file in the queue, so the sweep
32
+ saw an empty directory and removed it, and the contender then failed on a bare `ENOENT`. The
33
+ window is wide enough to lose because building the candidate probes the process identity,
34
+ which spawns `ps` on macOS and BSD. Publication now recreates the directory and retries once.
35
+ - The unstage summary no longer claims the files were "kept in your working tree". That is
36
+ false when a path was staged and then deleted before the commit, and when a staged deletion
37
+ is unstaged — the file is absent either way, though aimhooman never removed it. Unstaging
38
+ only ever touches the index, so the note says that instead of guessing where the file is.
39
+
40
+ ### Changed
41
+
42
+ - An ordinary commit no longer pays a Node cold start for a phase that does nothing. Git fires
43
+ `reference-transaction` twice per commit, and `refcheck` returns immediately for `committed`
44
+ and `aborted`, so the dispatcher now short-circuits those phases in the shell — after the
45
+ chained-hook call, so a chained hook still sees every phase. Measured locally, an ordinary
46
+ commit drops from about 870ms to about 710ms. The `prepared` scan, and the `--no-verify`
47
+ backstop that rides on it, are unchanged. A repository installed with an earlier release
48
+ keeps a valid dispatcher; re-run `aimhooman init` to pick up the faster one.
49
+ - The attribution backup no longer lingers indefinitely. `commit-msg` clears the previous run's
50
+ backup before it may write its own, so at most one exists at a time and a later clean commit
51
+ leaves none. The recovery window is now until your next commit rather than unbounded.
52
+
53
+ ## [0.1.4] - 2026-07-18
54
+
55
+ ### Fixed
56
+
57
+ - Uninstall no longer leaves aimhooman artifacts in `.git`. Every atomic write takes a
58
+ lock whose `<lock>.queue` directory was created but never removed — the release path
59
+ only unlinked its own candidate file — and `--purge-state` cleared just the state
60
+ directory. So `.git` kept the queue directories and the `COMMIT_EDITMSG.aimhooman-bak`
61
+ attribution backup while the command printed "state purged". Uninstall now sweeps this
62
+ residue once the lifecycle lock releases; `rmdirSync` removes only an empty directory,
63
+ so a concurrent contender's queue is never touched, and the lifecycle queue is removable
64
+ precisely because its own lock has already released. A plain uninstall keeps policy
65
+ state and drops the operational residue; `--purge-state` leaves no aimhooman fingerprints.
66
+ - A bare AWS access key ID is now detected. `secret.aws-key-content` fired only when an
67
+ `aws_secret_access_key` or `aws_session_token` name sat beside the value, so an
68
+ `AKIA…`/`ASIA…` access key ID committed on its own passed clean. The rule now matches the
69
+ access-key-ID prefixes directly — the fixed-prefix, highest-confidence AWS indicator. The
70
+ 16-character body and word boundaries keep short lookalikes such as `AKIA123` from matching.
71
+
8
72
  ## [0.1.3] - 2026-07-18
9
73
 
10
74
  ### 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.3-blue" alt="v0.1.3">
12
+ <img src="https://img.shields.io/badge/version-v0.1.5-blue" alt="v0.1.5">
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">
package/bin/aimhooman.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
- import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
3
+ import { chmodSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
@@ -20,7 +20,7 @@ import {
20
20
  } from '../src/gitx.mjs';
21
21
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
22
22
  import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
23
- import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
+ import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
24
24
  import { ArgumentError, parseArguments } from '../src/args.mjs';
25
25
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
26
26
  import { resolvePolicy } from '../src/policy-resolver.mjs';
@@ -372,7 +372,7 @@ function cmdPrecommit(args) {
372
372
  emptied = stagedBefore !== null
373
373
  && stagedBefore.every((path) => unstageTargets.has(path));
374
374
  process.stderr.write(
375
- `aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')}${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
375
+ `aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')} (index only; nothing on disk was deleted)${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
376
376
  );
377
377
  } catch (e) {
378
378
  process.stderr.write(
@@ -409,6 +409,10 @@ function cmdCommitmsg(args) {
409
409
  const file = positionals[0];
410
410
  const repo = tryRepo();
411
411
  if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
412
+ // A .aimhooman-bak from an earlier commit went stale the moment that commit
413
+ // finished, and git reuses this message path. Clear the previous run's backup
414
+ // now, before this run may write its own, so at most one ever lingers.
415
+ try { rmSync(`${file}.aimhooman-bak`, { force: true }); } catch { /* best effort */ }
412
416
  // Frictionless profiles (clean/compliance) never cancel a commit merely
413
417
  // because the message file cannot be read; only strict fails closed.
414
418
  let hookProfile = 'clean';
@@ -1615,7 +1619,8 @@ function cmdUninstall(args) {
1615
1619
  console.error('aimhooman: not a git repository');
1616
1620
  return 30;
1617
1621
  }
1618
- return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
1622
+ const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
1623
+ const exitStatus = withLock(lifecycleLock, () => {
1619
1624
  const rep = uninstallHooks(repo);
1620
1625
  // The irreversible work is already done above, and the report is still
1621
1626
  // below. A throw from here unwound past all of it, so a damaged marker was
@@ -1683,6 +1688,42 @@ function cmdUninstall(args) {
1683
1688
  // the uninstall is genuinely incomplete and 30 is the honest answer.
1684
1689
  return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
1685
1690
  }, LIFECYCLE_LOCK_OPTIONS);
1691
+ // Sweep the operational residue the guard authored in .git, so uninstall leaves
1692
+ // no aimhooman fingerprints behind — the same tooling residue this tool exists
1693
+ // to remove. rmdirSync deletes only an empty directory, so a live contender's
1694
+ // queue is never touched; the lifecycle queue becomes removable only now, after
1695
+ // its own lock above has released.
1696
+ for (const queue of [
1697
+ `${lifecycleLock}.queue`,
1698
+ `${repo.excludeFile}.aimhooman.lock.queue`,
1699
+ join(effectiveHooksDir(repo), '.aimhooman-hooks.lock.queue'),
1700
+ ]) {
1701
+ try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
1702
+ }
1703
+ // The attribution backup is the user's only copy of the lines stripped from
1704
+ // their last message — commit-msg already clears the previous one, so what
1705
+ // survives here is current, not stale. A plain uninstall says "state kept"
1706
+ // and must not delete it; only --purge-state, which promises to remove
1707
+ // everything, sweeps it. Git names the message file per operation
1708
+ // (COMMIT_EDITMSG, MERGE_MSG, ...) and the backup inherits that name, so
1709
+ // match the suffix rather than one filename. It lands beside that file, in
1710
+ // the per-worktree git directory rather than the common one, and uninstall
1711
+ // disarms every worktree at once — so cover the main one and each linked.
1712
+ if (purge) {
1713
+ const messageDirs = [repo.commonDir];
1714
+ try {
1715
+ const linked = join(repo.commonDir, 'worktrees');
1716
+ for (const name of readdirSync(linked)) messageDirs.push(join(linked, name));
1717
+ } catch { /* no linked worktrees */ }
1718
+ for (const dir of messageDirs) {
1719
+ try {
1720
+ for (const name of readdirSync(dir)) {
1721
+ if (name.endsWith('.aimhooman-bak')) rmSync(join(dir, name), { force: true });
1722
+ }
1723
+ } catch { /* directory missing or unreadable */ }
1724
+ }
1725
+ }
1726
+ return exitStatus;
1686
1727
  }
1687
1728
 
1688
1729
  function usage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": {
@@ -45,14 +45,15 @@
45
45
  },
46
46
  {
47
47
  "id": "secret.aws-key-content",
48
- "version": 1,
48
+ "version": 2,
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,}(?![A-Za-z0-9/+=])",
56
+ "\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
56
57
  ]
57
58
  },
58
59
  "actions": {
@@ -259,18 +259,34 @@ export function withLock(lockPath, fn, options = {}) {
259
259
  let held = false;
260
260
  let published = false;
261
261
  let primaryError = null;
262
+ // Between the mkdir above and the first publication below this contender owns
263
+ // no file in the queue, so a concurrent cleanup that removes empty queue
264
+ // directories — `aimhooman uninstall` sweeps them — can delete it out from
265
+ // under us. The gap is wide enough to lose: building the candidate probes the
266
+ // process identity, which spawns `ps` on macOS and BSD. Recreate and retry
267
+ // once rather than failing the caller with a bare ENOENT. Once a candidate is
268
+ // published the directory is no longer empty, so no cleanup can remove it.
269
+ const publish = () => {
270
+ try {
271
+ publishCandidate(candidatePath, candidate, options.candidateOperations);
272
+ } catch (error) {
273
+ if (error?.code !== 'ENOENT') throw error;
274
+ mkdirSync(queueDir, { recursive: true });
275
+ publishCandidate(candidatePath, candidate, options.candidateOperations);
276
+ }
277
+ };
262
278
  try {
263
279
  // Own the UUID pathname before publication begins. If the rename lands
264
280
  // but its directory fsync fails, finally still removes the candidate;
265
281
  // unlinking ENOENT is harmless when publication failed earlier.
266
282
  published = true;
267
- publishCandidate(candidatePath, candidate, options.candidateOperations);
283
+ publish();
268
284
  const observed = queueCandidates(queueDir, staleMs);
269
285
  candidate.ticket = observed.reduce((maximum, peer) => (
270
286
  peer.ticket === null ? maximum : Math.max(maximum, peer.ticket)
271
287
  ), 0) + 1;
272
288
  candidate.choosing = false;
273
- publishCandidate(candidatePath, candidate, options.candidateOperations);
289
+ publish();
274
290
 
275
291
  for (let attempt = 0; attempt < retries; attempt += 1) {
276
292
  // A file at the pre-queue lock path may belong to a concurrently
package/src/githooks.mjs CHANGED
@@ -124,7 +124,7 @@ function canonicalPath(path) {
124
124
 
125
125
  // Ask Git for the path it actually uses. In a linked worktree this resolves to
126
126
  // the common repository hooks directory, not <gitDir>/hooks.
127
- function effectiveHooksDir(repo) {
127
+ export function effectiveHooksDir(repo) {
128
128
  try {
129
129
  return execFileSync(
130
130
  'git',
@@ -642,6 +642,14 @@ function hookScript(name, cmd, cliPath, chainedPath) {
642
642
  const aimInvocation = name === 'reference-transaction'
643
643
  ? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
644
644
  : `run_aimhooman ${aimCommand} || exit $?`;
645
+ // committed/aborted fire only after refs are locked in, and refcheck can do
646
+ // nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
647
+ // so an ordinary commit no longer pays a Node cold start for the committed
648
+ // phase. Placed after the chained-hook call, so a chained hook still sees
649
+ // every phase.
650
+ const phaseShortCircuit = name === 'reference-transaction'
651
+ ? 'case "$1" in committed|aborted) exit 0 ;; esac\n'
652
+ : '';
645
653
  const template = `#!/bin/sh -p
646
654
  ${MARKER} (${name})
647
655
  # aimhooman-hook-version: ${HOOK_FORMAT_VERSION}
@@ -691,7 +699,7 @@ fi
691
699
  if [ -x "$CHAINED" ]; then
692
700
  ${chainedInvocation}
693
701
  fi
694
- ${aimInvocation}
702
+ ${phaseShortCircuit}${aimInvocation}
695
703
  `;
696
704
  const fingerprint = hookFingerprint(template);
697
705
  return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);