@rmyndharis/aimhooman 0.1.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.7",
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": {
package/rules/paths.json CHANGED
@@ -253,7 +253,7 @@
253
253
  },
254
254
  {
255
255
  "id": "secret.private-key",
256
- "version": 2,
256
+ "version": 3,
257
257
  "provider": "generic",
258
258
  "category": "secret",
259
259
  "confidence": "high",
@@ -274,10 +274,11 @@
274
274
  "strict": "block",
275
275
  "compliance": "block"
276
276
  },
277
- "reason": "Private key material must never be committed.",
277
+ "reason": "A path matching a private-key filename (id_rsa, id_ed25519, *.p12, *.pfx, ...) is blocked by name — content is not inspected. If this is a real key, it must never be committed; if the name is coincidental, rename the file.",
278
278
  "remediation": [
279
279
  "git restore --staged <path>",
280
- "rotate the key if it was ever exposed"
280
+ "if this is a real key, rotate it if it was ever exposed",
281
+ "if the name is coincidental (a fixture, a doc), rename the file so it no longer matches a private-key pattern"
281
282
  ]
282
283
  },
283
284
  {
@@ -391,10 +392,22 @@
391
392
  "category": "ephemeral-state",
392
393
  "confidence": "high",
393
394
  "kind": "path",
394
- "match": { "path_case": "insensitive", "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
395
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
395
+ "match": {
396
+ "path_case": "insensitive",
397
+ "paths": [
398
+ ".playwright-mcp/**",
399
+ "**/.playwright-mcp/**"
400
+ ]
401
+ },
402
+ "actions": {
403
+ "clean": "block",
404
+ "strict": "block",
405
+ "compliance": "block"
406
+ },
396
407
  "reason": "Playwright MCP session artifacts are local, not repository content.",
397
- "remediation": ["git restore --staged <path>"]
408
+ "remediation": [
409
+ "git restore --staged <path>"
410
+ ]
398
411
  },
399
412
  {
400
413
  "id": "remember.state",
@@ -403,10 +416,22 @@
403
416
  "category": "ephemeral-state",
404
417
  "confidence": "high",
405
418
  "kind": "path",
406
- "match": { "path_case": "insensitive", "paths": [".remember/**", "**/.remember/**"] },
407
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
419
+ "match": {
420
+ "path_case": "insensitive",
421
+ "paths": [
422
+ ".remember/**",
423
+ "**/.remember/**"
424
+ ]
425
+ },
426
+ "actions": {
427
+ "clean": "block",
428
+ "strict": "block",
429
+ "compliance": "block"
430
+ },
408
431
  "reason": "Remember second-brain data is local, not repository content.",
409
- "remediation": ["git restore --staged <path>"]
432
+ "remediation": [
433
+ "git restore --staged <path>"
434
+ ]
410
435
  },
411
436
  {
412
437
  "id": "superpowers.state",
@@ -415,10 +440,22 @@
415
440
  "category": "ephemeral-state",
416
441
  "confidence": "high",
417
442
  "kind": "path",
418
- "match": { "path_case": "insensitive", "paths": [".superpowers/**", "**/.superpowers/**"] },
419
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
443
+ "match": {
444
+ "path_case": "insensitive",
445
+ "paths": [
446
+ ".superpowers/**",
447
+ "**/.superpowers/**"
448
+ ]
449
+ },
450
+ "actions": {
451
+ "clean": "block",
452
+ "strict": "block",
453
+ "compliance": "block"
454
+ },
420
455
  "reason": "Superpowers plugin state is local, not repository content.",
421
- "remediation": ["git restore --staged <path>"]
456
+ "remediation": [
457
+ "git restore --staged <path>"
458
+ ]
422
459
  },
423
460
  {
424
461
  "id": "agent.state",
@@ -427,9 +464,21 @@
427
464
  "category": "ephemeral-state",
428
465
  "confidence": "high",
429
466
  "kind": "path",
430
- "match": { "path_case": "insensitive", "paths": [".agent/**", "**/.agent/**"] },
431
- "actions": { "clean": "block", "strict": "block", "compliance": "block" },
467
+ "match": {
468
+ "path_case": "insensitive",
469
+ "paths": [
470
+ ".agent/**",
471
+ "**/.agent/**"
472
+ ]
473
+ },
474
+ "actions": {
475
+ "clean": "block",
476
+ "strict": "block",
477
+ "compliance": "block"
478
+ },
432
479
  "reason": "Generic agent state is local, not repository content.",
433
- "remediation": ["git restore --staged <path>"]
480
+ "remediation": [
481
+ "git restore --staged <path>"
482
+ ]
434
483
  }
435
- ]
484
+ ]
@@ -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,19 +41,20 @@
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
  {
47
49
  "id": "secret.aws-key-content",
48
- "version": 2,
50
+ "version": 3,
49
51
  "provider": "aws",
50
52
  "category": "secret",
51
53
  "confidence": "high",
52
54
  "kind": "code",
53
55
  "match": {
54
56
  "content": [
55
- "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])",
57
+ "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?<!EXAMPLEKEY)(?![A-Za-z0-9/+=])",
56
58
  "\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
57
59
  ]
58
60
  },
@@ -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
@@ -43,14 +43,25 @@ function hooksDir(repo) {
43
43
  // Inside the worktree is not the same as ours. A hooks directory Git tracks
44
44
  // (husky, the vanilla .githooks pattern) is repository content: a dispatcher
45
45
  // written there stages this machine's absolute CLI, Node, and PATH for
46
- // everyone who clones.
47
- const tracked = inside && trackedPath(repo, dir);
46
+ // everyone who clones. An UNTRACKED hooks directory inside the worktree
47
+ // (a freshly created `.husky` before the first commit, a team-local
48
+ // `.team-hooks`) is repository content in waiting: the next `git add`
49
+ // stages it, carrying the same machine-local paths into history. Treat both
50
+ // as repository content so neither receives a dispatcher — UNLESS the team
51
+ // has explicitly excluded the path (.gitignore or .git/info/exclude), which
52
+ // is the opt-in that says "this hooks dir stays local". A hooks path inside
53
+ // `.git/` (the default, or a custom one) is local Git plumbing and is safe
54
+ // to install into regardless.
55
+ const worktreeContent = inside && !insideGitDir(repo, dir) && !gitIgnored(repo, dir);
56
+ const trackedByGit = inside && trackedPath(repo, dir);
57
+ const tracked = trackedByGit || worktreeContent;
48
58
  const repositoryOwned = inside && !tracked;
49
59
  const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
50
60
  if (shared) {
51
61
  const where = scope ? `${scope} scope` : 'a non-local scope';
52
62
  let reason = where;
53
- if (tracked) reason = `${where}, tracked by this repository`;
63
+ if (trackedByGit) reason = `${where}, tracked by this repository`;
64
+ else if (worktreeContent) reason = `${where}, inside the worktree so Git will track it on the next add (add it to .gitignore or .git/info/exclude to manage it locally)`;
54
65
  else if (localScope && !inside) reason = `${where}, outside this repository`;
55
66
  return {
56
67
  dir,
@@ -106,10 +117,34 @@ function trackedPath(repo, path) {
106
117
  }
107
118
  }
108
119
 
120
+ // gitIgnored reports whether Git would ignore path (via .gitignore,
121
+ // .git/info/exclude, or core.excludesfile). This is the opt-in that lets a team
122
+ // keep a worktree hooks dir local: an excluded path will not be staged by the
123
+ // next `git add`, so a dispatcher written there cannot leak machine-local
124
+ // absolute paths into history. Used to decide whether a worktree hooksPath is
125
+ // repository content in waiting (refuse) or intentionally local (install). A
126
+ // Git that cannot answer counts as NOT ignored, so we fail closed and refuse
127
+ // (the safer side for a leak that writes machine paths into shared history).
128
+ function gitIgnored(repo, path) {
129
+ try {
130
+ execFileSync(
131
+ 'git',
132
+ ['check-ignore', '--quiet', '--', String(path)],
133
+ { cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS },
134
+ );
135
+ // git check-ignore exits 0 when the path IS ignored, 1 when it is not.
136
+ return true;
137
+ } catch (error) {
138
+ // Non-zero exit means "not ignored" (exit 1) or "git failed". Either way,
139
+ // treat as not-ignored so the worktree-content guard stays on.
140
+ return false;
141
+ }
142
+ }
143
+
109
144
  function canonicalPath(path) {
110
145
  let current = resolve(path);
111
146
  const tail = [];
112
- for (;;) {
147
+ for (; ;) {
113
148
  try {
114
149
  return resolve(realpathSync(current), ...tail);
115
150
  } catch (error) {
@@ -616,6 +651,12 @@ function hookScript(name, cmd, cliPath, chainedPath) {
616
651
  const shellPathValue = hookPathForShell(pathValue);
617
652
  const nodeMetadata = Buffer.from(nodePath, 'utf8').toString('base64url');
618
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');
619
660
  const captureTree = name === 'commit-msg'
620
661
  ? `AIMHOOMAN_COMMIT_TREE=$(PATH="$AIMHOOMAN_PATH" git write-tree) || {
621
662
  echo "aimhooman: cannot snapshot the would-be commit tree" >&2
@@ -631,9 +672,19 @@ function hookScript(name, cmd, cliPath, chainedPath) {
631
672
  ) || exit $?
632
673
  `
633
674
  : '';
675
+ // The chained predecessor is sourced in a subshell, not exec'd. Git runs the
676
+ // dispatcher with $0 set to the original hook path (the dispatcher replaced
677
+ // it in place), and a subshell inherits $0, so sourcing lets the predecessor
678
+ // see the original $0 and resolve $(dirname "$0") to its real directory —
679
+ // the pattern husky and vanilla .githooks hooks use to find sibling scripts.
680
+ // Exec'ing "$CHAINED" would set the predecessor's $0 to the backup path and
681
+ // break that resolution. Sourcing runs the predecessor in this dispatcher's
682
+ // shell (/bin/sh -p), so its shebang is honoured only for sh-compatible
683
+ // scripts; bash-only predecessors are out of scope. exit inside the
684
+ // predecessor exits the subshell and propagates via || exit $?.
634
685
  const chainedInvocation = name === 'reference-transaction'
635
- ? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | "$CHAINED" "$@" || exit $?`
636
- : ` "$CHAINED" "$@" || exit $?`;
686
+ ? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | ( . "$CHAINED" ) || exit $?`
687
+ : ` ( . "$CHAINED" ) || exit $?`;
637
688
  const aimCommand = name === 'commit-msg'
638
689
  ? 'commitmsg "$1" --tree "$AIMHOOMAN_COMMIT_TREE"'
639
690
  : name === 'reference-transaction'
@@ -645,10 +696,31 @@ function hookScript(name, cmd, cliPath, chainedPath) {
645
696
  // committed/aborted fire only after refs are locked in, and refcheck can do
646
697
  // nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
647
698
  // 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.
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.
650
708
  const phaseShortCircuit = name === 'reference-transaction'
651
- ? '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
+ `
652
724
  : '';
653
725
  const template = `#!/bin/sh -p
654
726
  ${MARKER} (${name})
@@ -661,6 +733,7 @@ ${MARKER} (${name})
661
733
  AIMHOOMAN_CLI=${shq(resolvedCliPath)}
662
734
  AIMHOOMAN_NODE=${shq(nodePath)}
663
735
  AIMHOOMAN_PATH=${shq(shellPathValue)}
736
+ AIMHOOMAN_COMPILE_CACHE=${shq(compileCacheDir)}
664
737
  ${captureTree}${captureTransaction}run_aimhooman() {
665
738
  if [ ! -f "$AIMHOOMAN_CLI" ]; then
666
739
  echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
@@ -686,8 +759,15 @@ ${captureTree}${captureTransaction}run_aimhooman() {
686
759
  unset BASH_ENV ENV CDPATH
687
760
  PATH=$AIMHOOMAN_PATH
688
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
689
768
  export PATH
690
769
  export AIMHOOMAN_ACTIVE_HOOK
770
+ export NODE_COMPILE_CACHE
691
771
  "$AIMHOOMAN_NODE" "$AIMHOOMAN_CLI" "$@"
692
772
  )
693
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,27 @@ 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
+
242
+ // stagedTreeSha writes the index to a tree object and returns its SHA, the same
243
+ // value `git write-tree` produces in the commit-msg dispatcher. Used by the
244
+ // pre-commit/commit-msg marker dedup (W5): pre-commit records this sha when it
245
+ // scans clean, and commit-msg skips its duplicate tree scan when the sha still
246
+ // matches. The index is unchanged between the two hooks in a normal commit, so
247
+ // the sha is stable; any staging mutation invalidates it automatically.
248
+ export function stagedTreeSha(repo) {
249
+ return gitStr(['write-tree'], repo.root);
250
+ }
251
+
180
252
  // Unmerged index entries have only stages 1-3 and are omitted by the ordinary
181
253
  // cached diff. Expose their paths so callers cannot mistake a conflicted index
182
254
  // for a complete, clean staged snapshot.
package/src/hook.mjs CHANGED
@@ -447,10 +447,16 @@ function hookPreToolUse(input) {
447
447
  // stdin) can hide a commit or run arbitrary commands, and there is no argv
448
448
  // to read a --no-verify out of. The earlier message reused the commit text
449
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.
450
+ // was not a commit at all — this names the real shape instead. The message
451
+ // is conditional on whether a pipe is actually present: opaque syntax
452
+ // without a pipe (a subshell, a brace group, command substitution) should
453
+ // not tell the developer to "drop the `| bash` segment" on a command that
454
+ // has no `|`.
451
455
  const opaquePipelineReason = commit
452
456
  ? 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.';
457
+ : hasUnquotedPipe(executorCommand)
458
+ ? '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.'
459
+ : 'aimhooman cannot prove this command is read-only: it uses shell syntax (a subshell, command substitution, script-feed redirect, background job, or brace group) that can hide a commit or run arbitrary commands. Run the pieces separately.';
454
460
  const blocks = [];
455
461
  // potentialCommit treats a command as leading to a commit. uncertainShell
456
462
  // was too broad: it flagged any pipe, so a benign read-only pipeline
@@ -1961,7 +1967,18 @@ function hasUncertainShellSyntax(command) {
1961
1967
  quote = char;
1962
1968
  continue;
1963
1969
  }
1964
- if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}' || char === '<' || char === '>') return true;
1970
+ // `{`/`}` are ambiguous: brace expansion (`{a,b}`, `{owner}`, `x.{js,ts}`)
1971
+ // is argument-level and cannot hide a commit or feed code into a pipe
1972
+ // sink, while a brace group (`{ list; }`) is control flow and can. Brace
1973
+ // expansion touches the surrounding word (no separator before the `{`, no
1974
+ // space/`;` after it); a brace-group `{` stands as its own token. PowerShell
1975
+ // `{ }` script blocks are handled separately via the non-POSIX executor
1976
+ // path, so this POSIX distinction never loosens that.
1977
+ if (char === '{' || char === '}') {
1978
+ if (isBraceGroupToken(command, i)) return true;
1979
+ continue;
1980
+ }
1981
+ if (char === '`' || char === '(' || char === ')' || char === '<' || char === '>') return true;
1965
1982
  if (char === '|' && command[i + 1] !== '|') return true;
1966
1983
  if (char === '&' && command[i + 1] !== '&' && command[i - 1] !== '&') return true;
1967
1984
  }
@@ -1971,6 +1988,57 @@ function hasUncertainShellSyntax(command) {
1971
1988
  });
1972
1989
  }
1973
1990
 
1991
+ // BRACE_GROUP_LEAD chars may appear immediately before a brace-group token.
1992
+ // `:` is included so `cmd: { ... }` is handled, though it is not POSIX shell.
1993
+ const BRACE_GROUP_LEAD = new Set(['', ' ', '\t', '\n', ';', '&', '|', '(', ')']);
1994
+ // BRACE_GROUP_OPEN_TRAIL chars may follow a `{` that opens a brace group: the
1995
+ // reserved word `{` must be followed by whitespace, `;`, or end of string.
1996
+ const BRACE_GROUP_OPEN_TRAIL = new Set([' ', '\t', '\n', ';', '']);
1997
+ // BRACE_GROUP_CLOSE_LEAD chars precede a `}` that closes a brace group: the
1998
+ // closer must follow `;` or whitespace (the end of the group body).
1999
+ const BRACE_GROUP_CLOSE_LEAD = new Set([' ', '\t', '\n', ';', '']);
2000
+
2001
+ // isBraceGroupToken reports whether `{`/`}` at position i in command is a
2002
+ // POSIX brace-group token (reserved-word `{` or its closer `}`) rather than a
2003
+ // brace-expansion character. Brace expansion `{a,b}` / `{owner}` / `x.{js,ts}`
2004
+ // is argument-level and cannot feed code into a pipe sink or run a subcommand,
2005
+ // so it must not make a command "opaque". A brace group `{ list; }` is
2006
+ // control flow and can, so it stays opaque. PowerShell script blocks are
2007
+ // handled by the non-POSIX executor path, not here.
2008
+ function isBraceGroupToken(command, i) {
2009
+ const char = command[i];
2010
+ const prev = command[i - 1] ?? '';
2011
+ const next = command[i + 1] ?? '';
2012
+ if (char === '{') {
2013
+ if (!BRACE_GROUP_LEAD.has(prev)) return false;
2014
+ if (!BRACE_GROUP_OPEN_TRAIL.has(next)) return false;
2015
+ return true;
2016
+ }
2017
+ // `}` closer: must follow `;` or whitespace.
2018
+ return BRACE_GROUP_CLOSE_LEAD.has(prev);
2019
+ }
2020
+
2021
+ // hasUnquotedPipe reports whether command contains a pipe `|` that is not
2022
+ // `||`, outside of any quote. Used to decide whether the opaque-pipeline deny
2023
+ // message should mention a pipe at all — the original message always named
2024
+ // `| bash` even for commands with no pipe.
2025
+ function hasUnquotedPipe(command) {
2026
+ let quote = '';
2027
+ let escaped = false;
2028
+ for (let i = 0; i < command.length; i += 1) {
2029
+ const char = command[i];
2030
+ if (escaped) { escaped = false; continue; }
2031
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
2032
+ if (quote) {
2033
+ if (char === quote) quote = '';
2034
+ continue;
2035
+ }
2036
+ if (char === "'" || char === '"') { quote = char; continue; }
2037
+ if (char === '|' && command[i + 1] !== '|') return true;
2038
+ }
2039
+ return false;
2040
+ }
2041
+
1974
2042
  function shellUnits(s) {
1975
2043
  const out = [];
1976
2044
  let segment = '';
@@ -2146,7 +2214,14 @@ function containsOpaquePipeLexeme(command) {
2146
2214
  continue;
2147
2215
  }
2148
2216
  if (char === "'" || char === '"') { quote = char; continue; }
2149
- if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}') return true;
2217
+ // Brace expansion (`{a,b}`, `{owner}`) is not opaque: it only expands to
2218
+ // arguments for the same command and cannot feed code into a pipe sink.
2219
+ // Brace groups (`{ list; }`) are opaque. See isBraceGroupToken.
2220
+ if (char === '{' || char === '}') {
2221
+ if (isBraceGroupToken(command, i)) return true;
2222
+ continue;
2223
+ }
2224
+ if (char === '`' || char === '(' || char === ')') return true;
2150
2225
  if (char === '$' && (next === '(' || next === '{')) return true;
2151
2226
  if (char === '<' || char === ';') return true;
2152
2227
  if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;