@rmyndharis/aimhooman 0.1.8 → 0.2.0

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.8",
12
+ "version": "0.2.0",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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.8",
3
+ "version": "0.2.0",
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,93 @@ 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.2.0] - 2026-07-19
9
+
10
+ This release works through the findings of the cross-ecosystem field test
11
+ (8 upstream clones, 6 language loops, a local bare remote, and a private
12
+ GitHub sandbox). The headline: `allow` no longer says yes when it means no —
13
+ a path allow on a file whose content holds a secret is refused up front,
14
+ with the working escape hatch named in the same breath.
15
+
16
+ ### Fixed
17
+
18
+ - Tracked-tree scans (`audit`, `check --tracked`, `init --grandfather-secrets`)
19
+ no longer die with `spawnSync git EPIPE` on a partial clone of a repository
20
+ with submodule pins. A gitlink names a commit of another repository, so
21
+ asking `cat-file` about it triggered a promisor fetch that aborted the
22
+ whole metadata batch ("not our ref"). Gitlinks now skip the metadata read;
23
+ their type comes from the mode. Found by the OpenSSL field run, whose 11
24
+ submodule pins made the grandfather flag seed zero allows.
25
+ - `init --grandfather-secrets` now runs its one-shot tracked scan with the
26
+ total-byte budget raised to the env cap and the finding budget raised to
27
+ 100,000. The commit-time defaults (64 MiB, 1,000 findings) silently missed
28
+ fixtures beyond them in exactly the large, fixture-heavy repositories the
29
+ flag exists for; the per-file budget and an explicit env override still
30
+ apply.
31
+ - `aimhooman allow <path>` on a file whose content matches a secret rule
32
+ (e.g. a private key inside an ordinary-looking filename) no longer reports
33
+ `allowed` while the commit stays blocked. The guard now runs the engine's
34
+ secret content rules over the file's bytes, refuses the allow, and points
35
+ at `--scope secret-path`. Files over the scan budget or unreadable skip
36
+ the check; the commit-time scanner still fails closed on those.
37
+ - The scan-incomplete summary now names what was skipped
38
+ (`skipped: size-limit=1 file`) instead of `(size-limit=1)`, which read as
39
+ a one-byte budget rather than a count of files.
40
+
41
+ ### Added
42
+
43
+ - `aimhooman init --grandfather-secrets`: after a successful init, scan the
44
+ tracked tree once and write a `--scope secret-path` allow for every path
45
+ already tracking secret-looking material (test certs, sample keys). New
46
+ secrets stay blocked; only paths found in that scan are allowed. A failed
47
+ or incomplete scan warns without failing the init.
48
+ - Provider-token findings name the provider in human output
49
+ (`A provider access token (GitHub) must not enter Git history.`), so the
50
+ developer knows which credential to revoke. The token itself stays
51
+ redacted; the JSON report is unchanged.
52
+ - Secret content rules now carry a remediation line naming the fixture
53
+ escape hatch: `aimhooman allow <path> --scope secret-path --reason
54
+ "test fixture"`.
55
+ - The pre-commit hook now names locally-ignored AI artifacts once per set
56
+ change (`3 AI artifact(s) present locally are kept out of commits: ...`).
57
+ The prevention layer keeps them out of `git status`, which also kept their
58
+ exclusion silent: a `git add .` never told the developer the chat log did
59
+ not make the commit. The worktree walk is pathspec-pruned to the managed
60
+ exclude patterns, so it costs ~15ms; informational only, it never changes
61
+ an exit code.
62
+ - The reference-transaction veto now notes that the rejected commit object
63
+ remains in the local object store — collected by `git gc --prune=now` when
64
+ nothing else references it — so a secret payload is not mistaken for gone.
65
+
66
+ ### Changed
67
+
68
+ - Repeated findings of the same rule print the remediation once, then
69
+ `fix: as above for <rule id>`, instead of reprinting an identical fix
70
+ block for every hit.
71
+ - `init` output now prints `undo: aimhooman uninstall` and notes that known
72
+ AI artifacts are ignored locally (`git status --ignored` shows them).
73
+
74
+ ### Performance
75
+
76
+ - `openRepo` resolves the repository in one `git rev-parse` call instead of
77
+ three; every hook spawn pays this, so it was the cheapest milliseconds on
78
+ the commit path. The common-dir answer is resolved against the invoking
79
+ cwd and canonicalized, preserving the previous spelling on symlinked
80
+ paths (macOS `/tmp`), and a path containing a newline falls back to one
81
+ flag per call.
82
+ - The reference-transaction dispatcher exits before the Node spawn for a
83
+ `prepared` transaction that moves neither a branch nor HEAD (ORIG_HEAD,
84
+ tags, remote-tracking refs). Such payloads carry nothing `refcheck`
85
+ scans — but the skip first proves the four dispatchers are still
86
+ installed, so a hook manager wiping them mid-operation is answered with
87
+ a stop, not silence. An ordinary commit's hook time drops by roughly a
88
+ third in the field measurement (982ms → 649ms).
89
+ - Installed Git hook shims export `NODE_COMPILE_CACHE` pointing at a
90
+ per-install directory under the state dir (removed by
91
+ `uninstall --purge-state`), shaving the module parse/compile cost off
92
+ each hook spawn. An unwritable cache dir degrades to the old cold start,
93
+ never to a hook failure.
94
+
8
95
  ## [0.1.8] - 2026-07-18
9
96
 
10
97
  This release works through the findings of the real-world scenario report
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.8-blue" alt="v0.1.8">
12
+ <img src="https://img.shields.io/badge/version-v0.2.0-blue" alt="v0.2.0">
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">
@@ -130,6 +130,7 @@ Guard a repository:
130
130
 
131
131
  ```sh
132
132
  aimhooman init # git hooks + local excludes; normally no worktree files
133
+ aimhooman init --grandfather-secrets # also allow --scope secret-path for every secret already tracked (fixtures); new secrets stay blocked
133
134
  aimhooman status
134
135
  aimhooman uninstall # restore hooks/excludes; keep local policy state
135
136
  aimhooman uninstall --purge-state # also delete common Git-directory state
package/bin/aimhooman.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
3
4
  import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
@@ -9,6 +10,7 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
9
10
  import {
10
11
  GitRevisionError,
11
12
  gitConfig,
13
+ ignoredByPatterns,
12
14
  introducedCommits,
13
15
  openRepo,
14
16
  readCommitPath,
@@ -20,10 +22,11 @@ import {
20
22
  withIndexFromTree,
21
23
  } from '../src/gitx.mjs';
22
24
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
23
- import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
25
+ import { applyExclude, inspectExclude, managedPatterns, patternsForRules, removeExclude } from '../src/exclude.mjs';
24
26
  import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
25
27
  import { ArgumentError, parseArguments } from '../src/args.mjs';
26
28
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
29
+ import { DEFAULT_SCAN_LIMITS } from '../src/scan-session.mjs';
27
30
  import { resolvePolicy } from '../src/policy-resolver.mjs';
28
31
  import { atomicWrite, withLock } from '../src/atomic-write.mjs';
29
32
  import { commitParents, resolveCommit } from '../src/history-scan.mjs';
@@ -214,8 +217,11 @@ function emitDiagnostics(diagnostics = []) {
214
217
 
215
218
  function incompleteMessage(scan) {
216
219
  const reasons = scan.stats?.skipped || {};
220
+ // Each count is how many items were skipped, not the limit that fired:
221
+ // "(size-limit=1)" read as a one-byte budget. Name the noun so the number
222
+ // cannot be mistaken for the budget it tripped.
217
223
  const skipped = Object.entries(reasons)
218
- .map(([reason, count]) => `${reason}=${count}`)
224
+ .map(([reason, count]) => `${reason}=${count} ${skipCountNoun(reason, count)}`)
219
225
  .join(', ');
220
226
  // Every other reason is a size or budget the caller can shrink. A pack that
221
227
  // will not compile is not, and the warning above already names the file and
@@ -229,7 +235,7 @@ function incompleteMessage(scan) {
229
235
  : budgeted
230
236
  ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
231
237
  : 'reduce the target or limits and retry';
232
- let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
238
+ let message = `aimhooman: scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
233
239
  const skippedPaths = scan.stats?.skippedPaths || {};
234
240
  const pathLines = [];
235
241
  for (const [reason, entries] of Object.entries(skippedPaths)) {
@@ -242,6 +248,17 @@ function incompleteMessage(scan) {
242
248
  return message + pathLines.join('');
243
249
  }
244
250
 
251
+ // Most skip reasons tally files; the three below tally something else, so the
252
+ // noun travels with the reason instead of reading "local-pack-error=1 file".
253
+ function skipCountNoun(reason, count) {
254
+ const noun = {
255
+ 'finding-limit': 'finding',
256
+ 'local-input-limit': 'input',
257
+ 'local-pack-error': 'pack',
258
+ }[reason] || 'file';
259
+ return `${noun}${count === 1 ? '' : 's'}`;
260
+ }
261
+
245
262
  function formatBytes(bytes) {
246
263
  if (bytes == null || bytes < 0) return '?';
247
264
  if (bytes < 1024) return `${bytes} B`;
@@ -371,6 +388,38 @@ function precommitCleanMatches(repo, treeSha, profile) {
371
388
  && marker.profile === profile;
372
389
  }
373
390
 
391
+ // F-E1: the prevention layer keeps AI artifacts out of `git status`, which
392
+ // also keeps their exclusion silent — a `git add .` never tells the developer
393
+ // the chat log did not make the commit. Name the artifacts once per set
394
+ // change. The worktree walk is pathspec-pruned to the anchored managed
395
+ // patterns (~15ms; a bare ignored-listing costs ~150ms on a large tree), so
396
+ // `**/`-prefixed duplicates are skipped: nested artifacts still get excluded,
397
+ // they just do not get the notice. Informational only — it must never change
398
+ // an exit code, so every failure inside degrades to silence.
399
+ const IGNORED_NOTICE_VERSION = 1;
400
+ function noticeIgnoredArtifacts(repo) {
401
+ try {
402
+ const patterns = managedPatterns(repo.excludeFile)
403
+ .filter((pattern) => !pattern.startsWith('**/'));
404
+ if (!patterns.length) return;
405
+ const paths = ignoredByPatterns(repo, patterns).sort();
406
+ if (!paths.length) return;
407
+ const hash = createHash('sha256').update(paths.join('\0')).digest('hex');
408
+ const statePath = join(repo.stateDir, 'ignored-notice.json');
409
+ let previous = null;
410
+ try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
411
+ if (previous?.version === IGNORED_NOTICE_VERSION && previous.hash === hash) return;
412
+ writeFileSync(statePath, JSON.stringify({ version: IGNORED_NOTICE_VERSION, hash }));
413
+ const shown = paths.slice(0, 5);
414
+ const more = paths.length - shown.length;
415
+ process.stderr.write(
416
+ `aimhooman: ${paths.length} AI artifact(s) present locally are kept out of commits: `
417
+ + `${shown.map(visible).join(', ')}${more ? `, and ${more} more` : ''} `
418
+ + "(they stay on disk; 'git status --ignored' lists them; shown once per set change)\n"
419
+ );
420
+ } catch { /* informational only */ }
421
+ }
422
+
374
423
  function cmdPrecommit(args) {
375
424
  parseNoArguments(args);
376
425
  const repo = tryRepo();
@@ -403,6 +452,7 @@ function cmdPrecommit(args) {
403
452
  // sha is stable. A missing/stale/mismatched marker makes commit-msg
404
453
  // fall back to the full scan, so this is purely an optimization.
405
454
  recordPrecommitClean(repo, profile);
455
+ noticeIgnoredArtifacts(repo);
406
456
  return profile === 'strict' && reviews.length ? 11 : 0;
407
457
  }
408
458
  if (profile === 'strict') {
@@ -479,6 +529,7 @@ function cmdPrecommit(args) {
479
529
  // not. The request was to commit the artifact, not to stamp the history with
480
530
  // its message and no content.
481
531
  if (emptied) return 10;
532
+ if (scan.complete) noticeIgnoredArtifacts(repo);
482
533
  return scan.complete ? 0 : 31;
483
534
  }
484
535
 
@@ -746,6 +797,15 @@ function cmdRefcheck(args) {
746
797
  process.stderr.write(human(scan.findings, tone()));
747
798
  if (!scan.complete) process.stderr.write(incompleteMessage(scan));
748
799
  process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
800
+ // The vetoed commit stays in the object store: the ref never
801
+ // moved, but the bytes — including whatever triggered the block —
802
+ // are still on disk. Whether gc collects it depends on other refs,
803
+ // so phrase the cleanup as conditional; rotating an exposed secret
804
+ // stays the operator's call either way.
805
+ process.stderr.write(
806
+ `aimhooman: note: the rejected commit object remains in the local object store; `
807
+ + "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n"
808
+ );
749
809
  return code;
750
810
  }
751
811
  }
@@ -840,8 +900,9 @@ function cmdInit(args) {
840
900
  profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
841
901
  global: { names: ['--global'], type: 'boolean' },
842
902
  yes: { names: ['--yes'], type: 'boolean' },
903
+ grandfatherSecrets: { names: ['--grandfather-secrets'], type: 'boolean' },
843
904
  },
844
- conflicts: [['profile', 'global']],
905
+ conflicts: [['profile', 'global'], ['grandfatherSecrets', 'global']],
845
906
  maxPositionals: 0,
846
907
  });
847
908
  if (rejectUnsupportedGit()) return 20;
@@ -926,7 +987,7 @@ function cmdInit(args) {
926
987
  console.error('aimhooman: not a git repository');
927
988
  return 30;
928
989
  }
929
- return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
990
+ const code = withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
930
991
  let projectPolicy;
931
992
  try {
932
993
  projectPolicy = loadProjectPolicy(repo.root);
@@ -1014,11 +1075,77 @@ function cmdInit(args) {
1014
1075
  console.log(`aimhooman: initialised (profile: ${profile})`);
1015
1076
  console.log(` state: ${repo.stateDir}`);
1016
1077
  console.log(` excludes: ${repo.excludeFile}`);
1078
+ console.log(` note: known AI artifacts are now ignored locally; see them with 'git status --ignored'`);
1017
1079
  if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
1018
1080
  if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
1019
1081
  for (const warning of rep.warnings) console.log(` warning: ${warning}`);
1082
+ console.log(' undo: aimhooman uninstall');
1020
1083
  return 0;
1021
1084
  }, LIFECYCLE_LOCK_OPTIONS);
1085
+ if (code === 0 && options.grandfatherSecrets) grandfatherTrackedSecrets(repo);
1086
+ return code;
1087
+ }
1088
+
1089
+ // --grandfather-secrets: a repository that already tracks secret-looking
1090
+ // fixtures (test certs, sample keys) would see every commit touching one
1091
+ // blocked, and allowing each path by hand does not scale. After a successful
1092
+ // init, scan the tracked tree once and write a --scope secret-path allow for
1093
+ // every path with a secret finding. The posture for NEW files is unchanged —
1094
+ // only paths found in this scan get an allow, so a secret added after init is
1095
+ // still blocked. A failed or incomplete scan never fails the init itself: the
1096
+ // guard is already active by then, so the gap is reported as a warning.
1097
+ function grandfatherTrackedSecrets(repo) {
1098
+ let scan;
1099
+ try {
1100
+ // Commit-time budgets exist for latency, but a grandfather scan that
1101
+ // stops at the default 64 MiB total or 1,000 findings silently misses
1102
+ // fixtures in exactly the large, fixture-heavy repositories it exists
1103
+ // for (OpenSSL tracks ~200 MiB and its 279 key files produce more
1104
+ // than a thousand PEM findings). This is a one-shot, operator-invoked
1105
+ // scan of the operator's own repository, so the defaults here are the
1106
+ // same cap an env raise could reach plus a generous finding budget;
1107
+ // the per-file budget and an explicit env override still apply.
1108
+ scan = scanGitTarget(repo, {
1109
+ kind: 'tracked',
1110
+ limits: { maxTotalBytes: MAX_SCAN_LIMIT_BYTES, maxFindings: 100_000, ...scanLimits() },
1111
+ });
1112
+ } catch (error) {
1113
+ console.error(`aimhooman: warning: grandfather scan failed (${error.message}); no pre-existing secret paths were allowed`);
1114
+ return;
1115
+ }
1116
+ if (!scan.complete) {
1117
+ console.error('aimhooman: warning: tracked scan was incomplete, so the grandfathered set may be partial; allow the remaining paths with --scope secret-path');
1118
+ }
1119
+ const paths = [...new Set(scan.findings
1120
+ .filter((finding) => finding.category === 'secret' && finding.path)
1121
+ .map((finding) => finding.path))].sort();
1122
+ if (!paths.length) {
1123
+ console.log('aimhooman: no tracked secret findings to grandfather');
1124
+ return;
1125
+ }
1126
+ const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
1127
+ const added = withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
1128
+ const ov = loadOverrides(repo.stateDir);
1129
+ let count = 0;
1130
+ for (const path of paths) {
1131
+ const entry = {
1132
+ target: path,
1133
+ scope: 'secret-path',
1134
+ reason: 'pre-existing tracked fixture (grandfathered at init)',
1135
+ actor: gitConfig(repo.root, 'user.email'),
1136
+ at: new Date().toISOString(),
1137
+ };
1138
+ const sameAllow = (candidate) => (
1139
+ candidate.target === path && effectiveOverrideScope(candidate, engine) === 'secret-path'
1140
+ );
1141
+ if (ov.allow.some(sameAllow)) continue;
1142
+ ov.allow = upsert(ov.allow, entry, sameAllow);
1143
+ count += 1;
1144
+ }
1145
+ saveOverrides(repo.stateDir, ov);
1146
+ return count;
1147
+ });
1148
+ console.log(`aimhooman: grandfathered ${added} tracked secret path(s) with --scope secret-path allows; new files with secrets are still blocked`);
1022
1149
  }
1023
1150
 
1024
1151
  function cmdStatus(args) {
@@ -1131,6 +1258,25 @@ function cmdExplain(args) {
1131
1258
  return 0;
1132
1259
  }
1133
1260
 
1261
+ // The content half of the secret-allow guard in cmdOverride: run the engine's
1262
+ // own secret-category content rules over the target's bytes. Files over the
1263
+ // scanner's per-file budget, unreadable paths, and non-regular files skip it —
1264
+ // this guard is a UX safety net against an allow that would report success yet
1265
+ // leave the block in place, and the commit-time scanner still fails closed on
1266
+ // whatever was not checked here.
1267
+ function fileContentMatchesSecret(engine, repo, target) {
1268
+ let content;
1269
+ try {
1270
+ const file = join(repo.root, target);
1271
+ const stat = lstatSync(file);
1272
+ if (!stat.isFile() || stat.size > DEFAULT_SCAN_LIMITS.maxFileBytes) return false;
1273
+ content = readFileSync(file, 'utf8');
1274
+ } catch {
1275
+ return false;
1276
+ }
1277
+ return engine.checkContent(target, content, { categories: ['secret'] }).length > 0;
1278
+ }
1279
+
1134
1280
  function cmdOverride(args, allow) {
1135
1281
  const verb = allow ? 'allow' : 'deny';
1136
1282
  const { options, positionals } = parseArguments(args, {
@@ -1162,9 +1308,13 @@ function cmdOverride(args, allow) {
1162
1308
  // allow: only --scope secret-path suppresses secret findings, deliberately,
1163
1309
  // so a local override cannot hide a possible leaked key. Without this check
1164
1310
  // a bare `allow .env.minimal` would report success but leave the block in
1165
- // place, which reads as a broken allow.
1311
+ // place, which reads as a broken allow. checkPaths only evaluates path
1312
+ // rules, so a content-shaped secret (a private key inside an
1313
+ // ordinary-looking filename) would slip past it; the content half of the
1314
+ // guard closes that hole.
1166
1315
  if (allow && scope !== 'secret-path'
1167
- && engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
1316
+ && (engine.checkPaths([target]).some((finding) => finding.category === 'secret')
1317
+ || fileContentMatchesSecret(engine, repo, target))) {
1168
1318
  throw new ArgumentError(
1169
1319
  `"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
1170
1320
  + '(a local override must not hide a possible leaked key); '
@@ -1874,7 +2024,7 @@ function usage() {
1874
2024
  process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
1875
2025
 
1876
2026
  Usage:
1877
- aimhooman init [--profile clean|strict|compliance]
2027
+ aimhooman init [--profile clean|strict|compliance] [--grandfather-secrets]
1878
2028
  aimhooman init --global --yes
1879
2029
  aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
1880
2030
  aimhooman check --commit <rev> | --range <base>...<head> | --tracked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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": {
@@ -18,7 +18,8 @@
18
18
  },
19
19
  "reason": "Private-key material must not enter Git history.",
20
20
  "remediation": [
21
- "Remove the key from the index, rotate it if exposed, and store it outside the repository."
21
+ "Remove the key from the index, rotate it if exposed, and store it outside the repository.",
22
+ "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
22
23
  ]
23
24
  },
24
25
  {
@@ -40,7 +41,8 @@
40
41
  },
41
42
  "reason": "A service-account credential must not enter Git history.",
42
43
  "remediation": [
43
- "Remove the credential from the index, rotate it if exposed, and use the service's secret store."
44
+ "Remove the credential from the index, rotate it if exposed, and use the service's secret store.",
45
+ "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
44
46
  ]
45
47
  },
46
48
  {
@@ -63,7 +65,8 @@
63
65
  },
64
66
  "reason": "AWS credential material must not enter Git history.",
65
67
  "remediation": [
66
- "Remove the credential from the index, rotate it if exposed, and use an AWS credential provider."
68
+ "Remove the credential from the index, rotate it if exposed, and use an AWS credential provider.",
69
+ "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
67
70
  ]
68
71
  },
69
72
  {
@@ -86,7 +89,8 @@
86
89
  },
87
90
  "reason": "A provider access token must not enter Git history.",
88
91
  "remediation": [
89
- "Remove the token from the index, revoke or rotate it, and use the provider's secret store."
92
+ "Remove the token from the index, revoke or rotate it, and use the provider's secret store.",
93
+ "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
90
94
  ]
91
95
  }
92
96
  ]
package/src/exclude.mjs CHANGED
@@ -74,6 +74,20 @@ export function inspectExclude(file, patterns) {
74
74
  return { installed: true, current: missing.length === 0 && actual.size === patterns.length, missing };
75
75
  }
76
76
 
77
+ // managedPatterns returns the pattern lines currently inside the managed
78
+ // block, or [] when the file or block is absent. Read-only.
79
+ export function managedPatterns(file) {
80
+ const existing = readExclude(file, null);
81
+ if (existing === null) return [];
82
+ const range = managedRange(existing);
83
+ if (!range) return [];
84
+ return existing
85
+ .slice(range.start + BEGIN.length, range.end)
86
+ .split('\n')
87
+ .map((line) => line.trim())
88
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('!'));
89
+ }
90
+
77
91
  function stripBlock(s) {
78
92
  const range = managedRange(s);
79
93
  if (!range) return s;
package/src/githooks.mjs CHANGED
@@ -651,6 +651,12 @@ function hookScript(name, cmd, cliPath, chainedPath) {
651
651
  const shellPathValue = hookPathForShell(pathValue);
652
652
  const nodeMetadata = Buffer.from(nodePath, 'utf8').toString('base64url');
653
653
  const pathMetadata = Buffer.from(pathValue, 'utf8').toString('base64url');
654
+ // chainedPath is <stateDir>/chained/<name> for a repository install and
655
+ // <globalHooksDir>/chained/<name> for a global one, so its grandparent is
656
+ // always per-install state. The compile cache lives there rather than in a
657
+ // shared or temp location: no other repository can pollute it, and
658
+ // `uninstall --purge-state` removes it along with the rest of the state.
659
+ const compileCacheDir = join(dirname(dirname(String(chainedPath))), 'compile-cache');
654
660
  const captureTree = name === 'commit-msg'
655
661
  ? `AIMHOOMAN_COMMIT_TREE=$(PATH="$AIMHOOMAN_PATH" git write-tree) || {
656
662
  echo "aimhooman: cannot snapshot the would-be commit tree" >&2
@@ -690,10 +696,31 @@ function hookScript(name, cmd, cliPath, chainedPath) {
690
696
  // committed/aborted fire only after refs are locked in, and refcheck can do
691
697
  // nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
692
698
  // so an ordinary commit no longer pays a Node cold start for the committed
693
- // phase. Placed after the chained-hook call, so a chained hook still sees
694
- // every phase.
699
+ // phase. The prepared filter goes further: a transaction that moves neither
700
+ // a branch nor HEAD (ORIG_HEAD, tags, remote-tracking refs) carries nothing
701
+ // refcheck scans, so it never spawns at all — but only after proving the
702
+ // guard is still there to skip. A hook manager that wipes the dispatchers
703
+ // during a branch-free operation (tag, fetch, stash) would otherwise go
704
+ // unnoticed, because after a full wipe no later hook exists to raise the
705
+ // alarm. Presence is exactly what a deletion removes; content integrity
706
+ // keeps running on every branch transaction via refcheck. Both sit after
707
+ // the chained-hook call, so a chained hook still sees every phase.
695
708
  const phaseShortCircuit = name === 'reference-transaction'
696
- ? 'case "$1" in committed|aborted) exit 0 ;; esac\n'
709
+ ? `case "$1" in committed|aborted) exit 0 ;; esac
710
+ case "$1" in prepared)
711
+ case "$AIMHOOMAN_REF_UPDATES" in
712
+ *refs/heads/*|*" HEAD"*) ;;
713
+ *)
714
+ for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction; do
715
+ [ -x "$(dirname "$0")/$AIMHOOMAN_GUARD" ] || {
716
+ echo "aimhooman: required Git guards changed while reference-transaction was running; $AIMHOOMAN_GUARD is unavailable. The operation was stopped; run 'aimhooman init' and retry." >&2
717
+ exit 20
718
+ }
719
+ done
720
+ exit 0 ;;
721
+ esac ;;
722
+ esac
723
+ `
697
724
  : '';
698
725
  const template = `#!/bin/sh -p
699
726
  ${MARKER} (${name})
@@ -706,6 +733,7 @@ ${MARKER} (${name})
706
733
  AIMHOOMAN_CLI=${shq(resolvedCliPath)}
707
734
  AIMHOOMAN_NODE=${shq(nodePath)}
708
735
  AIMHOOMAN_PATH=${shq(shellPathValue)}
736
+ AIMHOOMAN_COMPILE_CACHE=${shq(compileCacheDir)}
709
737
  ${captureTree}${captureTransaction}run_aimhooman() {
710
738
  if [ ! -f "$AIMHOOMAN_CLI" ]; then
711
739
  echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
@@ -731,8 +759,15 @@ ${captureTree}${captureTransaction}run_aimhooman() {
731
759
  unset BASH_ENV ENV CDPATH
732
760
  PATH=$AIMHOOMAN_PATH
733
761
  AIMHOOMAN_ACTIVE_HOOK=${shq(name)}
762
+ # A V8 compile cache shared by every hook spawn of this installation shaves
763
+ # the module parse/compile cost off each Node start. Node creates the
764
+ # directory when it can and silently disables the cache when it cannot, so
765
+ # an unwritable state directory degrades to the old cold start, never to a
766
+ # hook failure.
767
+ NODE_COMPILE_CACHE=$AIMHOOMAN_COMPILE_CACHE
734
768
  export PATH
735
769
  export AIMHOOMAN_ACTIVE_HOOK
770
+ export NODE_COMPILE_CACHE
736
771
  "$AIMHOOMAN_NODE" "$AIMHOOMAN_CLI" "$@"
737
772
  )
738
773
  }
package/src/gitx.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  rmSync,
13
13
  symlinkSync,
14
14
  } from 'node:fs';
15
- import { isAbsolute, join } from 'node:path';
15
+ import { isAbsolute, join, resolve } from 'node:path';
16
16
  import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
17
17
 
18
18
  export class GitRevisionError extends Error {
@@ -99,7 +99,23 @@ function objectMetadata(repo, oids) {
99
99
  }
100
100
 
101
101
  function enrichEntries(repo, entries) {
102
- const metadata = objectMetadata(repo, entries.map((entry) => entry.oid));
102
+ const metadata = objectMetadata(
103
+ repo,
104
+ entries.filter((entry) => entry.mode !== '160000').map((entry) => entry.oid),
105
+ );
106
+ // Gitlinks (mode 160000) pin a commit of another repository. Enrich them
107
+ // from the local object store when the pin happens to be there, but never
108
+ // let the read fail the scan: in a partial clone asking cat-file about a
109
+ // pin that is not local triggers a promisor fetch that aborts the whole
110
+ // batch ("not our ref"), surfacing as an EPIPE. A failed read leaves the
111
+ // mode-derived type in place with no size.
112
+ try {
113
+ const gitlinks = objectMetadata(
114
+ repo,
115
+ entries.filter((entry) => entry.mode === '160000').map((entry) => entry.oid),
116
+ );
117
+ for (const [oid, object] of gitlinks) metadata.set(oid, object);
118
+ } catch { /* best effort — see above */ }
103
119
  return entries.map((entry) => {
104
120
  const object = metadata.get(entry.oid) || { type: null, size: null };
105
121
  return {
@@ -146,10 +162,45 @@ function diffEntries(repo, args, renameThreshold = null) {
146
162
 
147
163
  // openRepo resolves the repository containing cwd. Throws if not a repo.
148
164
  export function openRepo(cwd = process.cwd()) {
149
- const root = gitStr(['rev-parse', '--show-toplevel'], cwd);
150
- const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
151
- let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
152
- if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
165
+ // One rev-parse invocation, not three: this runs inside every Git hook
166
+ // spawn, and each git subprocess costs ~13ms of the commit-time budget.
167
+ // The flags print in request order, one value per line.
168
+ const lines = gitStr(
169
+ ['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'],
170
+ cwd,
171
+ ).split('\n');
172
+ if (lines.length !== 3) {
173
+ // A newline inside the repository path breaks the multi-flag output
174
+ // into extra lines; one flag per call is immune to that.
175
+ const root = gitStr(['rev-parse', '--show-toplevel'], cwd);
176
+ const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
177
+ let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
178
+ if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
179
+ return {
180
+ root,
181
+ gitDir,
182
+ commonDir,
183
+ stateDir: join(commonDir, 'aimhooman'),
184
+ excludeFile: join(commonDir, 'info', 'exclude'),
185
+ };
186
+ }
187
+ const [root, gitDir, commonRaw] = lines;
188
+ // Only --git-common-dir comes back relative, and it is relative to the cwd
189
+ // the command ran in, never to the toplevel. Anchor the plain-'.git' case
190
+ // (a main worktree from any cwd spelling) on git's own canonical root —
191
+ // the same spelling the three-call version produced — instead of
192
+ // realpath'ing through the cwd: git lengthens 8.3 names on Windows and
193
+ // resolves symlinked cwds on macOS, and matching it keeps one spelling
194
+ // per repository. Anything else (a subdir's ../.git, an uncommon layout)
195
+ // resolves against the invoking cwd, which is the same directory.
196
+ let commonDir;
197
+ if (isAbsolute(commonRaw)) {
198
+ commonDir = commonRaw;
199
+ } else if (commonRaw === '.git') {
200
+ commonDir = join(root, commonRaw);
201
+ } else {
202
+ commonDir = resolve(cwd, commonRaw);
203
+ }
153
204
  return {
154
205
  root,
155
206
  gitDir,
@@ -177,6 +228,17 @@ export function stagedPaths(repo) {
177
228
  ], repo.root));
178
229
  }
179
230
 
231
+ // ignoredByPatterns lists untracked, ignored paths under the given pathspecs.
232
+ // Passing pathspecs keeps git's directory pruning effective: a bare
233
+ // `ls-files --others --ignored` walks the whole worktree and costs ~150ms on
234
+ // a large one, while anchored pathspecs answer in ~15ms.
235
+ export function ignoredByPatterns(repo, pathspecs) {
236
+ if (!pathspecs.length) return [];
237
+ return nulStrings(gitBuf([
238
+ 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...pathspecs,
239
+ ], repo.root));
240
+ }
241
+
180
242
  // stagedTreeSha writes the index to a tree object and returns its SHA, the same
181
243
  // value `git write-tree` produces in the commit-msg dispatcher. Used by the
182
244
  // pre-commit/commit-msg marker dedup (W5): pre-commit records this sha when it
package/src/report.mjs CHANGED
@@ -7,6 +7,33 @@
7
7
  // for the rest. The JSON report (aimhooman review / --json) is never capped.
8
8
  const HUMAN_FINDING_CAP = 20;
9
9
 
10
+ // UT-06: prefix -> provider for secret.provider-token findings. The rule packs
11
+ // every provider into one pattern set (rules/secrets.json), so the provider
12
+ // name is recovered from the raw matched line at report time — before the
13
+ // redaction below runs. Only the label is ever rendered, never the token.
14
+ // Mirrors the rule's patterns; keep the rule's alternation order.
15
+ const TOKEN_PROVIDERS = [
16
+ ['ghp_', 'GitHub'], ['gho_', 'GitHub'], ['ghu_', 'GitHub'], ['ghs_', 'GitHub'],
17
+ ['ghr_', 'GitHub'], ['github_pat_', 'GitHub'],
18
+ ['glpat-', 'GitLab'],
19
+ ['npm_', 'npm'],
20
+ ['xoxb-', 'Slack'], ['xoxp-', 'Slack'], ['xoxa-', 'Slack'], ['xoxr-', 'Slack'], ['xoxs-', 'Slack'],
21
+ ['sk-ant-', 'Anthropic'],
22
+ ['sk-proj-', 'OpenAI'],
23
+ ['AIza', 'Google'],
24
+ ['sk_live_', 'Stripe'], ['rk_live_', 'Stripe'],
25
+ ['hf_', 'Hugging Face'],
26
+ ['SG.', 'SendGrid'],
27
+ ];
28
+
29
+ function tokenProvider(text) {
30
+ if (!text) return undefined;
31
+ for (const [prefix, provider] of TOKEN_PROVIDERS) {
32
+ if (text.includes(prefix)) return provider;
33
+ }
34
+ return undefined;
35
+ }
36
+
10
37
  export function human(findings, tone) {
11
38
  if (!findings.length) return '';
12
39
  let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
@@ -21,6 +48,7 @@ export function human(findings, tone) {
21
48
  // while bounding stderr for repeated-rule scans.
22
49
  const shown = findings.slice(0, HUMAN_FINDING_CAP);
23
50
  const hidden = findings.length - shown.length;
51
+ const fixesPrinted = new Set();
24
52
  for (const f of shown) {
25
53
  const loc = f.path
26
54
  ? `${visible(f.path)}${f.line ? `:${f.line}` : ''}`
@@ -28,15 +56,30 @@ export function human(findings, tone) {
28
56
  const related = (f.matchedRuleIds || []).filter((id) => id !== f.ruleId);
29
57
  const identity = related.length ? `${f.ruleId} (+ ${related.join(', ')})` : f.ruleId;
30
58
  const commit = f.commit ? ` [commit ${String(f.commit).slice(0, 12)}]` : '';
31
- out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${f.reason}\n`;
59
+ // UT-06: a provider-token finding used to say only "a provider access
60
+ // token", leaving the developer to guess which credential to revoke.
61
+ // Name the provider in the reason; the token itself stays redacted.
62
+ const provider = f.ruleId === 'secret.provider-token' ? tokenProvider(f.text) : undefined;
63
+ const reason = provider
64
+ ? f.reason.replace('provider access token', `provider access token (${provider})`)
65
+ : f.reason;
66
+ out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${reason}\n`;
32
67
  if (f.text && f.text.trim()) {
33
68
  out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
34
69
  }
35
70
  // Render the whole remediation array, not just the first entry. Several
36
71
  // 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`;
72
+ // that the previous single-index render dropped silently. UT-08: print a
73
+ // rule's fix once — a repeated rule (20 hits of the same secret rule)
74
+ // used to reprint the identical fix block for every finding.
75
+ const remedies = f.remediation || [];
76
+ if (remedies.length && fixesPrinted.has(f.ruleId)) {
77
+ out += ` fix: as above for ${f.ruleId}\n`;
78
+ } else {
79
+ if (remedies.length) fixesPrinted.add(f.ruleId);
80
+ for (const remedy of remedies) {
81
+ out += ` fix: ${remedy}\n`;
82
+ }
40
83
  }
41
84
  out += '\n';
42
85
  }