@rmyndharis/aimhooman 0.1.2 → 0.1.4
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 +5 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/aimhooman.md +5 -3
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +5 -3
- package/.github/copilot-instructions.md +5 -3
- package/.kiro/steering/aimhooman.md +5 -3
- package/.windsurf/rules/aimhooman.md +5 -3
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +49 -0
- package/GEMINI.md +5 -3
- package/README.md +15 -7
- package/bin/aimhooman.mjs +143 -32
- package/docs/design/frictionless-enforcement.md +28 -0
- package/package.json +1 -1
- package/rules/attribution.json +3 -3
- package/rules/markers.json +31 -5
- package/rules/paths.json +60 -33
- package/rules/secrets.json +5 -3
- package/skills/aimhooman/SKILL.md +5 -3
- package/src/atomic-write.mjs +11 -1
- package/src/githooks.mjs +1 -1
- package/src/gitx.mjs +17 -5
- package/src/history-scan.mjs +4 -0
- package/src/hook.mjs +401 -46
- package/src/scan-session.mjs +9 -3
- package/src/scan-target.mjs +10 -1
|
@@ -5,7 +5,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
5
5
|
Its policy:
|
|
6
6
|
|
|
7
7
|
- Never stage or commit local AI session/state files. Examples include
|
|
8
|
-
`.claude.json`, `.claude
|
|
8
|
+
`.claude.json`, `.claude/session*.json`,
|
|
9
9
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
10
10
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
11
11
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -15,8 +15,10 @@ Its policy:
|
|
|
15
15
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
16
16
|
If one is staged, unstage it and keep it out of Git instead.
|
|
17
17
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
18
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
-
service-account keys
|
|
18
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
20
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
21
|
+
SendGrid). Public certificates are allowed.
|
|
20
22
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
21
23
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
22
24
|
noreply emails. A commit message reads as if a human wrote it.
|
package/.clinerules/aimhooman.md
CHANGED
|
@@ -5,7 +5,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
5
5
|
Its policy:
|
|
6
6
|
|
|
7
7
|
- Never stage or commit local AI session/state files. Examples include
|
|
8
|
-
`.claude.json`, `.claude
|
|
8
|
+
`.claude.json`, `.claude/session*.json`,
|
|
9
9
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
10
10
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
11
11
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -15,8 +15,10 @@ Its policy:
|
|
|
15
15
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
16
16
|
If one is staged, unstage it and keep it out of Git instead.
|
|
17
17
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
18
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
-
service-account keys
|
|
18
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
20
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
21
|
+
SendGrid). Public certificates are allowed.
|
|
20
22
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
21
23
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
22
24
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -11,7 +11,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
11
11
|
Its policy:
|
|
12
12
|
|
|
13
13
|
- Never stage or commit local AI session/state files. Examples include
|
|
14
|
-
`.claude.json`, `.claude
|
|
14
|
+
`.claude.json`, `.claude/session*.json`,
|
|
15
15
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
16
16
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
17
17
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -21,8 +21,10 @@ Its policy:
|
|
|
21
21
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
22
22
|
If one is staged, unstage it and keep it out of Git instead.
|
|
23
23
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
24
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
25
|
-
service-account keys
|
|
24
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
25
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
26
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
27
|
+
SendGrid). Public certificates are allowed.
|
|
26
28
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
27
29
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
28
30
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -5,7 +5,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
5
5
|
Its policy:
|
|
6
6
|
|
|
7
7
|
- Never stage or commit local AI session/state files. Examples include
|
|
8
|
-
`.claude.json`, `.claude
|
|
8
|
+
`.claude.json`, `.claude/session*.json`,
|
|
9
9
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
10
10
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
11
11
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -15,8 +15,10 @@ Its policy:
|
|
|
15
15
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
16
16
|
If one is staged, unstage it and keep it out of Git instead.
|
|
17
17
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
18
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
-
service-account keys
|
|
18
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
20
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
21
|
+
SendGrid). Public certificates are allowed.
|
|
20
22
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
21
23
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
22
24
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -5,7 +5,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
5
5
|
Its policy:
|
|
6
6
|
|
|
7
7
|
- Never stage or commit local AI session/state files. Examples include
|
|
8
|
-
`.claude.json`, `.claude
|
|
8
|
+
`.claude.json`, `.claude/session*.json`,
|
|
9
9
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
10
10
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
11
11
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -15,8 +15,10 @@ Its policy:
|
|
|
15
15
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
16
16
|
If one is staged, unstage it and keep it out of Git instead.
|
|
17
17
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
18
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
-
service-account keys
|
|
18
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
20
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
21
|
+
SendGrid). Public certificates are allowed.
|
|
20
22
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
21
23
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
22
24
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -9,7 +9,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
9
9
|
Its policy:
|
|
10
10
|
|
|
11
11
|
- Never stage or commit local AI session/state files. Examples include
|
|
12
|
-
`.claude.json`, `.claude
|
|
12
|
+
`.claude.json`, `.claude/session*.json`,
|
|
13
13
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
14
14
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
15
15
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -19,8 +19,10 @@ Its policy:
|
|
|
19
19
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
20
20
|
If one is staged, unstage it and keep it out of Git instead.
|
|
21
21
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
22
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
23
|
-
service-account keys
|
|
22
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
23
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
24
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
25
|
+
SendGrid). Public certificates are allowed.
|
|
24
26
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
25
27
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
26
28
|
noreply emails. A commit message reads as if a human wrote it.
|
package/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
5
5
|
Its policy:
|
|
6
6
|
|
|
7
7
|
- Never stage or commit local AI session/state files. Examples include
|
|
8
|
-
`.claude.json`, `.claude
|
|
8
|
+
`.claude.json`, `.claude/session*.json`,
|
|
9
9
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
10
10
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
11
11
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -15,8 +15,10 @@ Its policy:
|
|
|
15
15
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
16
16
|
If one is staged, unstage it and keep it out of Git instead.
|
|
17
17
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
18
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
-
service-account keys
|
|
18
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
20
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
21
|
+
SendGrid). Public certificates are allowed.
|
|
20
22
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
21
23
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
22
24
|
noreply emails. A commit message reads as if a human wrote it.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,55 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.4] - 2026-07-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Uninstall no longer leaves aimhooman artifacts in `.git`. Every atomic write takes a
|
|
13
|
+
lock whose `<lock>.queue` directory was created but never removed — the release path
|
|
14
|
+
only unlinked its own candidate file — and `--purge-state` cleared just the state
|
|
15
|
+
directory. So `.git` kept the queue directories and the `COMMIT_EDITMSG.aimhooman-bak`
|
|
16
|
+
attribution backup while the command printed "state purged". Uninstall now sweeps this
|
|
17
|
+
residue once the lifecycle lock releases; `rmdirSync` removes only an empty directory,
|
|
18
|
+
so a concurrent contender's queue is never touched, and the lifecycle queue is removable
|
|
19
|
+
precisely because its own lock has already released. A plain uninstall keeps policy
|
|
20
|
+
state and drops the operational residue; `--purge-state` leaves no aimhooman fingerprints.
|
|
21
|
+
- A bare AWS access key ID is now detected. `secret.aws-key-content` fired only when an
|
|
22
|
+
`aws_secret_access_key` or `aws_session_token` name sat beside the value, so an
|
|
23
|
+
`AKIA…`/`ASIA…` access key ID committed on its own passed clean. The rule now matches the
|
|
24
|
+
access-key-ID prefixes directly — the fixed-prefix, highest-confidence AWS indicator. The
|
|
25
|
+
16-character body and word boundaries keep short lookalikes such as `AKIA123` from matching.
|
|
26
|
+
|
|
27
|
+
## [0.1.3] - 2026-07-18
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Repair that empties the index no longer mints an empty commit. Stage only a `.env`,
|
|
32
|
+
run `git commit -m "add config"`, and the pre-commit repair unstages it and prints
|
|
33
|
+
"the commit will be empty" — then Git made that empty commit anyway. Git refuses a
|
|
34
|
+
commit with nothing staged, but repair runs after Git has already decided to proceed,
|
|
35
|
+
so carrying on created a commit Git would never have made and left a junk commit to
|
|
36
|
+
`git reset --hard`. Pre-commit now exits 10 when the repair empties the index, so Git
|
|
37
|
+
stops. Nothing else staged means nothing to commit.
|
|
38
|
+
- `build && git add . && git commit` is no longer denied. Each half already passed on its
|
|
39
|
+
own; only the pair was refused. The commit-time-staging deny asked the wide bypass
|
|
40
|
+
predicate — the one that decides whether the staged-content backstop reads the blobs —
|
|
41
|
+
instead of the narrow question of whether anything will scan the commit. An unmodelled
|
|
42
|
+
prefix is not a bypass, and pre-commit still scans the real index at commit time. The
|
|
43
|
+
deny now asks that narrow question. A prefix that reaches the hooks, `--no-verify`, and
|
|
44
|
+
a real `core.hooksPath` override all keep the deny, where the staged files really would
|
|
45
|
+
go unscanned. The refusal message no longer names "--no-verify or shell indirection"
|
|
46
|
+
when neither is present.
|
|
47
|
+
- The `PreToolUse` guard stops refusing more everyday read-only pipelines. Building on the
|
|
48
|
+
0.1.1 filter allowance, a read-only Git subcommand is now an allowed pipe source, so
|
|
49
|
+
`git log | head`, `git status | grep modified`, `git diff | cat`, and
|
|
50
|
+
`cd repo && git log | head` run. Build and test toolchains (`npm`, `cargo`, `make`,
|
|
51
|
+
`jest`, `eslint`, `tsc`, `pytest`) are allowed as a source. The listing forms of
|
|
52
|
+
branch/tag/remote/stash/notes run (`git branch | grep`, `git remote -v | grep origin`),
|
|
53
|
+
while their mutating forms (`git branch -D`, `git tag v1`) stay denied. Git as a pipe
|
|
54
|
+
sink, `git commit --no-verify`, and `git push` still deny; the reference-transaction and
|
|
55
|
+
pre-commit hooks remain the boundary.
|
|
56
|
+
|
|
8
57
|
## [0.1.2] - 2026-07-17
|
|
9
58
|
|
|
10
59
|
### Removed
|
package/GEMINI.md
CHANGED
|
@@ -5,7 +5,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
5
5
|
Its policy:
|
|
6
6
|
|
|
7
7
|
- Never stage or commit local AI session/state files. Examples include
|
|
8
|
-
`.claude.json`, `.claude
|
|
8
|
+
`.claude.json`, `.claude/session*.json`,
|
|
9
9
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
10
10
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
11
11
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -15,8 +15,10 @@ Its policy:
|
|
|
15
15
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
16
16
|
If one is staged, unstage it and keep it out of Git instead.
|
|
17
17
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
18
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
-
service-account keys
|
|
18
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
19
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
20
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
21
|
+
SendGrid). Public certificates are allowed.
|
|
20
22
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
21
23
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
22
24
|
noreply emails. A commit message reads as if a human wrote it.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<img src="https://img.shields.io/github/actions/workflow/status/rmyndharis/aimhooman/test.yml?branch=main&label=CI" alt="CI">
|
|
12
|
-
<img src="https://img.shields.io/badge/version-v0.1.
|
|
12
|
+
<img src="https://img.shields.io/badge/version-v0.1.4-blue" alt="v0.1.4">
|
|
13
13
|
<img src="https://img.shields.io/badge/node-%E2%89%A522.8-339933?logo=node.js&logoColor=white" alt="Node 22.8+">
|
|
14
14
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-MIT-111111" alt="MIT">
|
|
@@ -154,8 +154,11 @@ ignores `.git/hooks`. Repository `init` installs and chains predecessors only wh
|
|
|
154
154
|
that directory is absent or is proven to be owned by the repository: inside it and
|
|
155
155
|
not tracked by Git. It refuses to modify a global, shared, external, or tracked
|
|
156
156
|
hook directory, because a dispatcher committed from one machine names paths that
|
|
157
|
-
exist only on that machine.
|
|
158
|
-
|
|
157
|
+
exist only on that machine. Those repositories are not guarded, and there is no
|
|
158
|
+
way to guard them today. Calling `aimhooman precommit` from an existing hook
|
|
159
|
+
manager runs the check but registers no managed guard, so the agent hook still
|
|
160
|
+
refuses the commit. Remove the override before retrying, or accept that the
|
|
161
|
+
repository is unguarded and do not run `init` there.
|
|
159
162
|
|
|
160
163
|
Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`, and
|
|
161
164
|
`reference-transaction`, and preserves an existing hook as a predecessor. For
|
|
@@ -238,7 +241,8 @@ files automatically.
|
|
|
238
241
|
`.agent/`. [`rules/paths.json`](rules/paths.json) is the complete catalog.
|
|
239
242
|
- **Secrets**: a real `.env` (not `.env.example`), private-key content,
|
|
240
243
|
`.aws/credentials`, service-account private keys, recognized AWS secret/session
|
|
241
|
-
assignments, and provider token prefixes for GitHub, GitLab, npm,
|
|
244
|
+
assignments, and provider token prefixes for GitHub, GitLab, npm, Slack,
|
|
245
|
+
Anthropic, OpenAI, Google, Stripe, Hugging Face, and SendGrid.
|
|
242
246
|
Public certificates are allowed.
|
|
243
247
|
- **AI attribution** in commit messages: known AI `Co-authored-by:` identities,
|
|
244
248
|
exact "Generated with/by ..." lines, and AI-service noreply attribution trailers.
|
|
@@ -410,9 +414,13 @@ that would open an editor, and commits with an active foreign `prepare-commit-ms
|
|
|
410
414
|
It still cannot prove that every program already selected by local Git config is safe.
|
|
411
415
|
Commands assembled from files, encoded data, or network input may also be invisible to
|
|
412
416
|
its non-executing shell parser (POSIX shells — bash, sh, zsh, dash, ksh — and
|
|
413
|
-
`git.exe
|
|
414
|
-
|
|
415
|
-
|
|
417
|
+
`git.exe`). Everyday read-only pipelines run, because a read-only source cannot hide or
|
|
418
|
+
feed a commit: `git log | head`, `git status | grep modified`, `git diff | cat`,
|
|
419
|
+
`git branch | grep`, `npm test | tail`, `cargo build 2>&1 | grep error`, and
|
|
420
|
+
`cd repo && git log | head` all pass. What stays uncertain and, on `strict`, is denied
|
|
421
|
+
with a retry instruction: a git command as a pipe *sink* (`cat patch | git apply`),
|
|
422
|
+
pipe-to-shell (`curl x | bash`), command nesting, background jobs, and non-POSIX
|
|
423
|
+
executors such as PowerShell or fish. Repository selection written in non-POSIX shell syntax
|
|
416
424
|
is denied before policy lookup on every profile. Guarded Git changes, including
|
|
417
425
|
`add`, commit, and ref updates, also reject shell-expanded targets, any leading-tilde
|
|
418
426
|
target, POSIX targets beginning with exactly `//`, and an explicit split
|
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { chmodSync, lstatSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '../src/gitx.mjs';
|
|
21
21
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
22
22
|
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
23
|
-
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
23
|
+
import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
24
24
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
25
25
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
26
26
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
@@ -59,6 +59,42 @@ function tone() {
|
|
|
59
59
|
return process.env.AIMHOOMAN_TONE === 'professional' || process.env.CI ? 'professional' : 'playful';
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// The per-scan budget sizes the `cat-file --batch` read buffer, so a value that
|
|
63
|
+
// travelled with a clone would let a hostile repository drive the verifier out
|
|
64
|
+
// of memory instead of being rejected by it. These stay in the environment for
|
|
65
|
+
// that reason, never in .aimhooman.json, and a raise is capped for the same one.
|
|
66
|
+
// Lowering needs no ceiling: a smaller budget only skips more, and a skip is
|
|
67
|
+
// already an incomplete scan, which fails closed at 31.
|
|
68
|
+
const MAX_SCAN_LIMIT_BYTES = 1 << 30;
|
|
69
|
+
|
|
70
|
+
// Crossing a scan budget marks the scan incomplete on every profile, and an
|
|
71
|
+
// incomplete scan is a ref update the reference-transaction hook refuses — the
|
|
72
|
+
// one boundary --no-verify does not skip. A repository tracking a lockfile, an
|
|
73
|
+
// image or a vendored bundle over the default therefore could not commit at all,
|
|
74
|
+
// and "reduce the target or limits and retry" named something no flag, schema
|
|
75
|
+
// field or config key could do. This is that knob.
|
|
76
|
+
function scanLimits() {
|
|
77
|
+
const limits = {};
|
|
78
|
+
for (const [key, name] of [
|
|
79
|
+
['maxFileBytes', 'AIMHOOMAN_MAX_FILE_BYTES'],
|
|
80
|
+
['maxTotalBytes', 'AIMHOOMAN_MAX_TOTAL_BYTES'],
|
|
81
|
+
]) {
|
|
82
|
+
const raw = process.env[name];
|
|
83
|
+
if (raw === undefined || raw === '') continue;
|
|
84
|
+
if (!/^\d+$/.test(raw)) {
|
|
85
|
+
throw new ArgumentError(`${name} must be a whole number of bytes; got "${visible(raw)}"`);
|
|
86
|
+
}
|
|
87
|
+
const value = Number(raw);
|
|
88
|
+
if (value < 1 || value > MAX_SCAN_LIMIT_BYTES) {
|
|
89
|
+
throw new ArgumentError(
|
|
90
|
+
`${name} must be between 1 and ${MAX_SCAN_LIMIT_BYTES} bytes; got ${raw}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
limits[key] = value;
|
|
94
|
+
}
|
|
95
|
+
return limits;
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
function configuredEngine(profile, repo) {
|
|
63
99
|
const { engine, errors } = newEngineWithDiagnostics(profile, repo?.stateDir);
|
|
64
100
|
if (errors.length && profile === 'strict') {
|
|
@@ -166,10 +202,16 @@ function incompleteMessage(scan) {
|
|
|
166
202
|
.join(', ');
|
|
167
203
|
// Every other reason is a size or budget the caller can shrink. A pack that
|
|
168
204
|
// will not compile is not, and the warning above already names the file and
|
|
169
|
-
// the error, so point at that instead of misdirecting to the limits.
|
|
205
|
+
// the error, so point at that instead of misdirecting to the limits. When a
|
|
206
|
+
// byte budget is what stopped the scan, name the budget: the caller whose own
|
|
207
|
+
// tree outgrew it needs to raise one, and "reduce the limits" sends them the
|
|
208
|
+
// wrong way down a road they cannot leave.
|
|
209
|
+
const budgeted = reasons['size-limit'] || reasons['total-byte-limit'];
|
|
170
210
|
const hint = reasons['local-pack-error']
|
|
171
211
|
? 'fix the reported rule pack and retry'
|
|
172
|
-
:
|
|
212
|
+
: budgeted
|
|
213
|
+
? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
|
|
214
|
+
: 'reduce the target or limits and retry';
|
|
173
215
|
return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
|
|
174
216
|
}
|
|
175
217
|
|
|
@@ -256,9 +298,10 @@ function cmdPrecommit(args) {
|
|
|
256
298
|
parseNoArguments(args);
|
|
257
299
|
const repo = tryRepo();
|
|
258
300
|
if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
|
|
301
|
+
const limits = scanLimits();
|
|
259
302
|
let scan;
|
|
260
303
|
try {
|
|
261
|
-
scan = scanGitTarget(repo, { kind: 'staged' });
|
|
304
|
+
scan = scanGitTarget(repo, { kind: 'staged', limits });
|
|
262
305
|
} catch (e) {
|
|
263
306
|
console.error(`aimhooman: cannot scan staged content: ${e.message}`);
|
|
264
307
|
return expectedErrorCode(e);
|
|
@@ -284,6 +327,7 @@ function cmdPrecommit(args) {
|
|
|
284
327
|
return 10;
|
|
285
328
|
}
|
|
286
329
|
const paths = [...new Set(blocks.map((f) => f.path).filter(Boolean))];
|
|
330
|
+
let emptied = false;
|
|
287
331
|
try {
|
|
288
332
|
const unstageTargets = new Set(paths);
|
|
289
333
|
for (const finding of blocks) {
|
|
@@ -292,15 +336,15 @@ function cmdPrecommit(args) {
|
|
|
292
336
|
}
|
|
293
337
|
}
|
|
294
338
|
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
295
|
-
//
|
|
296
|
-
// repair, not from a second git read after
|
|
297
|
-
// post-repair read followed an index write
|
|
298
|
-
// the wrong state under heavy CI load,
|
|
299
|
-
// unstagePaths is atomic (it throws on
|
|
300
|
-
// path is a repair target the index
|
|
301
|
-
// is best-effort and runs before the
|
|
302
|
-
// unstage still runs and the
|
|
303
|
-
// than
|
|
339
|
+
// Whether the repair empties the index is derived from the staged paths
|
|
340
|
+
// captured before repair, not from a second git read after
|
|
341
|
+
// `git restore --staged`. That post-repair read followed an index write
|
|
342
|
+
// and could transiently report the wrong state under heavy CI load,
|
|
343
|
+
// flaking the repair tests. unstagePaths is atomic (it throws on
|
|
344
|
+
// failure), so when every staged path is a repair target the index
|
|
345
|
+
// matches HEAD afterward. The capture is best-effort and runs before the
|
|
346
|
+
// repair: if this read fails the unstage still runs and the commit is
|
|
347
|
+
// left to proceed rather than blocked on a state we could not read.
|
|
304
348
|
let stagedBefore;
|
|
305
349
|
try {
|
|
306
350
|
stagedBefore = stagedPaths(repo);
|
|
@@ -325,10 +369,10 @@ function cmdPrecommit(args) {
|
|
|
325
369
|
if (!pending.length) break;
|
|
326
370
|
unstagePaths(repo, pending);
|
|
327
371
|
}
|
|
328
|
-
|
|
372
|
+
emptied = stagedBefore !== null
|
|
329
373
|
&& stagedBefore.every((path) => unstageTargets.has(path));
|
|
330
374
|
process.stderr.write(
|
|
331
|
-
`aimhooman: unstaged ${paths.length}
|
|
375
|
+
`aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')}${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
|
|
332
376
|
);
|
|
333
377
|
} catch (e) {
|
|
334
378
|
process.stderr.write(
|
|
@@ -337,8 +381,20 @@ function cmdPrecommit(args) {
|
|
|
337
381
|
);
|
|
338
382
|
return 10;
|
|
339
383
|
}
|
|
340
|
-
|
|
384
|
+
// Seven of the rules that can reach the summary above are secret rules, so it
|
|
385
|
+
// cannot name a cause without guessing wrong for a private key or an AWS
|
|
386
|
+
// credential — and it used to be the only thing printed for a block. Let the
|
|
387
|
+
// findings speak instead: human() already carries the rule id, the path, the
|
|
388
|
+
// reason and the remediation, and already redacts secret text. It labels each
|
|
389
|
+
// one BLOCK, which is the decision that unstaged the path, not a stopped
|
|
390
|
+
// commit; the summary above says what actually happened to them.
|
|
391
|
+
process.stderr.write(human([...blocks, ...reviews], tone()));
|
|
341
392
|
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
393
|
+
// Git refuses a commit with nothing staged; repair runs after git has already
|
|
394
|
+
// decided otherwise, so carrying on here mints the empty commit git would
|
|
395
|
+
// not. The request was to commit the artifact, not to stamp the history with
|
|
396
|
+
// its message and no content.
|
|
397
|
+
if (emptied) return 10;
|
|
342
398
|
return scan.complete ? 0 : 31;
|
|
343
399
|
}
|
|
344
400
|
|
|
@@ -380,12 +436,13 @@ function cmdCommitmsg(args) {
|
|
|
380
436
|
}
|
|
381
437
|
const text = messageBytes.toString('utf8');
|
|
382
438
|
const validUtf8 = Buffer.from(text, 'utf8').equals(messageBytes);
|
|
439
|
+
const limits = scanLimits();
|
|
383
440
|
let scan;
|
|
384
441
|
let treeScan = null;
|
|
385
442
|
try {
|
|
386
443
|
const checked = againstWouldBeTree(() => ({
|
|
387
444
|
message: scanMessage(repo, text, { target: 'staged' }),
|
|
388
|
-
tree: options.tree ? scanGitTarget(repo, { kind: 'staged' }) : null,
|
|
445
|
+
tree: options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null,
|
|
389
446
|
}));
|
|
390
447
|
scan = checked.message;
|
|
391
448
|
treeScan = checked.tree;
|
|
@@ -461,18 +518,12 @@ function cmdRefcheck(args) {
|
|
|
461
518
|
if (phase === 'committed' || phase === 'aborted') return 0;
|
|
462
519
|
|
|
463
520
|
const repo = tryRepo();
|
|
464
|
-
if (!repo) {
|
|
465
|
-
// Global hooks also run in bare repositories. Bare repositories have no
|
|
466
|
-
// worktree/index policy boundary and are intentionally unsupported, so
|
|
467
|
-
// a global dispatcher must remain transparent there instead of making
|
|
468
|
-
// every receive-pack or update-ref fail merely because --show-toplevel
|
|
469
|
-
// is unavailable.
|
|
470
|
-
if (currentRepositoryIsBare()) return 0;
|
|
471
|
-
console.error('aimhooman: not a git repository');
|
|
472
|
-
return 30;
|
|
473
|
-
}
|
|
474
521
|
if (phase === 'preparing') {
|
|
475
|
-
|
|
522
|
+
// This phase never scans, and without a repository there is no
|
|
523
|
+
// dispatcher directory to inspect either. Git fires it while `git init`
|
|
524
|
+
// is still building the repository, so refusing here would only stop
|
|
525
|
+
// repository creation on behalf of a phase with nothing to say.
|
|
526
|
+
return repo && dispatchHooksChanged(repo, 'clean') ? 20 : 0;
|
|
476
527
|
}
|
|
477
528
|
let input;
|
|
478
529
|
try { input = readFileSync(0, 'utf8'); }
|
|
@@ -499,6 +550,31 @@ function cmdRefcheck(args) {
|
|
|
499
550
|
updates.push({ oldObjectId, newObjectId, ref });
|
|
500
551
|
}
|
|
501
552
|
|
|
553
|
+
if (!repo) {
|
|
554
|
+
// Git writes the initial HEAD inside a reference transaction and fires
|
|
555
|
+
// this hook while the repository is still being built: GIT_DIR is
|
|
556
|
+
// exported but nothing can be queried yet, so every rev-parse fails,
|
|
557
|
+
// --is-bare-repository included. The bare carve-out below therefore
|
|
558
|
+
// cannot speak for `git init` or `git init --bare`, and reading the
|
|
559
|
+
// silence as "not a git repository" aborted the transaction and left a
|
|
560
|
+
// half-built .git behind that a second `git init` could not repair.
|
|
561
|
+
// That payload is `0000..0000 ref:refs/heads/<name> HEAD`, which the
|
|
562
|
+
// symref filter above already dropped, so no policy-relevant update
|
|
563
|
+
// survives and there is nothing to scan. The scope is "the payload
|
|
564
|
+
// carries nothing", never "git failed to answer": a transaction holding
|
|
565
|
+
// a real object still demands a repository, so no one can disable the
|
|
566
|
+
// last veto by arranging for openRepo to fail.
|
|
567
|
+
if (!updates.length) return 0;
|
|
568
|
+
// Global hooks also run in bare repositories. Bare repositories have no
|
|
569
|
+
// worktree/index policy boundary and are intentionally unsupported, so
|
|
570
|
+
// a global dispatcher must remain transparent there instead of making
|
|
571
|
+
// every receive-pack or update-ref fail merely because --show-toplevel
|
|
572
|
+
// is unavailable.
|
|
573
|
+
if (currentRepositoryIsBare()) return 0;
|
|
574
|
+
console.error('aimhooman: not a git repository');
|
|
575
|
+
return 30;
|
|
576
|
+
}
|
|
577
|
+
|
|
502
578
|
let commits;
|
|
503
579
|
try {
|
|
504
580
|
const contextsByCommit = new Map();
|
|
@@ -533,6 +609,7 @@ function cmdRefcheck(args) {
|
|
|
533
609
|
console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
|
|
534
610
|
return 30;
|
|
535
611
|
}
|
|
612
|
+
const limits = scanLimits();
|
|
536
613
|
for (const [revision, reviewContexts] of commits) {
|
|
537
614
|
let scan;
|
|
538
615
|
try {
|
|
@@ -541,6 +618,7 @@ function cmdRefcheck(args) {
|
|
|
541
618
|
revision,
|
|
542
619
|
reviewContexts,
|
|
543
620
|
policyMigrationContexts: reviewContexts,
|
|
621
|
+
limits,
|
|
544
622
|
});
|
|
545
623
|
}
|
|
546
624
|
catch (error) {
|
|
@@ -593,6 +671,7 @@ function cmdCheck(args) {
|
|
|
593
671
|
return 30;
|
|
594
672
|
}
|
|
595
673
|
}
|
|
674
|
+
const limits = scanLimits();
|
|
596
675
|
let scan;
|
|
597
676
|
try {
|
|
598
677
|
if (o.message && !o.staged && !o.tracked) {
|
|
@@ -606,6 +685,7 @@ function cmdCheck(args) {
|
|
|
606
685
|
revision: o.commit,
|
|
607
686
|
range: o.range,
|
|
608
687
|
explicitProfile: o.profile,
|
|
688
|
+
limits,
|
|
609
689
|
...(o.message ? { messageText } : {}),
|
|
610
690
|
});
|
|
611
691
|
}
|
|
@@ -1535,9 +1615,21 @@ function cmdUninstall(args) {
|
|
|
1535
1615
|
console.error('aimhooman: not a git repository');
|
|
1536
1616
|
return 30;
|
|
1537
1617
|
}
|
|
1538
|
-
|
|
1618
|
+
const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
|
|
1619
|
+
const exitStatus = withLock(lifecycleLock, () => {
|
|
1539
1620
|
const rep = uninstallHooks(repo);
|
|
1540
|
-
|
|
1621
|
+
// The irreversible work is already done above, and the report is still
|
|
1622
|
+
// below. A throw from here unwound past all of it, so a damaged marker was
|
|
1623
|
+
// the only thing the user heard while four dispatchers kept guarding every
|
|
1624
|
+
// commit. Report the failure beside the removal report instead of in place
|
|
1625
|
+
// of it. Nothing is swallowed: this is also where the symlink and permission
|
|
1626
|
+
// guards surface, and their messages still reach the user and still exit 30.
|
|
1627
|
+
let excludeFailure = '';
|
|
1628
|
+
try {
|
|
1629
|
+
removeExclude(repo.excludeFile);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
|
|
1632
|
+
}
|
|
1541
1633
|
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1542
1634
|
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1543
1635
|
const remaining = remainingDispatchers(repo);
|
|
@@ -1552,6 +1644,7 @@ function cmdUninstall(args) {
|
|
|
1552
1644
|
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1553
1645
|
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1554
1646
|
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1647
|
+
if (excludeFailure) console.error(` failure: ${excludeFailure}`);
|
|
1555
1648
|
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1556
1649
|
if (purge) {
|
|
1557
1650
|
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
@@ -1587,8 +1680,26 @@ function cmdUninstall(args) {
|
|
|
1587
1680
|
console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
|
|
1588
1681
|
console.log(' run `aimhooman uninstall --global` to remove it.');
|
|
1589
1682
|
}
|
|
1590
|
-
|
|
1683
|
+
// The managed block is still in the exclude file and still ignoring paths, so
|
|
1684
|
+
// the uninstall is genuinely incomplete and 30 is the honest answer.
|
|
1685
|
+
return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
|
|
1591
1686
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1687
|
+
// Sweep the operational residue the guard authored in .git, so uninstall leaves
|
|
1688
|
+
// no aimhooman fingerprints behind — the same tooling residue this tool exists
|
|
1689
|
+
// to remove. rmdirSync deletes only an empty directory, so a live contender's
|
|
1690
|
+
// queue is never touched; the lifecycle queue becomes removable only now, after
|
|
1691
|
+
// its own lock above has released.
|
|
1692
|
+
for (const queue of [
|
|
1693
|
+
`${lifecycleLock}.queue`,
|
|
1694
|
+
`${repo.excludeFile}.aimhooman.lock.queue`,
|
|
1695
|
+
join(effectiveHooksDir(repo), '.aimhooman-hooks.lock.queue'),
|
|
1696
|
+
]) {
|
|
1697
|
+
try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
|
|
1698
|
+
}
|
|
1699
|
+
// A one-commit-ago attribution backup that git's next COMMIT_EDITMSG makes stale.
|
|
1700
|
+
try { rmSync(join(repo.commonDir, 'COMMIT_EDITMSG.aimhooman-bak'), { force: true }); }
|
|
1701
|
+
catch { /* nothing to remove */ }
|
|
1702
|
+
return exitStatus;
|
|
1592
1703
|
}
|
|
1593
1704
|
|
|
1594
1705
|
function usage() {
|