@rmyndharis/aimhooman 0.1.0 → 0.1.1

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.0",
12
+ "version": "0.1.1",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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.0",
3
+ "version": "0.1.1",
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
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-07-16
11
+
12
+ ### Fixed
13
+
14
+ - Git hooks no longer trap the repository when the aimhooman CLI or Node is
15
+ missing (for example after the package was removed without `aimhooman
16
+ uninstall`). The dispatcher used to exit 127 and abort every commit; it now
17
+ warns once and allows the operation without protection, so a half-removed
18
+ install degrades gracefully instead of breaking Git.
19
+ - The `PreToolUse` guard no longer denies ordinary read-only pipelines. Any pipe
20
+ made the parsed command uncertain, and the clean and compliance profiles read
21
+ that uncertainty as a possibly hidden commit, so everyday lines such as
22
+ `gh issue view 1 | tail -5` were blocked. A pipeline now passes when every
23
+ segment is a known read-only command and the line carries no opaque shell
24
+ syntax; everything else stays denied, including pipe-to-shell, subshells,
25
+ `eval`, and readers that execute their own input. `strict` is unchanged.
26
+ - A bare `allow <path>` for a path that matches a secret rule (for example
27
+ `.env.minimal`) no longer reports success while leaving the block in place.
28
+ It now fails closed and directs to `--scope secret-path`, the only scope that
29
+ can silence a secret, so a local override cannot hide a possible leaked key.
30
+ - Detect secrets renamed to a neutral path. A path-only secret such as `.env`
31
+ was missed when moved to a name the destination scan does not match, because its
32
+ content carries no PEM, AWS, or token shape; the rename-source review now retains
33
+ secret-category findings and reports them on the destination path where the bytes
34
+ live, so clean-profile repair unstages the blob that carries the secret. Deleting
35
+ a secret path remains a non-finding.
36
+ - Retry the atomic rename that commits a state write when Windows reports a
37
+ transient `EPERM`, `EACCES`, or `EBUSY`. An antivirus or indexer holding a
38
+ handle on the file aimhooman had just written could kill a lock contender at
39
+ its ticket publication, which surfaced as unrelated failures (a lifecycle-queue
40
+ timeout, or a repair that appeared not to run). A persistent failure, any other
41
+ error code, and every non-Windows platform still fail immediately, and the
42
+ original file is never left partially written.
43
+ - The clean-profile repair now verifies it cleared every target and re-runs the
44
+ unstage when a transient git operation under heavy load left a path staged, so
45
+ the repair no longer reports success while an artifact rides through.
46
+ - Derive the post-repair empty-commit hint from the staged paths captured
47
+ before repair instead of a second git read after `git restore --staged`. That
48
+ read followed an index write and could transiently report the wrong state
49
+ under heavy load; the derivation is deterministic.
50
+
51
+ ### Changed
52
+
53
+ - The release workflow runs `npm run verify` before publish instead of `npm test`,
54
+ so the tarball-manifest check and the installed-hook smoke test gate the published
55
+ tag, matching the push workflow.
56
+ - Non-hook commands no longer load the PreToolUse shell parser at startup; it is
57
+ imported only for the `hook` subcommand, which speeds up `init`, `uninstall`,
58
+ `check`, `status`, and the rest and gives the lifecycle-lock queue more headroom
59
+ on slow runners.
60
+
10
61
  ## [0.1.0] - 2026-07-15
11
62
 
12
63
  First public release. aimhooman keeps AI coding-agent residue out of Git history
@@ -30,10 +81,9 @@ commit messages, and AI markers left in code.
30
81
  - Scan every index stage during unresolved conflicts so a secret on either side
31
82
  cannot be missed.
32
83
  - Authorize protected-path CI only when the exact workflow-run attempt's actor and
33
- triggering actor, including their IDs, match the current personal-repository
34
- owner returned by GitHub. Bind that authorization to exact commits, paths,
35
- blobs, modes, deletion tombstones, and policy migrations; non-owner changes fail
36
- closed with no reviewer fallback.
84
+ triggering actor, including their IDs, match the repository owner returned by
85
+ GitHub. Bind that authorization to exact commits, paths, blobs, modes, deletion
86
+ tombstones, and policy migrations; changes not attributed to the owner fail closed.
37
87
  - Keep the published local rule-pack schema compatible with strict JSON Schema
38
88
  validators.
39
89
  - Prove npm version absence and forward dist-tag movement with bounded retries
package/CONTRIBUTING.md CHANGED
@@ -107,38 +107,18 @@ Commits must read as if a human wrote them:
107
107
 
108
108
  By participating, you agree to abide by the [Code of Conduct](CODE_OF_CONDUCT.md).
109
109
 
110
- This is a personal, owner-only repository. GitHub branch rules require the `tests`
111
- workflow but no pull-request approval, code-owner review, last-push approval, or
112
- other reviewer. The repository has no `CODEOWNERS` fallback.
113
-
114
- Changes to agent instructions or `.aimhooman.json` are protected-path changes. CI
115
- verifies the pinned repository and owner login plus numeric IDs through the GitHub
116
- API, then fetches the exact workflow-run attempt. GitHub must attribute the
117
- attempt's `actor` and `triggering_actor` to that owner's login and numeric ID. CI
118
- binds that authorization to the exact
119
- head, transition commit, path, resulting blob and regular-file mode, or deletion
120
- tombstone. Policy migrations additionally bind the old and new policy objects.
121
- Another commit, attempt, path result, mode, or migration needs fresh authorization.
122
- A protected-path change by any non-owner fails closed.
123
-
124
- The owner account and its credentials are the repository trust root. These checks
125
- verify GitHub's actor attribution and bind the resulting objects; they do not prove
126
- an interactive human action, provide independent review, or protect against a
127
- compromised owner credential.
110
+ ## Releases
128
111
 
129
112
  Releases publish to npm automatically when a `v*` tag is pushed. The workflow
130
- installs dependencies, runs the test suite, and publishes with npm build provenance,
131
- authenticated by the `NPM_TOKEN` secret. To cut a release: bump the version (keep
132
- `package.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and
133
- `.claude-plugin/marketplace.json` in sync), add the `## [version]` CHANGELOG entry
134
- and the matching README version badge, then tag and push:
113
+ installs dependencies, runs the test suite, and publishes with npm build provenance.
114
+ To cut a release, bump the version (keep `package.json`, `.claude-plugin/plugin.json`,
115
+ `.codex-plugin/plugin.json`, and `.claude-plugin/marketplace.json` in sync), add the
116
+ `## [version]` CHANGELOG entry and the matching README version badge, then:
135
117
 
136
118
  ```sh
137
119
  git tag -a v0.1.0 -m v0.1.0
138
120
  git push origin v0.1.0
139
121
  ```
140
122
 
141
- Protect `v*` tags so only the owner can create them and tag update or deletion is
142
- blocked. Use a granular, publish-only, package-scoped npm token with 2FA enabled
143
- and rotate it regularly. Do not run `npm publish` or move dist-tags manually while
144
- a release job is pending or running.
123
+ Use a granular, publish-only npm token with 2FA enabled, and avoid publishing or
124
+ moving dist-tags manually while a release job is running.
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.0-blue" alt="v0.1.0">
12
+ <img src="https://img.shields.io/badge/version-v0.1.1-blue" alt="v0.1.1">
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">
@@ -286,17 +286,15 @@ Under `strict`, policy files and agent instructions produce review-required find
286
286
  The product's `review` and `policy-review` commands record local, object-bound decisions;
287
287
  an ordinary path allow cannot satisfy either finding.
288
288
 
289
- This personal repository does not use GitHub approvals, required reviewers, or
290
- `CODEOWNERS`. For a protected-path change, CI verifies the pinned repository and owner
291
- login plus numeric IDs through the GitHub API, then inspects the exact workflow-run
292
- attempt. GitHub must attribute both `actor` and `triggering_actor`, including their
293
- numeric IDs, to that owner. CI then binds the
294
- authorization to the exact head, transition commit, path, resulting blob and regular-file
295
- mode, or deletion tombstone. A strict-policy migration also binds its old and new policy
296
- objects. A different attempt, commit, path result, mode, or policy transition needs fresh
297
- authorization. A non-owner change fails closed; there is no reviewer or `CODEOWNERS`
298
- fallback. The owner account and its credentials are the trust root, so this is owner
299
- authorization, not independent review.
289
+ For a protected-path change, CI verifies the pinned repository and owner login plus
290
+ numeric IDs through the GitHub API, then inspects the exact workflow-run attempt.
291
+ GitHub must attribute both `actor` and `triggering_actor`, including their numeric IDs,
292
+ to that owner. CI then binds the authorization to the exact head, transition commit,
293
+ path, resulting blob and regular-file mode, or deletion tombstone. A strict-policy
294
+ migration also binds its old and new policy objects. A different attempt, commit, path
295
+ result, mode, or policy transition needs fresh authorization. A change not attributed to
296
+ the owner fails closed. This is owner authorization verified through GitHub attribution,
297
+ not independent review.
300
298
 
301
299
  ## Overrides
302
300
 
package/SECURITY.md CHANGED
@@ -54,16 +54,14 @@ only the latest minor release will receive security fixes.
54
54
  - A committed `.aimhooman.json` provides the versioned team profile. Invalid
55
55
  project policy fails closed; per-clone allow/deny entries remain local and
56
56
  should be governed by team process where compliance requires it.
57
- - This is a personal, owner-only repository with no GitHub approval, required
58
- reviewer, or `CODEOWNERS` gate. For protected-path changes, CI resolves the
59
- pinned repository and owner through the GitHub API and fetches the exact
60
- workflow-run attempt. GitHub must attribute both the actor and triggering actor
61
- to the owner's login and numeric ID. The resulting decision is bound to the exact head,
62
- transition commit, path, blob and regular-file mode, or deletion tombstone; a
63
- policy migration also binds its old and new objects. Non-owner changes fail
64
- closed with no reviewer fallback. The owner is the trust root, not an independent
65
- reviewer. These checks verify GitHub attribution, not an interactive human action,
66
- and cannot defend against malicious or compromised owner credentials.
57
+ - For protected-path changes (agent instruction files, `.aimhooman.json`), CI resolves
58
+ the repository and owner through the GitHub API and fetches the exact workflow-run
59
+ attempt. GitHub must attribute both the actor and triggering actor to the owner's
60
+ login and numeric ID. The resulting decision is bound to the exact head, transition
61
+ commit, path, blob, and regular-file mode (or deletion tombstone); a policy migration
62
+ also binds its old and new objects. Changes not attributed to the owner fail closed.
63
+ These checks verify GitHub actor attribution, not an interactive human action, and
64
+ cannot defend against a compromised owner credential.
67
65
  - The release pipeline pins actions to immutable commit SHAs and publishes with npm
68
66
  build provenance. Pushing a `v*` tag runs the workflow: it installs dependencies,
69
67
  runs the test suite, then publishes to npm authenticated by the `NPM_TOKEN` secret
package/bin/aimhooman.mjs CHANGED
@@ -8,11 +8,11 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
8
8
  import {
9
9
  GitRevisionError,
10
10
  gitConfig,
11
- hasStagedChanges,
12
11
  introducedCommits,
13
12
  openRepo,
14
13
  readCommitPath,
15
14
  readStagedPath,
15
+ stagedPaths,
16
16
  stagedRenameSources,
17
17
  unstagePaths,
18
18
  withIndexFromTree,
@@ -20,7 +20,6 @@ import {
20
20
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
21
21
  import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
22
22
  import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
- import { runHook } from '../src/hook.mjs';
24
23
  import { ArgumentError, parseArguments } from '../src/args.mjs';
25
24
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
26
25
  import { resolvePolicy } from '../src/policy-resolver.mjs';
@@ -284,9 +283,41 @@ function cmdPrecommit(args) {
284
283
  }
285
284
  }
286
285
  for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
286
+ // The empty-commit hint is derived from the staged paths captured before
287
+ // repair, not from a second git read after `git restore --staged`. That
288
+ // post-repair read followed an index write and could transiently report
289
+ // the wrong state under heavy CI load, flaking the repair tests.
290
+ // unstagePaths is atomic (it throws on failure), so when every staged
291
+ // path is a repair target the index matches HEAD afterward. The capture
292
+ // is best-effort and runs before the repair: if this read fails the
293
+ // unstage still runs and the hint is omitted (empty stays false) rather
294
+ // than skipping the repair or blocking the commit.
295
+ let stagedBefore;
296
+ try {
297
+ stagedBefore = stagedPaths(repo);
298
+ } catch {
299
+ stagedBefore = null;
300
+ }
287
301
  unstagePaths(repo, [...unstageTargets]);
288
- let empty = false;
289
- try { empty = !hasStagedChanges(repo); } catch { /* could not re-read */ }
302
+ // Under heavy CI load a `git restore --staged` or the rename-source
303
+ // detection above can transiently leave a target staged, which flakes
304
+ // the repair tests and would let an artifact ride through. Re-detect
305
+ // rename sources and re-unstage any still-staged target until every
306
+ // target is gone or the budget is reached.
307
+ for (let attempt = 0; attempt < 4; attempt += 1) {
308
+ let stillStaged;
309
+ try {
310
+ for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
311
+ stillStaged = new Set(stagedPaths(repo));
312
+ } catch {
313
+ break;
314
+ }
315
+ const pending = [...unstageTargets].filter((path) => stillStaged.has(path));
316
+ if (!pending.length) break;
317
+ unstagePaths(repo, pending);
318
+ }
319
+ const empty = stagedBefore !== null
320
+ && stagedBefore.every((path) => unstageTargets.has(path));
290
321
  process.stderr.write(
291
322
  `aimhooman: unstaged ${paths.length} AI artifact(s) from this commit: ${paths.map(visible).join(', ')}${empty ? ' (nothing else staged — the commit will be empty)' : ''}\n`
292
323
  );
@@ -885,6 +916,19 @@ function cmdOverride(args, allow) {
885
916
  `secret rules cannot be allowed at --scope rule (that would suppress every matching secret path under every profile); use --scope secret-path <path> to allow a specific secret path`,
886
917
  );
887
918
  }
919
+ // A path that matches a secret rule cannot be silenced by a path (or rule)
920
+ // allow: only --scope secret-path suppresses secret findings, deliberately,
921
+ // so a local override cannot hide a possible leaked key. Without this check
922
+ // a bare `allow .env.minimal` would report success but leave the block in
923
+ // place, which reads as a broken allow.
924
+ if (allow && scope !== 'secret-path'
925
+ && engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
926
+ throw new ArgumentError(
927
+ `"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
928
+ + '(a local override must not hide a possible leaked key); '
929
+ + 'use --scope secret-path to explicitly allow this specific path',
930
+ );
931
+ }
888
932
  if (scope === 'secret-path' && !allow) {
889
933
  throw new ArgumentError('--scope secret-path is only valid with allow');
890
934
  }
@@ -1563,7 +1607,14 @@ if (argv[0] === 'hook') {
1563
1607
  console.error('aimhooman: hook requires exactly one supported event');
1564
1608
  process.exit(20);
1565
1609
  }
1566
- runHook(hookArgs)
1610
+ // hook.mjs (the PreToolUse shell parser) is by far the largest module and is
1611
+ // only needed for the `hook` subcommand. Loading it lazily cuts the heaviest
1612
+ // parse cost from every other command's startup, so the lifecycle-lock
1613
+ // candidate is published earlier. This widens the margin under the queue-wait
1614
+ // budget rather than guaranteeing it: openRepo's git spawns still set the
1615
+ // floor on slow runners.
1616
+ import('../src/hook.mjs')
1617
+ .then(({ runHook }) => runHook(hookArgs))
1567
1618
  .then((code) => {
1568
1619
  // Drain the permission decision before exiting: on a piped stdout,
1569
1620
  // process.exit() can cut off the emit() write and drop a deny. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {
@@ -39,7 +39,7 @@ export function atomicWrite(file, data, options = {}) {
39
39
  operations.sync(descriptor);
40
40
  operations.close(descriptor);
41
41
  descriptor = undefined;
42
- operations.rename(temporary, file);
42
+ renameWithRetry(operations, temporary, file, options.renameRetries);
43
43
  try {
44
44
  syncDirectory(directory, operations);
45
45
  } catch (error) {
@@ -57,6 +57,30 @@ export function atomicWrite(file, data, options = {}) {
57
57
  }
58
58
  }
59
59
 
60
+ // Windows fails a rename with EPERM/EACCES/EBUSY while another handle is open on
61
+ // either path — an antivirus or indexer scanning the file aimhooman just wrote is
62
+ // enough, and it clears within milliseconds. Observed on CI as a lock contender
63
+ // dying at its ticket publication, which then read as a lifecycle-queue timeout.
64
+ // The rename is the atomic commit point, so a retry either lands the complete
65
+ // file or leaves the original untouched; it can never publish a partial write.
66
+ // A non-transient error (and every code on other platforms) still throws at once.
67
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
68
+
69
+ function renameWithRetry(operations, temporary, file, retries = 20) {
70
+ for (let attempt = 0; ; attempt += 1) {
71
+ try {
72
+ operations.rename(temporary, file);
73
+ return;
74
+ } catch (error) {
75
+ const retryable = process.platform === 'win32'
76
+ && TRANSIENT_RENAME_CODES.has(error?.code)
77
+ && attempt + 1 < retries;
78
+ if (!retryable) throw error;
79
+ waitForLock(5);
80
+ }
81
+ }
82
+ }
83
+
60
84
  function existingMode(file) {
61
85
  try {
62
86
  return statSync(file).mode & 0o777;
package/src/githooks.mjs CHANGED
@@ -581,8 +581,8 @@ AIMHOOMAN_NODE=${shq(nodePath)}
581
581
  AIMHOOMAN_PATH=${shq(shellPathValue)}
582
582
  ${captureTree}${captureTransaction}run_aimhooman() {
583
583
  if [ ! -f "$AIMHOOMAN_CLI" ] || [ ! -f "$AIMHOOMAN_NODE" ]; then
584
- echo "aimhooman: guard unavailable; install it or run 'aimhooman init' again" >&2
585
- return 127
584
+ echo "aimhooman: guard unavailable (aimhooman CLI or Node is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
585
+ return 0
586
586
  fi
587
587
  (
588
588
  unset NODE_OPTIONS NODE_PATH NODE_REPL_EXTERNAL_MODULE NODE_EXTRA_CA_CERTS
package/src/gitx.mjs CHANGED
@@ -612,18 +612,6 @@ export function trackedEntries(repo) {
612
612
  return enrichEntries(repo, entries);
613
613
  }
614
614
 
615
- // hasStagedChanges checks the complete index delta, including deletions that
616
- // are intentionally excluded from content scanning.
617
- export function hasStagedChanges(repo) {
618
- try {
619
- gitBuf(['diff', '--cached', '--quiet', '--'], repo.root);
620
- return false;
621
- } catch (error) {
622
- if (error?.status === 1) return true;
623
- throw error;
624
- }
625
- }
626
-
627
615
  // withIndexFromTree exposes an immutable tree through Git's staged-index APIs.
628
616
  // It is used by commit-msg after the dispatcher snapshots the would-be commit
629
617
  // tree, so a chained hook cannot switch the policy by mutating the live index.
package/src/hook.mjs CHANGED
@@ -101,7 +101,12 @@ function hookPreToolUse(input) {
101
101
  const executorCommand = command(input, executor);
102
102
  if (executorCommand === null) return unknownExecutorShape(input, executor.name);
103
103
  const rawParsed = parseGit(executorCommand);
104
- if (nonPosixExecutor(executor.name)) rawParsed.uncertainShell = true;
104
+ if (nonPosixExecutor(executor.name)) {
105
+ rawParsed.uncertainShell = true;
106
+ // The benign-pipeline classifier is POSIX-only; never exempt a non-POSIX
107
+ // shell line (pwsh/fish/cmd/nu) from opaque-commit-hiding treatment.
108
+ rawParsed.opaqueCommitHiding = true;
109
+ }
105
110
  if (executorTargetSyntaxUncertain(executor.name, executorCommand, rawParsed)
106
111
  && (rawParsed.addPaths.length > 0 || rawParsed.commands.some(isProtectedMutation))) {
107
112
  return emitDecision(
@@ -364,7 +369,7 @@ function hookPreToolUse(input) {
364
369
  const prefixBypass = parsed.commands.some((candidate) => (
365
370
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
366
371
  && candidate.prefixRisk
367
- )) || (parsed.uncertainShell && parsed.commands.length === 0 && parsed.prefixRisk);
372
+ )) || (parsed.opaqueCommitHiding && parsed.commands.length === 0 && parsed.prefixRisk);
368
373
  const aliasBypass = parsed.commands.some((candidate) => (
369
374
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
370
375
  && candidate.inlineAliasRisk
@@ -379,7 +384,13 @@ function hookPreToolUse(input) {
379
384
  const unmodelledPrefixReason =
380
385
  'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
381
386
  const blocks = [];
382
- const potentialCommit = commit || parsed.uncertainShell;
387
+ // potentialCommit treats a command as leading to a commit. uncertainShell
388
+ // was too broad: it flagged any pipe, so a benign read-only pipeline
389
+ // (gh ... | tail) was scanned and denied as if it staged a secret.
390
+ // opaqueCommitHiding keeps every commit-hiding shape (subshells,
391
+ // substitution, script-feeds, code-executing/unlisted pipe segments) while
392
+ // excluding pipelines of known read-only commands.
393
+ const potentialCommit = commit || parsed.opaqueCommitHiding;
383
394
  if (potentialCommit && repo) {
384
395
  try {
385
396
  const entries = stagedEntries(repo);
@@ -1055,11 +1066,17 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
1055
1066
  : commands.some((candidate) => candidate.verb === 'commit' && candidate.futureIndex)
1056
1067
  ? 'future-index'
1057
1068
  : 'direct';
1069
+ // opaqueCommitHiding narrows uncertainShell to uncertainty that can hide or
1070
+ // feed a commit (subshells, substitution, script-feeds, code-executing or
1071
+ // unlisted pipe segments). A bare pipeline of known read-only commands
1072
+ // (gh ... | tail) cannot, so it is not treated as a potential commit.
1073
+ const opaqueCommitHiding = uncertainShell && !benignReadOnlyPipeline(cmd);
1058
1074
  return {
1059
1075
  commit,
1060
1076
  noVerify,
1061
1077
  bypassHooks,
1062
1078
  uncertainShell,
1079
+ opaqueCommitHiding,
1063
1080
  classification,
1064
1081
  environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
1065
1082
  prefixRisk,
@@ -1737,6 +1754,103 @@ function shellUnits(s) {
1737
1754
  return out;
1738
1755
  }
1739
1756
 
1757
+ // SAFE_PIPE_EXECUTABLES are commands permitted in a "benign read-only pipeline":
1758
+ // they take only data arguments, never run a sub-command, never execute code,
1759
+ // never write files, and cannot bypass Git hooks. Anything not listed is treated
1760
+ // as potentially able to hide or feed a commit (fail-closed). Deliberately
1761
+ // excluded: shells and interpreters, awk/sed/ed/vim, sqlite3/psql/dc/bc/octave
1762
+ // and other stdin-as-program readers, git, tee (writes files), env/xargs/find
1763
+ // (run sub-commands), and curl/wget (fetch arbitrary content to a pipe).
1764
+ const SAFE_PIPE_EXECUTABLES = new Set([
1765
+ 'cat', 'tac', 'head', 'tail', 'tr', 'cut', 'paste', 'fold', 'fmt', 'expand',
1766
+ 'unexpand', 'rev', 'nl', 'pr', 'column', 'sort', 'uniq', 'comm', 'tsort',
1767
+ 'shuf', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'xxd', 'od',
1768
+ 'hexdump', 'base64', 'base32', 'md5sum', 'sha1sum', 'sha224sum', 'sha256sum',
1769
+ 'sha384sum', 'sha512sum', 'cksum', 'b2sum', 'sum', 'file', 'ls', 'exa', 'eza',
1770
+ 'lsd', 'tree', 'du', 'df', 'stat', 'date', 'whoami', 'id', 'uname',
1771
+ 'hostname', 'uptime', 'printenv', 'pwd', 'basename', 'dirname', 'realpath',
1772
+ 'seq', 'yes', 'echo', 'printf', 'test', 'true', 'false', 'jq', 'yq', 'bat',
1773
+ 'gh', '[',
1774
+ ]);
1775
+
1776
+ // containsOpaquePipeLexeme is a quote-aware scan for shell constructs that can
1777
+ // hide or feed a commit or subcommand: command substitution, subshells, brace
1778
+ // expansion/groups, script-feed redirects, and command separators. Output
1779
+ // redirects (>, >>) and fd-dups such as 2>&1 are intentionally NOT opaque: they
1780
+ // cannot feed code into a pipe sink. A bare & (background) is opaque unless it
1781
+ // is part of an fd-dup (>& / <&) or &&.
1782
+ function containsOpaquePipeLexeme(command) {
1783
+ let quote = '';
1784
+ let escaped = false;
1785
+ for (let i = 0; i < command.length; i += 1) {
1786
+ const char = command[i];
1787
+ const prev = command[i - 1];
1788
+ const next = command[i + 1];
1789
+ if (escaped) { escaped = false; continue; }
1790
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
1791
+ if (quote) {
1792
+ if (quote === '"' && (char === '`' || (char === '$' && next === '('))) return true;
1793
+ if (char === quote) quote = '';
1794
+ continue;
1795
+ }
1796
+ if (char === "'" || char === '"') { quote = char; continue; }
1797
+ if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}') return true;
1798
+ if (char === '$' && (next === '(' || next === '{')) return true;
1799
+ if (char === '<' || char === ';') return true;
1800
+ if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
1801
+ }
1802
+ return false;
1803
+ }
1804
+
1805
+ // splitPipeSegments splits a command on an unquoted pipe (|) that is not part
1806
+ // of ||. Unlike shellUnits it does not break on &, so 2>&1 stays within a
1807
+ // segment.
1808
+ function splitPipeSegments(command) {
1809
+ const segments = [];
1810
+ let segment = '';
1811
+ let quote = '';
1812
+ let escaped = false;
1813
+ for (let i = 0; i < command.length; i += 1) {
1814
+ const char = command[i];
1815
+ if (escaped) { segment += char; escaped = false; continue; }
1816
+ if (char === '\\' && quote !== "'") { segment += char; escaped = true; continue; }
1817
+ if (quote) { segment += char; if (char === quote) quote = ''; continue; }
1818
+ if (char === "'" || char === '"') { segment += char; quote = char; continue; }
1819
+ if (char === '|' && command[i + 1] !== '|') { segments.push(segment); segment = ''; continue; }
1820
+ segment += char;
1821
+ }
1822
+ segments.push(segment);
1823
+ return segments;
1824
+ }
1825
+
1826
+ // pipelineSegmentExecutable returns the shellExecutable basename of the first
1827
+ // non-environment-assignment token in a pipe segment, or null when the segment
1828
+ // has no command. A leading redirect token (e.g. 2>&1 before the command) is
1829
+ // treated as unlisted -> null -> not benign (fail-closed), which is safe.
1830
+ function pipelineSegmentExecutable(segment) {
1831
+ for (const word of shellWords(segment.trim())) {
1832
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
1833
+ return shellExecutable(word);
1834
+ }
1835
+ return null;
1836
+ }
1837
+
1838
+ // benignReadOnlyPipeline reports whether a command is a bare pipeline whose
1839
+ // every segment is a known non-code-executing, non-mutating command with no
1840
+ // opaque shell syntax. Such a pipe cannot hide or feed a commit, so it is not a
1841
+ // "potential commit" for the unmodelled-prefix guard. Fail-closed: any opaque
1842
+ // lexeme, unlisted executable, missing executable, or non-pipe returns false.
1843
+ function benignReadOnlyPipeline(command) {
1844
+ if (!command.includes('|')) return false;
1845
+ if (containsOpaquePipeLexeme(command)) return false;
1846
+ const segments = splitPipeSegments(command);
1847
+ if (segments.length < 2) return false;
1848
+ return segments.every((segment) => {
1849
+ const executable = pipelineSegmentExecutable(segment);
1850
+ return executable !== null && SAFE_PIPE_EXECUTABLES.has(executable);
1851
+ });
1852
+ }
1853
+
1740
1854
  function pipedShellCommitInvocations(source, initialCwd) {
1741
1855
  const units = shellUnits(source);
1742
1856
  const commits = [];
@@ -363,27 +363,38 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
363
363
 
364
364
  function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
365
365
  for (const entry of entries) {
366
- const paths = [];
367
- if (entry.status === 'D' || entry.type === 'deleted') paths.push(entry.path);
368
- if (entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path) {
369
- paths.push(entry.sourcePath);
370
- }
371
- for (const path of paths) {
372
- const context = {
373
- objectId: null,
374
- mode: null,
375
- transition: options.transition ?? entry.commit,
376
- ...(options.allowMissingPolicy && path === '.aimhooman.json'
377
- ? { transientAllowRules: new Set(['generic.project-policy']) }
378
- : {}),
379
- };
380
- const findings = engine.checkPaths([path], context).filter((finding) => (
381
- finding.matchedRuleIds?.some((ruleId) => (
382
- ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
383
- ))
384
- ));
385
- accumulator.add(findings.map((finding) => decorate(finding, entry, policy)));
386
- }
366
+ // A deleted path is gone, so only structural policy rules (agent
367
+ // instructions, project policy) can still matter: deleting a secret or a
368
+ // session file is hygiene, not a violation. A renamed-away path is
369
+ // different — its content survives under a new name, so a path-only
370
+ // secret (e.g. .env) must still fire or a `git mv` to a neutral name
371
+ // would slip past the destination scan, which only catches content-shaped
372
+ // secrets. That finding is reported on the destination path (where the
373
+ // bytes now live) so clean-profile repair unstages the blob that carries
374
+ // the secret rather than the old name.
375
+ const deleted = entry.status === 'D' || entry.type === 'deleted';
376
+ const renamed = entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path;
377
+ if (!deleted && !renamed) continue;
378
+ const sourcePath = deleted ? entry.path : entry.sourcePath;
379
+ const context = {
380
+ objectId: null,
381
+ mode: null,
382
+ transition: options.transition ?? entry.commit,
383
+ ...(options.allowMissingPolicy && sourcePath === '.aimhooman.json'
384
+ ? { transientAllowRules: new Set(['generic.project-policy']) }
385
+ : {}),
386
+ };
387
+ const findings = engine.checkPaths([sourcePath], context).filter((finding) => {
388
+ if (finding.matchedRuleIds?.some((ruleId) => (
389
+ ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
390
+ ))) return true;
391
+ return renamed && finding.category === 'secret';
392
+ });
393
+ accumulator.add(findings.map((finding) => decorate(
394
+ renamed && finding.category === 'secret' ? { ...finding, path: entry.path } : finding,
395
+ entry,
396
+ policy,
397
+ )));
387
398
  }
388
399
  }
389
400