@rmyndharis/aimhooman 0.1.8 → 0.3.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/.agents/rules/aimhooman.md +9 -6
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.clinerules/aimhooman.md +9 -6
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor/rules/aimhooman.mdc +9 -6
- package/.github/copilot-instructions.md +9 -6
- package/.kiro/steering/aimhooman.md +9 -6
- package/.windsurf/rules/aimhooman.md +9 -6
- package/AGENTS.md +9 -6
- package/CHANGELOG.md +147 -0
- package/CONTRIBUTING.md +2 -3
- package/GEMINI.md +9 -6
- package/README.md +56 -374
- package/SECURITY.md +6 -4
- package/bin/aimhooman.mjs +190 -55
- package/docs/ai-artifacts.gitignore +63 -0
- package/docs/catalog.md +41 -0
- package/docs/design/frictionless-enforcement.md +27 -17
- package/hooks/hooks.json +1 -1
- package/package.json +5 -2
- package/rules/paths.json +0 -114
- package/schemas/overrides.schema.json +4 -1
- package/skills/aimhooman/SKILL.md +11 -8
- package/src/exclude.mjs +16 -2
- package/src/githooks.mjs +38 -3
- package/src/gitx.mjs +68 -6
- package/src/hook.mjs +33 -12
- package/src/report.mjs +23 -5
- package/src/scan-session.mjs +14 -21
- package/src/scan-target.mjs +10 -10
- package/src/scan.mjs +9 -24
- package/src/state.mjs +59 -10
- package/rules/secrets.json +0 -92
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
This document describes aimhooman's enforcement model. aimhooman is designed to
|
|
4
4
|
prevent known AI-tool residue from leaking into Git history **without editing
|
|
5
|
-
`.gitignore
|
|
6
|
-
Plugin startup provides best-effort prevention without `init`;
|
|
7
|
-
enforcement requires local or global hook setup.
|
|
5
|
+
`.gitignore` by default, and with repair before rejection on the default ordinary
|
|
6
|
+
commit path.** Plugin startup provides best-effort prevention without `init`;
|
|
7
|
+
complete Git-boundary enforcement requires local or global hook setup.
|
|
8
8
|
|
|
9
9
|
## Goals
|
|
10
10
|
|
|
11
11
|
- **Zero-activation for Claude Code:** the plugin is active every session
|
|
12
12
|
(SessionStart), no `init`.
|
|
13
|
-
- **No `.gitignore` burden:** known AI artifacts are auto-excluded via
|
|
14
|
-
`.git/info/exclude` (local, never committed
|
|
13
|
+
- **No `.gitignore` burden by default:** known AI artifacts are auto-excluded via
|
|
14
|
+
`.git/info/exclude` (local, never committed). `init --gitignore` is the opt-in
|
|
15
|
+
variant that writes the same managed block to the worktree `.gitignore`, so a
|
|
16
|
+
team can commit it and share the ignore set across clones.
|
|
15
17
|
- **Repair-first for hygiene by default:** AI artifacts are excluded before
|
|
16
|
-
staging or unstaged at commit. If a block cannot be repaired
|
|
17
|
-
pinned tree,
|
|
18
|
+
staging or unstaged at commit. If a block cannot be repaired or remains in the
|
|
19
|
+
pinned tree, the operation stops. An incomplete scan warns on
|
|
20
|
+
`clean`/`compliance` and stops on `strict`; the final ref guard stops on it
|
|
21
|
+
at every profile.
|
|
18
22
|
- **Broad, community-updatable rule catalog:** covers many AI tools; extendable
|
|
19
23
|
via PR (core) and local rules (personal).
|
|
20
24
|
- **Block remains available as opt-in** (`strict` profile) for teams that want
|
|
@@ -41,8 +45,9 @@ enforcement requires local or global hook setup.
|
|
|
41
45
|
3. **PreToolUse cannot unstage.** It runs before the tool executes; at `git add X`,
|
|
42
46
|
X is not staged yet. So unstage is a git-tier (`pre-commit`) capability; the
|
|
43
47
|
plugin tier does prevention (excludes) plus advisory (warn).
|
|
44
|
-
4. **`.gitignore` stays clean** because aimhooman writes
|
|
45
|
-
(local, not committed
|
|
48
|
+
4. **`.gitignore` stays clean by default** because aimhooman writes
|
|
49
|
+
`.git/info/exclude` (local, not committed). The opt-in `init --gitignore`
|
|
50
|
+
writes the same managed block to `.gitignore` for teams that want it committed.
|
|
46
51
|
|
|
47
52
|
## Design
|
|
48
53
|
|
|
@@ -54,16 +59,18 @@ enforcement requires local or global hook setup.
|
|
|
54
59
|
| 1. Catch-all | `pre-commit` hook unstages AI artifacts from the index | git hook | proceeds after repair; stops if repair fails |
|
|
55
60
|
| 2. Agent guard | PreToolUse reports paths and rejects unprovable protected Git mutations | plugin | advisory for paths; fail-closed for boundary bypass |
|
|
56
61
|
| 3. Strict policy | `strict` profile blocks instead of repairing (exit 10) | both | block |
|
|
57
|
-
| 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation
|
|
62
|
+
| 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation; an incomplete scan warns on clean/compliance, stops on strict |
|
|
58
63
|
| 5. Final ref check | prepared `reference-transaction` scans what each commit introduced to `HEAD` or a branch changes (and the message of commits authored locally) | git hook | blocks a violation or incomplete scan |
|
|
59
64
|
|
|
60
65
|
Default profile `clean` uses layers 0, 1, 2, 4, and 5. Successful repair keeps the
|
|
61
66
|
ordinary path low-friction. The pinned-tree and final-ref scans deliberately stop if
|
|
62
|
-
a block remains on a path the commit actually changes
|
|
67
|
+
a block remains on a path the commit actually changes. The final-ref scan also stops
|
|
68
|
+
when it is incomplete, on every profile; an incomplete pinned-tree scan warns on
|
|
69
|
+
`clean`/`compliance` and stops on `strict`.
|
|
63
70
|
A file already in history is not re-tried on every later commit: the final ref check
|
|
64
71
|
judges a commit by its change set, so an inherited path no longer blocks unrelated
|
|
65
72
|
work. Use `aimhooman check --tracked` (or scan the PR range in CI) to surface legacy
|
|
66
|
-
|
|
73
|
+
artifacts without bricking the branch.
|
|
67
74
|
Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
|
|
68
75
|
hook checks dispatcher integrity there without scanning unresolved references,
|
|
69
76
|
then keeps the scan veto at `prepared`, after Git has locked them.
|
|
@@ -95,14 +102,15 @@ rather than run the command on its own, which is the friction this tier exists t
|
|
|
95
102
|
### Components
|
|
96
103
|
|
|
97
104
|
1. **Rule catalog** — `rules/paths.json`, `rules/attribution.json`,
|
|
98
|
-
`rules/markers.json
|
|
105
|
+
`rules/markers.json`. Covers Claude, Codex, Copilot, Cursor, Aider, SpecStory,
|
|
99
106
|
Continue, Playwright MCP, Remember, Superpowers, and a generic agent dir.
|
|
100
107
|
Community extends via PR (core); personal extensions via
|
|
101
108
|
`<common-git-dir>/aimhooman/rules/*.json` (local, shared by linked worktrees,
|
|
102
109
|
loaded after core, and only able to add restrictions).
|
|
103
110
|
2. **Auto-exclude** — `applyExclude` writes patterns derived from unambiguous
|
|
104
111
|
`ephemeral-state` and `local-settings` rules to `.git/info/exclude` from
|
|
105
|
-
SessionStart, init, and the agent guard.
|
|
112
|
+
SessionStart, init, and the agent guard. Review-required and policy paths
|
|
113
|
+
remain visible.
|
|
106
114
|
3. **Git boundary** — the `pre-commit` hook scans staged paths; for any AI
|
|
107
115
|
artifact it removes it from the index, then lets the commit proceed only when
|
|
108
116
|
that repair succeeds. `commit-msg` removes complete exact high-confidence attribution lines
|
|
@@ -133,8 +141,10 @@ means local rules only add restrictions and can never override a core block.
|
|
|
133
141
|
## Implementation notes
|
|
134
142
|
|
|
135
143
|
- **Category-aware failure.** On `clean`, a malformed local rule pack is skipped
|
|
136
|
-
with a warning because built-in rules remain active. Corrupt override state
|
|
137
|
-
unreadable Git inputs
|
|
144
|
+
with a warning because built-in rules remain active. Corrupt override state and
|
|
145
|
+
unreadable Git inputs stop every profile. An incomplete staged-content scan warns
|
|
146
|
+
on `clean`/`compliance` and stops `strict`; the final ref guard fails closed on it
|
|
147
|
+
at every profile.
|
|
138
148
|
Any automatic unstage failure stops; allowing it through would only defer the
|
|
139
149
|
same block to the pinned-tree or final-ref scan. `strict` also vetoes policy
|
|
140
150
|
findings and reviews.
|
|
@@ -146,7 +156,7 @@ means local rules only add restrictions and can never override a core block.
|
|
|
146
156
|
- **HEAD-safe unstage.** `git restore --staged` needs HEAD; on a repository's
|
|
147
157
|
initial commit it falls back to `git rm --cached --ignore-unmatch`.
|
|
148
158
|
- **Repair never mints an empty commit.** When the pre-commit repair unstages the last
|
|
149
|
-
staged path (stage only a `.
|
|
159
|
+
staged path (stage only a `.claude.json`, then `git commit`), it exits 10 so Git stops, the
|
|
150
160
|
same outcome as committing with nothing staged. Carrying on would create a commit Git
|
|
151
161
|
itself would have refused and leave the developer a junk commit to `git reset --hard`.
|
|
152
162
|
- **Anchored attribution rules.** The AI-noreply rule is anchored to trailer
|
package/hooks/hooks.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI works. Hoomans ship. Keep AI session files
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "AI works. Hoomans ship. Keep AI session files and attribution out of your commits.",
|
|
5
5
|
"homepage": "https://github.com/rmyndharis/aimhooman#readme",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"hooks/",
|
|
22
22
|
"schemas/",
|
|
23
23
|
"docs/hosts.json",
|
|
24
|
+
"docs/ai-artifacts.gitignore",
|
|
25
|
+
"docs/catalog.md",
|
|
24
26
|
"docs/design/",
|
|
25
27
|
"docs/logo/",
|
|
26
28
|
".claude-plugin/",
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
"check": "node scripts/validate.mjs",
|
|
48
50
|
"sync:hosts": "node scripts/sync-hosts.mjs",
|
|
49
51
|
"sync:ruleset": "node scripts/sync-ruleset.mjs",
|
|
52
|
+
"sync:catalog": "node scripts/sync-catalog.mjs",
|
|
50
53
|
"test": "node --import ./tests/test-env.mjs --test",
|
|
51
54
|
"test:coverage": "node --import ./tests/test-env.mjs --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-include=\"bin/**\" --test-coverage-include=\"scripts/authorize-owner-paths.mjs\" --test-coverage-include=\"scripts/github-owner-authority.mjs\" --test-coverage-include=\"scripts/package-manifest.mjs\" --test-coverage-include=\"scripts/scan-ci-history.mjs\" --test-coverage-exclude=\"tests/**\" --test-coverage-lines=75 --test-coverage-branches=60 --test-coverage-functions=85 --test-reporter=spec --test-reporter=./scripts/coverage-threshold.mjs --test-reporter-destination=stdout --test-reporter-destination=stderr --test",
|
|
52
55
|
"check:static": "node scripts/static-gates.mjs",
|
package/rules/paths.json
CHANGED
|
@@ -216,120 +216,6 @@
|
|
|
216
216
|
"git restore --staged <path>"
|
|
217
217
|
]
|
|
218
218
|
},
|
|
219
|
-
{
|
|
220
|
-
"id": "secret.dotenv",
|
|
221
|
-
"version": 4,
|
|
222
|
-
"provider": "generic",
|
|
223
|
-
"category": "secret",
|
|
224
|
-
"confidence": "high",
|
|
225
|
-
"kind": "path",
|
|
226
|
-
"match": {
|
|
227
|
-
"path_case": "insensitive",
|
|
228
|
-
"paths": [
|
|
229
|
-
".env",
|
|
230
|
-
"**/.env",
|
|
231
|
-
".env.*",
|
|
232
|
-
"**/.env.*"
|
|
233
|
-
],
|
|
234
|
-
"except": [
|
|
235
|
-
"**/*.example",
|
|
236
|
-
"**/*.sample",
|
|
237
|
-
"**/*.template",
|
|
238
|
-
"**/*.dist",
|
|
239
|
-
"**/*.defaults",
|
|
240
|
-
"**/*.minimal"
|
|
241
|
-
]
|
|
242
|
-
},
|
|
243
|
-
"actions": {
|
|
244
|
-
"clean": "block",
|
|
245
|
-
"strict": "block",
|
|
246
|
-
"compliance": "block"
|
|
247
|
-
},
|
|
248
|
-
"reason": "Environment files often carry secrets and should not be committed.",
|
|
249
|
-
"remediation": [
|
|
250
|
-
"git restore --staged <path>",
|
|
251
|
-
"commit a .env.example with placeholders instead"
|
|
252
|
-
]
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
"id": "secret.private-key",
|
|
256
|
-
"version": 3,
|
|
257
|
-
"provider": "generic",
|
|
258
|
-
"category": "secret",
|
|
259
|
-
"confidence": "high",
|
|
260
|
-
"kind": "path",
|
|
261
|
-
"match": {
|
|
262
|
-
"path_case": "insensitive",
|
|
263
|
-
"paths": [
|
|
264
|
-
"**/id_rsa",
|
|
265
|
-
"**/id_dsa",
|
|
266
|
-
"**/id_ecdsa",
|
|
267
|
-
"**/id_ed25519",
|
|
268
|
-
"**/*.p12",
|
|
269
|
-
"**/*.pfx"
|
|
270
|
-
]
|
|
271
|
-
},
|
|
272
|
-
"actions": {
|
|
273
|
-
"clean": "block",
|
|
274
|
-
"strict": "block",
|
|
275
|
-
"compliance": "block"
|
|
276
|
-
},
|
|
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
|
-
"remediation": [
|
|
279
|
-
"git restore --staged <path>",
|
|
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"
|
|
282
|
-
]
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
"id": "secret.aws-credentials",
|
|
286
|
-
"version": 2,
|
|
287
|
-
"provider": "generic",
|
|
288
|
-
"category": "secret",
|
|
289
|
-
"confidence": "high",
|
|
290
|
-
"kind": "path",
|
|
291
|
-
"match": {
|
|
292
|
-
"path_case": "insensitive",
|
|
293
|
-
"paths": [
|
|
294
|
-
".aws/credentials",
|
|
295
|
-
"**/.aws/credentials"
|
|
296
|
-
]
|
|
297
|
-
},
|
|
298
|
-
"actions": {
|
|
299
|
-
"clean": "block",
|
|
300
|
-
"strict": "block",
|
|
301
|
-
"compliance": "block"
|
|
302
|
-
},
|
|
303
|
-
"reason": "AWS credentials must never be committed.",
|
|
304
|
-
"remediation": [
|
|
305
|
-
"git restore --staged <path>"
|
|
306
|
-
]
|
|
307
|
-
},
|
|
308
|
-
{
|
|
309
|
-
"id": "secret.claude-credentials",
|
|
310
|
-
"version": 1,
|
|
311
|
-
"provider": "claude-code",
|
|
312
|
-
"category": "secret",
|
|
313
|
-
"confidence": "high",
|
|
314
|
-
"kind": "path",
|
|
315
|
-
"match": {
|
|
316
|
-
"path_case": "insensitive",
|
|
317
|
-
"paths": [
|
|
318
|
-
".claude/.credentials.json",
|
|
319
|
-
"**/.claude/.credentials.json"
|
|
320
|
-
]
|
|
321
|
-
},
|
|
322
|
-
"actions": {
|
|
323
|
-
"clean": "block",
|
|
324
|
-
"strict": "block",
|
|
325
|
-
"compliance": "block"
|
|
326
|
-
},
|
|
327
|
-
"reason": "This file holds an OAuth access token and must never be committed.",
|
|
328
|
-
"remediation": [
|
|
329
|
-
"git restore --staged <path>",
|
|
330
|
-
"Revoke the token if it already reached a commit."
|
|
331
|
-
]
|
|
332
|
-
},
|
|
333
219
|
{
|
|
334
220
|
"id": "generic.agent-instructions",
|
|
335
221
|
"version": 2,
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"required": ["target"],
|
|
15
15
|
"properties": {
|
|
16
16
|
"target": { "type": "string", "minLength": 1 },
|
|
17
|
-
"scope": {
|
|
17
|
+
"scope": {
|
|
18
|
+
"enum": ["path", "rule"],
|
|
19
|
+
"$comment": "v0.3.0 retired the secret-path scope with built-in secret scanning; the loader drops legacy entries with a warning instead of failing the file."
|
|
20
|
+
},
|
|
18
21
|
"reason": { "type": "string" },
|
|
19
22
|
"actor": { "type": "string" },
|
|
20
23
|
"at": { "type": "string", "format": "date-time" }
|
|
@@ -3,8 +3,8 @@ name: aimhooman
|
|
|
3
3
|
description: >
|
|
4
4
|
Keep AI tooling artifacts out of Git. Use whenever staging, committing, or
|
|
5
5
|
pushing changes: block AI session/state files (.claude/session.json,
|
|
6
|
-
.codex/history, .copilot, .cursor/session, .aider.*, .specstory, .agent)
|
|
7
|
-
|
|
6
|
+
.codex/history, .copilot, .cursor/session, .aider.*, .specstory, .agent)
|
|
7
|
+
and unwanted AI attribution in
|
|
8
8
|
commit messages (Co-authored-by an AI, "Generated with AI"). Also use when the user says
|
|
9
9
|
"aimhooman", "ship it like a hooman", or asks to clean AI residue before a commit.
|
|
10
10
|
license: MIT
|
|
@@ -24,13 +24,16 @@ Its policy:
|
|
|
24
24
|
`.copilot/*`, `.cursor/session*`, `.cursor/chats/*`, `.cursor/logs/*`,
|
|
25
25
|
`.aider.*`, `.specstory/*`, `.continue/sessions/*`, `.playwright-mcp/*`,
|
|
26
26
|
`.remember/*`, `.superpowers/*`, and `.agent/*`. The examples are not
|
|
27
|
-
exhaustive; the packaged `rules/paths.json` is the detection source of truth
|
|
28
|
-
If one is staged, unstage it and keep it out of Git
|
|
27
|
+
exhaustive; the packaged `rules/paths.json` is the detection source of truth
|
|
28
|
+
for these artifacts. If one is staged, unstage it and keep it out of Git
|
|
29
|
+
instead.
|
|
29
30
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
30
31
|
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
31
32
|
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
32
33
|
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
33
|
-
SendGrid). Public certificates are allowed.
|
|
34
|
+
SendGrid). Public certificates are allowed. aimhooman does not scan for
|
|
35
|
+
secrets — this rule is on you, and a dedicated scanner such as gitleaks is
|
|
36
|
+
the right backstop for it.
|
|
34
37
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
35
38
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
36
39
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -61,7 +64,7 @@ npm test
|
|
|
61
64
|
npm run test:coverage
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
aimhooman also enforces
|
|
65
|
-
violation to fix, not a check to bypass. The rule
|
|
66
|
-
ship.
|
|
67
|
+
aimhooman also enforces the artifact and attribution rules at commit time, so
|
|
68
|
+
a blocked commit means a real violation to fix, not a check to bypass. The rule
|
|
69
|
+
is simple: AI works, hoomans ship.
|
|
67
70
|
<!-- aimhooman:ruleset-end -->
|
package/src/exclude.mjs
CHANGED
|
@@ -8,8 +8,8 @@ const END = '# <<< aimhooman managed excludes';
|
|
|
8
8
|
|
|
9
9
|
// Local AI residue kept out of `git status`. Deriving this list from the rule
|
|
10
10
|
// catalog prevents the prevention layer from drifting behind enforcement. We
|
|
11
|
-
// intentionally exclude only unambiguous tooling state/settings:
|
|
12
|
-
//
|
|
11
|
+
// intentionally exclude only unambiguous tooling state/settings: review-
|
|
12
|
+
// required and policy rules must remain visible to users.
|
|
13
13
|
const AUTO_EXCLUDE_CATEGORIES = new Set(['ephemeral-state', 'local-settings']);
|
|
14
14
|
|
|
15
15
|
export function patternsForRules(rules) {
|
|
@@ -74,6 +74,20 @@ export function inspectExclude(file, patterns) {
|
|
|
74
74
|
return { installed: true, current: missing.length === 0 && actual.size === patterns.length, missing };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// managedPatterns returns the pattern lines currently inside the managed
|
|
78
|
+
// block, or [] when the file or block is absent. Read-only.
|
|
79
|
+
export function managedPatterns(file) {
|
|
80
|
+
const existing = readExclude(file, null);
|
|
81
|
+
if (existing === null) return [];
|
|
82
|
+
const range = managedRange(existing);
|
|
83
|
+
if (!range) return [];
|
|
84
|
+
return existing
|
|
85
|
+
.slice(range.start + BEGIN.length, range.end)
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map((line) => line.trim())
|
|
88
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('!'));
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
function stripBlock(s) {
|
|
78
92
|
const range = managedRange(s);
|
|
79
93
|
if (!range) return s;
|
package/src/githooks.mjs
CHANGED
|
@@ -651,6 +651,12 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
651
651
|
const shellPathValue = hookPathForShell(pathValue);
|
|
652
652
|
const nodeMetadata = Buffer.from(nodePath, 'utf8').toString('base64url');
|
|
653
653
|
const pathMetadata = Buffer.from(pathValue, 'utf8').toString('base64url');
|
|
654
|
+
// chainedPath is <stateDir>/chained/<name> for a repository install and
|
|
655
|
+
// <globalHooksDir>/chained/<name> for a global one, so its grandparent is
|
|
656
|
+
// always per-install state. The compile cache lives there rather than in a
|
|
657
|
+
// shared or temp location: no other repository can pollute it, and
|
|
658
|
+
// `uninstall --purge-state` removes it along with the rest of the state.
|
|
659
|
+
const compileCacheDir = join(dirname(dirname(String(chainedPath))), 'compile-cache');
|
|
654
660
|
const captureTree = name === 'commit-msg'
|
|
655
661
|
? `AIMHOOMAN_COMMIT_TREE=$(PATH="$AIMHOOMAN_PATH" git write-tree) || {
|
|
656
662
|
echo "aimhooman: cannot snapshot the would-be commit tree" >&2
|
|
@@ -690,10 +696,31 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
690
696
|
// committed/aborted fire only after refs are locked in, and refcheck can do
|
|
691
697
|
// nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
|
|
692
698
|
// so an ordinary commit no longer pays a Node cold start for the committed
|
|
693
|
-
// phase.
|
|
694
|
-
//
|
|
699
|
+
// phase. The prepared filter goes further: a transaction that moves neither
|
|
700
|
+
// a branch nor HEAD (ORIG_HEAD, tags, remote-tracking refs) carries nothing
|
|
701
|
+
// refcheck scans, so it never spawns at all — but only after proving the
|
|
702
|
+
// guard is still there to skip. A hook manager that wipes the dispatchers
|
|
703
|
+
// during a branch-free operation (tag, fetch, stash) would otherwise go
|
|
704
|
+
// unnoticed, because after a full wipe no later hook exists to raise the
|
|
705
|
+
// alarm. Presence is exactly what a deletion removes; content integrity
|
|
706
|
+
// keeps running on every branch transaction via refcheck. Both sit after
|
|
707
|
+
// the chained-hook call, so a chained hook still sees every phase.
|
|
695
708
|
const phaseShortCircuit = name === 'reference-transaction'
|
|
696
|
-
?
|
|
709
|
+
? `case "$1" in committed|aborted) exit 0 ;; esac
|
|
710
|
+
case "$1" in prepared)
|
|
711
|
+
case "$AIMHOOMAN_REF_UPDATES" in
|
|
712
|
+
*refs/heads/*|*" HEAD"*) ;;
|
|
713
|
+
*)
|
|
714
|
+
for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction; do
|
|
715
|
+
[ -x "$(dirname "$0")/$AIMHOOMAN_GUARD" ] || {
|
|
716
|
+
echo "aimhooman: required Git guards changed while reference-transaction was running; $AIMHOOMAN_GUARD is unavailable. The operation was stopped; run 'aimhooman init' and retry." >&2
|
|
717
|
+
exit 20
|
|
718
|
+
}
|
|
719
|
+
done
|
|
720
|
+
exit 0 ;;
|
|
721
|
+
esac ;;
|
|
722
|
+
esac
|
|
723
|
+
`
|
|
697
724
|
: '';
|
|
698
725
|
const template = `#!/bin/sh -p
|
|
699
726
|
${MARKER} (${name})
|
|
@@ -706,6 +733,7 @@ ${MARKER} (${name})
|
|
|
706
733
|
AIMHOOMAN_CLI=${shq(resolvedCliPath)}
|
|
707
734
|
AIMHOOMAN_NODE=${shq(nodePath)}
|
|
708
735
|
AIMHOOMAN_PATH=${shq(shellPathValue)}
|
|
736
|
+
AIMHOOMAN_COMPILE_CACHE=${shq(compileCacheDir)}
|
|
709
737
|
${captureTree}${captureTransaction}run_aimhooman() {
|
|
710
738
|
if [ ! -f "$AIMHOOMAN_CLI" ]; then
|
|
711
739
|
echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
|
|
@@ -731,8 +759,15 @@ ${captureTree}${captureTransaction}run_aimhooman() {
|
|
|
731
759
|
unset BASH_ENV ENV CDPATH
|
|
732
760
|
PATH=$AIMHOOMAN_PATH
|
|
733
761
|
AIMHOOMAN_ACTIVE_HOOK=${shq(name)}
|
|
762
|
+
# A V8 compile cache shared by every hook spawn of this installation shaves
|
|
763
|
+
# the module parse/compile cost off each Node start. Node creates the
|
|
764
|
+
# directory when it can and silently disables the cache when it cannot, so
|
|
765
|
+
# an unwritable state directory degrades to the old cold start, never to a
|
|
766
|
+
# hook failure.
|
|
767
|
+
NODE_COMPILE_CACHE=$AIMHOOMAN_COMPILE_CACHE
|
|
734
768
|
export PATH
|
|
735
769
|
export AIMHOOMAN_ACTIVE_HOOK
|
|
770
|
+
export NODE_COMPILE_CACHE
|
|
736
771
|
"$AIMHOOMAN_NODE" "$AIMHOOMAN_CLI" "$@"
|
|
737
772
|
)
|
|
738
773
|
}
|
package/src/gitx.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
rmSync,
|
|
13
13
|
symlinkSync,
|
|
14
14
|
} from 'node:fs';
|
|
15
|
-
import { isAbsolute, join } from 'node:path';
|
|
15
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
16
16
|
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
17
17
|
|
|
18
18
|
export class GitRevisionError extends Error {
|
|
@@ -99,7 +99,23 @@ function objectMetadata(repo, oids) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function enrichEntries(repo, entries) {
|
|
102
|
-
const metadata = objectMetadata(
|
|
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,17 @@ export function stagedPaths(repo) {
|
|
|
177
228
|
], repo.root));
|
|
178
229
|
}
|
|
179
230
|
|
|
231
|
+
// ignoredByPatterns lists untracked, ignored paths under the given pathspecs.
|
|
232
|
+
// Passing pathspecs keeps git's directory pruning effective: a bare
|
|
233
|
+
// `ls-files --others --ignored` walks the whole worktree and costs ~150ms on
|
|
234
|
+
// a large one, while anchored pathspecs answer in ~15ms.
|
|
235
|
+
export function ignoredByPatterns(repo, pathspecs) {
|
|
236
|
+
if (!pathspecs.length) return [];
|
|
237
|
+
return nulStrings(gitBuf([
|
|
238
|
+
'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...pathspecs,
|
|
239
|
+
], repo.root));
|
|
240
|
+
}
|
|
241
|
+
|
|
180
242
|
// stagedTreeSha writes the index to a tree object and returns its SHA, the same
|
|
181
243
|
// value `git write-tree` produces in the commit-msg dispatcher. Used by the
|
|
182
244
|
// pre-commit/commit-msg marker dedup (W5): pre-commit records this sha when it
|
package/src/hook.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { newEngineWithDiagnostics } from './scan.mjs';
|
|
|
8
8
|
import { openRepo, stagedEntries } from './gitx.mjs';
|
|
9
9
|
import { applyExclude, patternsForRules } from './exclude.mjs';
|
|
10
10
|
import { activeGitHook, installedHooks } from './githooks.mjs';
|
|
11
|
+
import { loadConfig } from './state.mjs';
|
|
11
12
|
import { visible } from './report.mjs';
|
|
12
13
|
import { extractRuleset } from './ruleset-text.mjs';
|
|
13
14
|
import { resolvePolicy } from './policy-resolver.mjs';
|
|
@@ -84,7 +85,14 @@ export function hookSessionStart() {
|
|
|
84
85
|
const repo = openRepo();
|
|
85
86
|
const policy = resolvePolicy(repo, { target: 'worktree' });
|
|
86
87
|
const { engine: eng } = newEngineWithDiagnostics(policy.profile, repo.stateDir);
|
|
87
|
-
|
|
88
|
+
const patterns = patternsForRules(eng.rules);
|
|
89
|
+
applyExclude(repo.excludeFile, patterns);
|
|
90
|
+
// A clone that opted into the committed variant gets the same refresh in
|
|
91
|
+
// its worktree .gitignore; every failure degrades silently, same as the
|
|
92
|
+
// exclude write above.
|
|
93
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
94
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
95
|
+
}
|
|
88
96
|
} catch {
|
|
89
97
|
/* not a repo; nothing to exclude */
|
|
90
98
|
}
|
|
@@ -383,11 +391,23 @@ function hookPreToolUse(input) {
|
|
|
383
391
|
// above so a read-only .git/info (CI checkout, read-only volume, a
|
|
384
392
|
// repository owned by another user) cannot decide what is allowed.
|
|
385
393
|
if (repo) {
|
|
394
|
+
const patterns = patternsForRules(eng.rules);
|
|
386
395
|
try {
|
|
387
|
-
applyExclude(repo.excludeFile,
|
|
396
|
+
applyExclude(repo.excludeFile, patterns);
|
|
388
397
|
} catch (e) {
|
|
389
398
|
hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
|
|
390
399
|
}
|
|
400
|
+
// The committed variant of the block gets the same best-effort refresh
|
|
401
|
+
// when this clone opted into it; a failure there is housekeeping too,
|
|
402
|
+
// never part of the verdict.
|
|
403
|
+
try {
|
|
404
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
405
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
const gitignoreWarning = `could not refresh ${join(repo.root, '.gitignore')}: ${e.message}`;
|
|
409
|
+
hygieneWarning = hygieneWarning ? `${hygieneWarning}; ${gitignoreWarning}` : gitignoreWarning;
|
|
410
|
+
}
|
|
391
411
|
}
|
|
392
412
|
// A strict policy cannot make a meaningful guarantee if Git's own guards
|
|
393
413
|
// are explicitly bypassed. Deny before the shell can stage-and-commit in a
|
|
@@ -402,7 +422,7 @@ function hookPreToolUse(input) {
|
|
|
402
422
|
// A preceding command that may have changed the repository or its hooks is
|
|
403
423
|
// also a potential pre-commit bypass. Strict rejects it above; clean and
|
|
404
424
|
// compliance still need the staged-content backstop so a hook mutation
|
|
405
|
-
// cannot
|
|
425
|
+
// cannot sneak a blocked staged file past the guard.
|
|
406
426
|
const commitPrefixRisk = parsed.commands.some((candidate) => (
|
|
407
427
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
408
428
|
&& candidate.prefixRisk
|
|
@@ -426,8 +446,8 @@ function hookPreToolUse(input) {
|
|
|
426
446
|
// them. A build, a test run, ls — those leave pre-commit to answer, and
|
|
427
447
|
// refusing them taught agents to drop the `&&` gate rather than to run the
|
|
428
448
|
// command separately. hiddenBypass stays wider on purpose; it is what keeps
|
|
429
|
-
// the staged-content backstop reading the blobs, so a
|
|
430
|
-
// index still stops the commit here.
|
|
449
|
+
// the staged-content backstop reading the blobs, so a blocked file already
|
|
450
|
+
// in the index still stops the commit here.
|
|
431
451
|
const prefixHookBypass = opaqueCommitRisk
|
|
432
452
|
|| (commitPrefixRisk && (noVerify || bypassHooks || parsed.prefixHooksRisk));
|
|
433
453
|
// The same distinction, for the deny paths that ask "will anything scan this
|
|
@@ -460,7 +480,7 @@ function hookPreToolUse(input) {
|
|
|
460
480
|
const blocks = [];
|
|
461
481
|
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
462
482
|
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
463
|
-
// (gh ... | tail) was scanned and denied as if it staged a
|
|
483
|
+
// (gh ... | tail) was scanned and denied as if it staged a blocked file.
|
|
464
484
|
// opaqueCommitHiding keeps every commit-hiding shape (subshells,
|
|
465
485
|
// substitution, script-feeds, code-executing/unlisted pipe segments) while
|
|
466
486
|
// excluding pipelines of known read-only commands.
|
|
@@ -503,8 +523,8 @@ function hookPreToolUse(input) {
|
|
|
503
523
|
// disables the real pre-commit guard, leaving this hook the only check.
|
|
504
524
|
// When the commit also stages files at commit time (-a/--all/-u/--patch, or
|
|
505
525
|
// a preceding `git add`), those files are neither in stagedPaths(repo) now
|
|
506
|
-
// nor in addPaths, so they were never scanned. Rather than let
|
|
507
|
-
//
|
|
526
|
+
// nor in addPaths, so they were never scanned. Rather than let unscanned
|
|
527
|
+
// content through, deny. (Strict already denied the bypass above.) The rare
|
|
508
528
|
// false-positive on an explicit `git add <path> && git commit --no-verify`
|
|
509
529
|
// is safe-side; drop --no-verify to proceed.
|
|
510
530
|
//
|
|
@@ -512,7 +532,8 @@ function hookPreToolUse(input) {
|
|
|
512
532
|
// a --no-verify inside the inner command, where parsed.noVerify cannot see
|
|
513
533
|
// it (the parser only inspects literal argv). Such an uncertain commit is
|
|
514
534
|
// therefore treated as a potential hook bypass for the clean/compliance
|
|
515
|
-
//
|
|
535
|
+
// staged-content backstop, so a blocked file cannot slip past the guard
|
|
536
|
+
// wrapped in eval.
|
|
516
537
|
const indexReplacement = parsed.commands.some((candidate) => (
|
|
517
538
|
candidate.verb === 'commit' && candidate.indexMutationRisk
|
|
518
539
|
));
|
|
@@ -553,9 +574,9 @@ function hookPreToolUse(input) {
|
|
|
553
574
|
}
|
|
554
575
|
// clean / compliance: advisory only (the git pre-commit unstage is the real
|
|
555
576
|
// enforcement) — except when --no-verify/core.hooksPath bypasses that hook.
|
|
556
|
-
// A bypassed guard is the one case clean cannot delegate to git, so a
|
|
557
|
-
// secret
|
|
558
|
-
//
|
|
577
|
+
// A bypassed guard is the one case clean cannot delegate to git, so a
|
|
578
|
+
// secret-category finding (a local rule pack can still declare one) is
|
|
579
|
+
// denied here; hygiene findings stay advisory.
|
|
559
580
|
const bypassed = Boolean(potentialCommit && hiddenBypass);
|
|
560
581
|
if (bypassed && blocks.some((f) => f.category === 'secret')) {
|
|
561
582
|
return emitDecision(
|