@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +156 -0
- package/README.md +2 -1
- package/bin/aimhooman.mjs +473 -241
- package/package.json +1 -1
- package/rules/paths.json +65 -16
- package/rules/secrets.json +11 -7
- package/src/exclude.mjs +14 -0
- package/src/githooks.mjs +89 -9
- package/src/gitx.mjs +78 -6
- package/src/hook.mjs +79 -4
- package/src/report.mjs +71 -3
- package/src/scan-session.mjs +77 -1
- package/src/scan.mjs +40 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.
|
|
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":
|
|
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": "
|
|
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
|
-
"
|
|
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": {
|
|
395
|
-
|
|
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": [
|
|
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": {
|
|
407
|
-
|
|
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": [
|
|
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": {
|
|
419
|
-
|
|
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": [
|
|
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": {
|
|
431
|
-
|
|
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": [
|
|
480
|
+
"remediation": [
|
|
481
|
+
"git restore --staged <path>"
|
|
482
|
+
]
|
|
434
483
|
}
|
|
435
|
-
]
|
|
484
|
+
]
|
package/rules/secrets.json
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
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 (
|
|
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"
|
|
636
|
-
: ` "$CHAINED"
|
|
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.
|
|
649
|
-
//
|
|
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
|
-
?
|
|
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(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|