@rmyndharis/aimhooman 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +1 -1
- package/CHANGELOG.md +53 -2
- package/README.md +2 -2
- package/bin/aimhooman.mjs +9 -3
- package/docs/cli-reference.md +114 -0
- package/docs/faq.md +60 -0
- package/docs/integrations.md +108 -0
- package/docs/policy.md +82 -0
- package/docs/secrets.md +83 -0
- package/package.json +6 -1
- package/src/args.mjs +2 -7
- package/src/gitx.mjs +1 -13
- package/src/hook.mjs +14 -3
- package/src/report.mjs +5 -4
- package/src/scan-session.mjs +1 -1
- package/src/state.mjs +56 -16
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,54 @@ 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.3.1] - 2026-07-19
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `fix` on a strict policy no longer exits 11 (review required) when the
|
|
13
|
+
message holds a removable attribution line but the recheck of the cleaned
|
|
14
|
+
message stays incomplete; it exits 31, the same verdict `--apply` reaches
|
|
15
|
+
for the same content.
|
|
16
|
+
- `commitmsg` printed the blocking "scan incomplete … and retry" framing for
|
|
17
|
+
a tree scan that stopped at exit 11 on the compliance profile; the framing
|
|
18
|
+
now follows the profile, matching `check` and `fix`.
|
|
19
|
+
- `loadOverrides` burned the full lock retry budget (~50 × 10 ms) when a
|
|
20
|
+
caller already held `overrides.json.lock` (every `allow`/`deny`/`override`/
|
|
21
|
+
`review`/`policy-review` write); the warn-once migration persist now takes
|
|
22
|
+
one lock attempt and skips quietly when the lock is held.
|
|
23
|
+
- Two-arg `loadConfig(stateDir, root)` dropped the per-clone `gitignore`
|
|
24
|
+
record whenever a project policy owned the profile; the record is now read
|
|
25
|
+
best effort from config.json.
|
|
26
|
+
- Session-start hook: a failed `.git/info/exclude` refresh no longer skips
|
|
27
|
+
the worktree `.gitignore` refresh; each has its own silent failure, like
|
|
28
|
+
the pre-tool-use path.
|
|
29
|
+
- `uninstall` now also sweeps the worktree `.gitignore` lock queue left by
|
|
30
|
+
`init --gitignore`; the sweep previously covered only the three `.git`-side
|
|
31
|
+
queues.
|
|
32
|
+
- `action.yml` gained a `version` input (default `latest`) so the installed
|
|
33
|
+
CLI can be pinned (pinning the action tag never pinned the CLI), and its
|
|
34
|
+
`actions/setup-node` step is pinned to the same SHA the test workflow uses.
|
|
35
|
+
- The tarball now ships `docs/secrets.md`, `docs/policy.md`,
|
|
36
|
+
`docs/cli-reference.md`, `docs/faq.md`, and `docs/integrations.md`; links
|
|
37
|
+
to them from the README and the catalog dangled inside node_modules.
|
|
38
|
+
- Documentation and comment accuracy: the Cursor manifest description and the
|
|
39
|
+
usage and integrations exit-code lines no longer mention secret scanning
|
|
40
|
+
and now name the strict/final-guard scope of exit 31; the README flowchart
|
|
41
|
+
no longer implies an incomplete scan blocks pre-commit on every profile;
|
|
42
|
+
`docs/policy.md` states correctly that strict turns policy-file and
|
|
43
|
+
agent-instruction findings into blocks and that only the policy
|
|
44
|
+
downgrade/removal block is allow-proof; the 0.3.0 binary bullet, the FAQ
|
|
45
|
+
budget answer, and two stale comments now describe what the code does.
|
|
46
|
+
|
|
47
|
+
### Removed
|
|
48
|
+
|
|
49
|
+
- Dead code: the unreachable `release` branch of
|
|
50
|
+
`scripts/scan-ci-history.mjs` (no workflow sets that context) along with
|
|
51
|
+
`selectReleaseBase` and the `RELEASE_ENVIRONMENT_VERIFIED` contract; the
|
|
52
|
+
never-produced `object-read-failed` incomplete reason; the callerless
|
|
53
|
+
`repeatable` option in the argument parser; and eight unused `node:fs`
|
|
54
|
+
imports in `src/gitx.mjs`.
|
|
55
|
+
|
|
8
56
|
## [0.3.0] - 2026-07-19
|
|
9
57
|
|
|
10
58
|
This release narrows aimhooman to the job it does better than anyone else:
|
|
@@ -30,8 +78,11 @@ reference-transaction guard keeps its veto on every profile.
|
|
|
30
78
|
`overrides.json` are dropped on load with a warning naming the file.
|
|
31
79
|
A secret already in history needs rotation and a gitleaks run, not
|
|
32
80
|
aimhooman.
|
|
33
|
-
-
|
|
34
|
-
|
|
81
|
+
- In-budget binary blobs are no longer content-scanned: v0.2.0 read their
|
|
82
|
+
bytes for secrets, and with the scanner gone they now skip as binary. The
|
|
83
|
+
oversized-file probe is unchanged — binary up to 16 MiB still skips
|
|
84
|
+
complete, and binary over it is still a size-limit skip that leaves the
|
|
85
|
+
scan incomplete.
|
|
35
86
|
|
|
36
87
|
### Added
|
|
37
88
|
|
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.3.
|
|
12
|
+
<img src="https://img.shields.io/badge/version-v0.3.1-blue" alt="v0.3.1">
|
|
13
13
|
<img src="https://img.shields.io/badge/node-%E2%89%A522.8-339933?logo=node.js&logoColor=white" alt="Node 22.8+">
|
|
14
14
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-MIT-111111" alt="MIT">
|
|
@@ -52,7 +52,7 @@ flowchart TD
|
|
|
52
52
|
COMMIT([Ordinary git commit or merge]) --> PRE["pre-commit / pre-merge-commit<br/>run predecessor, resolve staged policy,<br/>scan exact index"]
|
|
53
53
|
DIRECT([Sequencer or direct ref path<br/>cherry-pick · rebase · fetch · worktree · update-ref]) --> REF
|
|
54
54
|
PRE -->|clean: safe repair| MSG["commit-msg snapshots would-be tree,<br/>runs predecessor, then checks<br/>the message and pinned full tree"]
|
|
55
|
-
PRE -->|strict violation
|
|
55
|
+
PRE -->|strict violation or failed repair| BLOCK([Operation stops])
|
|
56
56
|
MSG -->|message and tree accepted| REF["reference-transaction prepared<br/>scans what each introduced commit changes<br/>(messages of locally authored commits only)"]
|
|
57
57
|
MSG -->|unsafe or unrepairable| BLOCK
|
|
58
58
|
REF -->|accepted| SHIP([Ref update commits])
|
package/bin/aimhooman.mjs
CHANGED
|
@@ -611,7 +611,7 @@ function cmdCommitmsg(args) {
|
|
|
611
611
|
const treeCode = exitCode(treeScan.findings, treeScan.profile, treeScan.complete);
|
|
612
612
|
if (treeCode !== 0) {
|
|
613
613
|
if (treeScan.findings.length) process.stderr.write(human(treeScan.findings, tone()));
|
|
614
|
-
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan));
|
|
614
|
+
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan, { blocking: treeScan.profile === 'strict' }));
|
|
615
615
|
return treeCode;
|
|
616
616
|
}
|
|
617
617
|
// exitCode passes an incomplete tree scan on frictionless profiles; the
|
|
@@ -1645,9 +1645,13 @@ function cmdFix(args) {
|
|
|
1645
1645
|
? `aimhooman: strict policy would remove ${repair.removed.length} exact attribution line(s); rerun with --apply to write the repair`
|
|
1646
1646
|
: 'aimhooman: strict policy found no automatically repairable attribution lines'
|
|
1647
1647
|
);
|
|
1648
|
+
// 11 asks a human to review the lines a repair would remove, which is
|
|
1649
|
+
// only honest when the recheck saw the whole message. An incomplete
|
|
1650
|
+
// recheck on strict fails closed (31) — the same verdict --apply
|
|
1651
|
+
// reaches below for the same content.
|
|
1648
1652
|
return remaining.some((finding) => finding.decision === 'block')
|
|
1649
1653
|
? 10
|
|
1650
|
-
: repair.removed.length ? 11 : exitCode(remaining, scan.profile, remainingComplete);
|
|
1654
|
+
: repair.removed.length && remainingComplete ? 11 : exitCode(remaining, scan.profile, remainingComplete);
|
|
1651
1655
|
}
|
|
1652
1656
|
} else if (options.apply) {
|
|
1653
1657
|
throw new ArgumentError('--apply is only needed when the active profile is strict');
|
|
@@ -1975,6 +1979,7 @@ function cmdUninstall(args) {
|
|
|
1975
1979
|
for (const queue of [
|
|
1976
1980
|
`${lifecycleLock}.queue`,
|
|
1977
1981
|
`${repo.excludeFile}.aimhooman.lock.queue`,
|
|
1982
|
+
`${join(repo.root, '.gitignore')}.aimhooman.lock.queue`,
|
|
1978
1983
|
join(effectiveHooksDir(repo), '.aimhooman-hooks.lock.queue'),
|
|
1979
1984
|
]) {
|
|
1980
1985
|
try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
|
|
@@ -2030,7 +2035,8 @@ Usage:
|
|
|
2030
2035
|
aimhooman version
|
|
2031
2036
|
|
|
2032
2037
|
Exit codes: 0 clean, 10 blocked, 11 review required, 20 invalid input or policy,
|
|
2033
|
-
30 Git or I/O failure, 31 incomplete scan
|
|
2038
|
+
30 Git or I/O failure, 31 incomplete scan on strict or at the final ref guard
|
|
2039
|
+
(clean/compliance warn and continue).
|
|
2034
2040
|
`);
|
|
2035
2041
|
}
|
|
2036
2042
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# CLI reference
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
aimhooman init | status | check | audit | scan | explain | allow | deny | override | review | policy-review | fix | doctor | uninstall | version
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
## Install and init
|
|
8
|
+
|
|
9
|
+
Node 22.8+ and Git 2.28+, zero runtime dependencies, ships as source. Git 2.28
|
|
10
|
+
is required for the prepared-phase reference transaction guard that checks
|
|
11
|
+
cherry-pick, revert, rebase, `git am`, and other ref-producing flows.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g @rmyndharis/aimhooman
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Guard a repository:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
aimhooman init # git hooks + local excludes; normally no worktree files
|
|
21
|
+
aimhooman init --gitignore # also write the managed AI-artifact block to the worktree .gitignore
|
|
22
|
+
aimhooman status
|
|
23
|
+
aimhooman uninstall # restore hooks/excludes; keep local policy state
|
|
24
|
+
aimhooman uninstall --purge-state # also delete common Git-directory state
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`init --gitignore` is the opt-in for teams that want the ignore set committed:
|
|
28
|
+
it writes the same managed pattern block into the worktree `.gitignore`,
|
|
29
|
+
records the choice in the local config, and prints a notice to commit the file
|
|
30
|
+
so every clone shares it. `status` shows the recorded choice, and `uninstall`
|
|
31
|
+
removes the block (and the file, if aimhooman created it and nothing else
|
|
32
|
+
remains). The default stays local: `.git/info/exclude` only.
|
|
33
|
+
|
|
34
|
+
Two honest edges. `uninstall` deletes the `.gitignore` itself only when
|
|
35
|
+
aimhooman created it and the removed block leaves it empty — if you committed
|
|
36
|
+
the file, that deletion is a tracked worktree deletion you then commit or
|
|
37
|
+
restore. And the opt-in record lives in the common Git directory, so linked
|
|
38
|
+
worktrees share the choice while each worktree keeps its own `.gitignore`.
|
|
39
|
+
|
|
40
|
+
For commits you make at the terminal (outside your AI tool), one global setup guards
|
|
41
|
+
eligible non-bare repositories that do not override `core.hooksPath` locally:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
aimhooman init --global --yes # advanced: change core.hooksPath after confirmation
|
|
45
|
+
aimhooman uninstall --global # unset it
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Global `core.hooksPath` changes Git behavior for repositories that inherit it and can
|
|
49
|
+
replace their default hook directory. A local or worktree-scoped override takes
|
|
50
|
+
precedence. Bare repositories are outside the worktree/index policy boundary and the
|
|
51
|
+
global dispatchers leave them unchanged. `status` shows both local and global values.
|
|
52
|
+
Prefer repository `init` unless the global ordering is understood.
|
|
53
|
+
|
|
54
|
+
When `core.hooksPath` is set, Git reads hooks only from that effective directory and
|
|
55
|
+
ignores `.git/hooks`. Repository `init` installs and chains predecessors only when
|
|
56
|
+
that directory is absent or is proven to be owned by the repository: inside it and
|
|
57
|
+
not tracked by Git. It refuses to modify a global, shared, external, or tracked
|
|
58
|
+
hook directory, because a dispatcher committed from one machine names paths that
|
|
59
|
+
exist only on that machine. Those repositories are not guarded, and there is no
|
|
60
|
+
way to guard them today. Calling `aimhooman precommit` from an existing hook
|
|
61
|
+
manager runs the check but registers no managed guard, so the agent hook still
|
|
62
|
+
refuses the commit. Remove the override before retrying, or accept that the
|
|
63
|
+
repository is unguarded and do not run `init` there.
|
|
64
|
+
|
|
65
|
+
Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`, and
|
|
66
|
+
`reference-transaction`, and preserves an existing hook as a predecessor. For
|
|
67
|
+
`commit-msg`, aimhooman pins the would-be tree before the predecessor runs, so a later
|
|
68
|
+
index change cannot select a weaker policy. The prepared reference transaction is the
|
|
69
|
+
last local check for cherry-pick, revert, rebase, `git am`, fetch/worktree branch
|
|
70
|
+
creation, and direct branch-ref updates. Every profile stops if a predecessor removes
|
|
71
|
+
a required guard; the first running dispatcher that detects the loss aborts the
|
|
72
|
+
operation.
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
`check` accepts one Git target (`--staged`, `--tracked`, `--commit <rev>`, or
|
|
77
|
+
`--range <base>...<head>`), plus `--message <file>`, `--profile`, and `--json`.
|
|
78
|
+
Commit and range targets read commit messages from Git automatically. A range scans each
|
|
79
|
+
introduced commit, so a bad file added and deleted before the endpoint is still reported.
|
|
80
|
+
Use an all-zero object ID as the base when there is no prior commit; this includes the root
|
|
81
|
+
commit. Deleting an ordinary forbidden path is not itself a finding, while removing or
|
|
82
|
+
lowering a versioned strict project policy still needs a bound policy review.
|
|
83
|
+
`audit` and `scan` are aliases for a full tracked-index scan.
|
|
84
|
+
`init --global --yes` and `uninstall --global` manage the advanced terminal-Git guard;
|
|
85
|
+
`uninstall --global` cannot be combined with the local `--purge-state` option.
|
|
86
|
+
`fix` follows the active profile: clean writes an exact safe repair, compliance makes no
|
|
87
|
+
change, and strict previews unless `--apply` is supplied.
|
|
88
|
+
`allow` resolves a finding by path or rule ID; an allow entry's scope is `path` or
|
|
89
|
+
`rule` (see [docs/policy.md](policy.md#overrides)).
|
|
90
|
+
|
|
91
|
+
On `clean` and `compliance`, `check`, `fix`, and the commit hooks warn and continue
|
|
92
|
+
when a scan is incomplete; `strict` exits 31 instead. The final ref guard
|
|
93
|
+
(`reference-transaction`) stays fail-closed on every profile and still vetoes what
|
|
94
|
+
it cannot fully scan.
|
|
95
|
+
|
|
96
|
+
Machine reports use `schema_version: 1` and include target policy identity, completeness,
|
|
97
|
+
scan statistics, commit and object metadata. Schemas are published in [`schemas/`](../schemas/).
|
|
98
|
+
|
|
99
|
+
For an existing repository, start with `aimhooman audit --json`. If a residue path is
|
|
100
|
+
already tracked, remove it from the index with `git rm --cached <path>` and add an
|
|
101
|
+
appropriate ignore/exclude. aimhooman does not scan for secrets; if one was committed,
|
|
102
|
+
rotate it first — [docs/secrets.md](secrets.md) covers why and what to run instead.
|
|
103
|
+
History cleanup is deliberately outside aimhooman's scope.
|
|
104
|
+
|
|
105
|
+
## Exit codes
|
|
106
|
+
|
|
107
|
+
| Code | Meaning |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| 0 | clean, or non-blocking review |
|
|
110
|
+
| 10 | policy violation (block) |
|
|
111
|
+
| 11 | review-required on a non-clean profile |
|
|
112
|
+
| 20 | usage, configuration, or rule-pack error |
|
|
113
|
+
| 30 | Git or I/O error |
|
|
114
|
+
| 31 | scan incomplete on `strict` or at the final ref guard (`clean`/`compliance` warn and continue) |
|
package/docs/faq.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# FAQ
|
|
2
|
+
|
|
3
|
+
**Is this a way to hide AI use?** No. aimhooman removes operational residue and
|
|
4
|
+
establishes human ownership. It never changes author, committer, signature, or
|
|
5
|
+
timestamp, and the compliance profile keeps any disclosure your policy requires.
|
|
6
|
+
|
|
7
|
+
**Will it cancel my commits?** On the default `clean` profile, aimhooman first tries to
|
|
8
|
+
exclude or unstage hygiene artifacts and safely remove exact attribution lines. The
|
|
9
|
+
commit proceeds if no block remains; a scan that runs over budget warns and continues.
|
|
10
|
+
A pre-existing tracked block, failed unstage/repair, or unterminated exact attribution
|
|
11
|
+
still stops the operation, and the final `reference-transaction` guard vetoes a commit
|
|
12
|
+
it cannot fully scan, on every profile. `strict` cancels findings instead of repairing,
|
|
13
|
+
and treats an incomplete scan as a stop.
|
|
14
|
+
|
|
15
|
+
**Does it slow commits down?** The staged check runs locally with no network and reads
|
|
16
|
+
Git objects in batches. Text-oriented rules skip binary files. Size and total budgets
|
|
17
|
+
are visible in reports. Files over 2 MiB or a scan over 64 MiB make the scan
|
|
18
|
+
incomplete (binary files skip complete; oversized text is what trips it): direct
|
|
19
|
+
checks and the Git pre-commit guards warn on `clean`/`compliance` and stop on
|
|
20
|
+
`strict`, and the final ref guard stops on every profile rather than
|
|
21
|
+
claiming that content was checked.
|
|
22
|
+
|
|
23
|
+
**Can the agent bypass it?** Any local tool can ultimately be bypassed by a user with
|
|
24
|
+
commit access. The agent guard fails closed on what it cannot prove: empty, invalid,
|
|
25
|
+
or non-object hook JSON, missing managed final guards, and hook or receive-pack
|
|
26
|
+
indirection around protected ref mutations are denied on every profile, and `strict`
|
|
27
|
+
additionally rejects `--no-verify`, unknown executor shapes, and uncertain commit
|
|
28
|
+
execution. Read-only commands and pipelines run; the guard stands between the agent
|
|
29
|
+
and protected Git mutations, not between you and reading a repository. Git hooks are
|
|
30
|
+
not a sandbox: an editor or another local program started during a commit has the same
|
|
31
|
+
filesystem access and can change a later hook. Wrappers that can select another cwd or
|
|
32
|
+
filesystem namespace (`sudo`, `chroot`, WSL, sandbox launchers) fail closed on every
|
|
33
|
+
profile; retry as a direct Git command from the target repository. Treat local
|
|
34
|
+
executables and Git config as trusted. For team enforcement, scan the actual PR range
|
|
35
|
+
in CI (a normal CI checkout has no staged changes):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
git fetch origin main
|
|
39
|
+
aimhooman check --range origin/main...HEAD --profile strict
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
On GitHub Actions, configure `actions/checkout` with `fetch-depth: 0` so the
|
|
43
|
+
triple-dot merge base is available.
|
|
44
|
+
|
|
45
|
+
`pre-commit` and `commit-msg` do not cover every sequencer or ref movement. The managed
|
|
46
|
+
`reference-transaction` hook therefore checks introduced commits during Git's prepared
|
|
47
|
+
phase and can abort the local ref update. Git 2.54's earlier `preparing` callback is
|
|
48
|
+
accepted for compatibility and checks guard integrity; scanning remains in `prepared`,
|
|
49
|
+
after references are locked.
|
|
50
|
+
CI still scans the exact pushed or PR history:
|
|
51
|
+
local hooks do not govern another clone, server-side updates, or history created before
|
|
52
|
+
the guard was installed.
|
|
53
|
+
Bare repositories have no worktree/index boundary and are not supported by local commands.
|
|
54
|
+
A submodule is a separate repository with separate state and hooks; run `aimhooman init`
|
|
55
|
+
inside each submodule that needs local enforcement.
|
|
56
|
+
|
|
57
|
+
**What about secrets?** aimhooman does not scan for secrets since v0.3.0 and never
|
|
58
|
+
did history cleanup. A committed credential is exposed: rotate it, then run a
|
|
59
|
+
dedicated scanner over the repository. [docs/secrets.md](secrets.md) explains the
|
|
60
|
+
reasoning and shows the gitleaks setup we recommend.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Integrations
|
|
2
|
+
|
|
3
|
+
aimhooman runs wherever commits get checked: as a pre-commit.com hook, as a
|
|
4
|
+
GitHub Action, and next to other hook tooling. This page shows each setup and
|
|
5
|
+
what it does and does not cover.
|
|
6
|
+
|
|
7
|
+
## pre-commit.com
|
|
8
|
+
|
|
9
|
+
The repository ships a `.pre-commit-hooks.yaml`, so pre-commit installs
|
|
10
|
+
aimhooman straight from Git. Add this to `.pre-commit-config.yaml` and run
|
|
11
|
+
`pre-commit install`:
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
repos:
|
|
15
|
+
- repo: https://github.com/rmyndharis/aimhooman
|
|
16
|
+
rev: v0.3.0
|
|
17
|
+
hooks:
|
|
18
|
+
- id: aimhooman
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
pre-commit npm-installs the package at the pinned revision and runs
|
|
22
|
+
`aimhooman check --staged` on every commit. Pin `rev` to a tag and bump it
|
|
23
|
+
deliberately.
|
|
24
|
+
|
|
25
|
+
The hook checks the staged tree, so it passes no filenames. Any non-zero
|
|
26
|
+
exit fails it: 10 for a policy violation, 11 for a review-required finding on
|
|
27
|
+
a non-clean profile, 20 for a usage or configuration error, 30 for a Git or
|
|
28
|
+
I/O error, 31 when a scan budget leaves the scan incomplete on `strict` or at
|
|
29
|
+
the final ref guard (`clean`/`compliance` warn and continue). On the default
|
|
30
|
+
`clean` profile a review-only finding exits 0, so reviews alone do not fail
|
|
31
|
+
the hook. Blocks always do.
|
|
32
|
+
|
|
33
|
+
This hook is the CLI check only. It does not install aimhooman's managed Git
|
|
34
|
+
hooks or the agent guard; `aimhooman init` in the repository adds those.
|
|
35
|
+
|
|
36
|
+
## GitHub Actions
|
|
37
|
+
|
|
38
|
+
The repository root has an `action.yml`, so a workflow can scan the exact
|
|
39
|
+
pull request range:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# .github/workflows/aimhooman.yml
|
|
43
|
+
name: aimhooman
|
|
44
|
+
on: [pull_request]
|
|
45
|
+
|
|
46
|
+
jobs:
|
|
47
|
+
check:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/checkout@v4
|
|
51
|
+
with:
|
|
52
|
+
fetch-depth: 0
|
|
53
|
+
- uses: rmyndharis/aimhooman@v0.3.0
|
|
54
|
+
with:
|
|
55
|
+
base: ${{ github.event.pull_request.base.sha }}
|
|
56
|
+
head: ${{ github.event.pull_request.head.sha }}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`fetch-depth: 0` is required. The range is triple-dot, so the scan starts at
|
|
60
|
+
the merge base of the two commits, and a shallow checkout does not have that
|
|
61
|
+
commit. The action installs aimhooman from npm and defaults to the `strict`
|
|
62
|
+
profile; set `profile:` under `with:` to change that.
|
|
63
|
+
|
|
64
|
+
Pinning the action tag (`@v0.3.0`) pins only the action's steps, not the CLI:
|
|
65
|
+
the action npm-installs aimhooman at run time, and the `version` input
|
|
66
|
+
defaults to `latest`. Set `version:` under `with:` (for example
|
|
67
|
+
`version: 0.3.0`) to pin the CLI itself.
|
|
68
|
+
|
|
69
|
+
For `push` events, use `base: ${{ github.event.before }}` and
|
|
70
|
+
`head: ${{ github.event.after }}`. On the first push to a branch `before` is
|
|
71
|
+
all zeros; `aimhooman check --range` accepts an all-zero base and includes
|
|
72
|
+
the root commit in the scan.
|
|
73
|
+
|
|
74
|
+
CI is the enforcement tier that travels with the repository: local hooks do
|
|
75
|
+
not govern another clone, and a laptop can skip its own hooks. The action
|
|
76
|
+
cannot.
|
|
77
|
+
|
|
78
|
+
## Secrets
|
|
79
|
+
|
|
80
|
+
aimhooman does not scan for secrets. Pair it with
|
|
81
|
+
[gitleaks](https://github.com/gitleaks/gitleaks), either as a pre-commit.com
|
|
82
|
+
hook or as a second CI job next to the action above.
|
|
83
|
+
[docs/secrets.md](secrets.md) explains why the built-in scanner was removed
|
|
84
|
+
and shows both gitleaks setups.
|
|
85
|
+
|
|
86
|
+
## husky
|
|
87
|
+
|
|
88
|
+
husky sets `core.hooksPath` to `.husky`. `aimhooman init` refuses to install
|
|
89
|
+
into or over an external or shared hooks directory by design: `.husky` is
|
|
90
|
+
tracked repository content, and a dispatcher written there would stage this
|
|
91
|
+
machine's absolute CLI and Node paths for everyone who clones
|
|
92
|
+
(`src/githooks.mjs` has the full rule set). Two ways out:
|
|
93
|
+
|
|
94
|
+
- Keep husky and call `aimhooman check --staged` from `.husky/pre-commit`.
|
|
95
|
+
You get the CLI check only. The agent-tier guard (PreToolUse) asks for
|
|
96
|
+
aimhooman's own managed hooks and refuses guarded commits without them, so
|
|
97
|
+
agent-driven commits stay unguarded in this setup.
|
|
98
|
+
- Drop husky for the guarded hooks and let `aimhooman init` manage them.
|
|
99
|
+
Existing hooks are not lost: init preserves each one as a chained
|
|
100
|
+
predecessor, and the dispatcher runs it before its own check.
|
|
101
|
+
|
|
102
|
+
## lint-staged
|
|
103
|
+
|
|
104
|
+
lint-staged builds a list of staged files and runs one command per batch.
|
|
105
|
+
aimhooman does not fit that shape: it reads the whole index and commit from
|
|
106
|
+
Git and takes no file list, so a per-batch invocation would check the same
|
|
107
|
+
index several times. Run it at hook level instead, through its own managed
|
|
108
|
+
hooks or the pre-commit.com setup above.
|
package/docs/policy.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Team policy and overrides
|
|
2
|
+
|
|
3
|
+
aimhooman's per-clone defaults serve a solo developer. This page covers the
|
|
4
|
+
team layer: a versioned policy file, owner-authorized changes to protected
|
|
5
|
+
paths in CI, local overrides, and local rule packs.
|
|
6
|
+
|
|
7
|
+
## Versioned team policy
|
|
8
|
+
|
|
9
|
+
Commit `.aimhooman.json` when every clone should use the same baseline:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"schema_version": 1,
|
|
14
|
+
"profile": "strict"
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The project policy takes precedence over the per-clone profile written by `init`.
|
|
19
|
+
An individual `check` may escalate to `--profile strict`, but cannot weaken or replace
|
|
20
|
+
the team profile. Malformed project policy fails closed with an actionable error;
|
|
21
|
+
personal allow/deny exceptions and local rule packs remain in the common Git directory
|
|
22
|
+
under `aimhooman/`.
|
|
23
|
+
Under `strict`, policy files and agent instructions are blocking findings (exit 10):
|
|
24
|
+
both rules' strict action is `block`, not review. The product's `review` and
|
|
25
|
+
`policy-review` commands record local, object-bound decisions. An ordinary path or
|
|
26
|
+
rule allow does satisfy those engine findings; the one finding an allow cannot
|
|
27
|
+
satisfy is the strict-policy downgrade-or-removal block, which is constructed
|
|
28
|
+
outside the engine and clears only through a bound `policy-review` acknowledgment.
|
|
29
|
+
|
|
30
|
+
### Owner authorization in CI
|
|
31
|
+
|
|
32
|
+
For a protected-path change, CI verifies the pinned repository and owner login plus
|
|
33
|
+
numeric IDs through the GitHub API, then inspects the exact workflow-run attempt.
|
|
34
|
+
GitHub must attribute both `actor` and `triggering_actor`, including their numeric IDs,
|
|
35
|
+
to that owner. CI then binds the authorization to the exact head, transition commit,
|
|
36
|
+
path, resulting blob and regular-file mode, or deletion tombstone. A strict-policy
|
|
37
|
+
migration also binds its old and new policy objects. A different attempt, commit, path
|
|
38
|
+
result, mode, or policy transition needs fresh authorization. A change not attributed to
|
|
39
|
+
the owner fails closed. This is owner authorization verified through GitHub attribution,
|
|
40
|
+
not independent review.
|
|
41
|
+
|
|
42
|
+
## Overrides
|
|
43
|
+
|
|
44
|
+
Every decision has a rule ID, so you can resolve a finding narrowly:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
aimhooman allow AGENTS.md --reason "shared team config" # stop flagging this path
|
|
48
|
+
aimhooman deny path/or/rule-id # always block it
|
|
49
|
+
aimhooman explain claude.session-state # why a rule fires
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Overrides live in the repository's common Git directory under
|
|
53
|
+
`aimhooman/overrides.json` (local, never committed), so linked worktrees share them.
|
|
54
|
+
An allow entry's scope is `path` or `rule`.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
aimhooman override list --json
|
|
58
|
+
aimhooman override remove AGENTS.md
|
|
59
|
+
aimhooman override reset --all
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Secret scanning left the product in v0.3.0, and the `secret-path` allow scope went
|
|
63
|
+
with it: existing `secret-path` entries are dropped on load with a warning, and the
|
|
64
|
+
rest of the file keeps working. Use a dedicated scanner for credentials instead —
|
|
65
|
+
[docs/secrets.md](secrets.md) explains the reasoning and shows the gitleaks setup.
|
|
66
|
+
|
|
67
|
+
## Local rule packs
|
|
68
|
+
|
|
69
|
+
Add your own per-repository detection with local rule packs in the common Git directory
|
|
70
|
+
under `aimhooman/rules/*.json`
|
|
71
|
+
(the structural schema is in [`schemas/rule-pack.schema.json`](../schemas/rule-pack.schema.json);
|
|
72
|
+
local rules only add detection — they can't weaken
|
|
73
|
+
a built-in block). Within one rule, local content patterns are capped at 32 expressions,
|
|
74
|
+
512 characters per expression, and 4,096 characters total. Path and exception scopes
|
|
75
|
+
share the same glob count, per-expression, and total limits. They use a flat subset: literals,
|
|
76
|
+
character classes, anchors, dot, escapes, and fixed `{n}` repeats. Groups, alternation,
|
|
77
|
+
lookaround, backreferences, and variable quantifiers are rejected. A local expression
|
|
78
|
+
does not run on a line longer than 16,384 characters; that skip is reported and makes the
|
|
79
|
+
scan incomplete. Path rules are case-sensitive by default. Set
|
|
80
|
+
`match.path_case` to `"insensitive"` only for a security name whose meaning is
|
|
81
|
+
case-insensitive, such as `.env`; matching folds that rule's candidate and patterns but
|
|
82
|
+
does not change the Git path or override identity.
|
package/docs/secrets.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Secrets and aimhooman
|
|
2
|
+
|
|
3
|
+
aimhooman v0.3.0 removed built-in secret scanning. This page explains why and
|
|
4
|
+
shows the setup we recommend instead.
|
|
5
|
+
|
|
6
|
+
## Why the built-in scanner went away
|
|
7
|
+
|
|
8
|
+
aimhooman does one job: it keeps AI tooling residue (session files, local
|
|
9
|
+
state, AI attribution) out of Git history. Secret scanning is a different
|
|
10
|
+
craft. A serious scanner tracks hundreds of credential formats, tunes for
|
|
11
|
+
entropy and false positives, and ships pattern updates on its own cadence.
|
|
12
|
+
aimhooman's eight rules could not keep up with that, and a scanner that only
|
|
13
|
+
sometimes works is worse than none: it reads as coverage it never had. Use a
|
|
14
|
+
tool that does secrets full-time. We recommend [gitleaks](https://github.com/gitleaks/gitleaks).
|
|
15
|
+
|
|
16
|
+
Two things do stay. The agent-facing policy still tells agents never to commit
|
|
17
|
+
secrets, because an instruction costs nothing. And a local rule pack can still
|
|
18
|
+
declare `category: "secret"`; findings from such a rule keep their matched
|
|
19
|
+
text redacted in every report.
|
|
20
|
+
|
|
21
|
+
## Recommended setup: gitleaks
|
|
22
|
+
|
|
23
|
+
### Pre-commit hook (pre-commit.com)
|
|
24
|
+
|
|
25
|
+
Add this to `.pre-commit-config.yaml` and run `pre-commit install`:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
repos:
|
|
29
|
+
- repo: https://github.com/gitleaks/gitleaks
|
|
30
|
+
rev: v8.30.1
|
|
31
|
+
hooks:
|
|
32
|
+
- id: gitleaks
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The hook scans staged changes and stops the commit when a credential shows
|
|
36
|
+
up. Pin `rev` and bump it deliberately.
|
|
37
|
+
|
|
38
|
+
### GitHub Action
|
|
39
|
+
|
|
40
|
+
Laptops bypass hooks; CI should not. Add a workflow step that scans pull
|
|
41
|
+
requests:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
# .github/workflows/gitleaks.yml
|
|
45
|
+
name: gitleaks
|
|
46
|
+
on: [push, pull_request]
|
|
47
|
+
jobs:
|
|
48
|
+
scan:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
with:
|
|
53
|
+
fetch-depth: 0
|
|
54
|
+
- uses: gitleaks/gitleaks-action@v2
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`fetch-depth: 0` gives the action the full history so a secret introduced
|
|
58
|
+
three commits back still gets caught.
|
|
59
|
+
|
|
60
|
+
### Running it alongside aimhooman
|
|
61
|
+
|
|
62
|
+
The two tools do not overlap and do not fight. aimhooman owns Git's
|
|
63
|
+
`pre-commit`, `commit-msg`, and `reference-transaction` hooks; gitleaks runs
|
|
64
|
+
as a pre-commit.com hook or in CI, so both fire on the same commit without
|
|
65
|
+
sharing state. A commit carrying an AI session file and an API key gets
|
|
66
|
+
stopped for both reasons, each reported by the tool that owns it. Keep
|
|
67
|
+
aimhooman for AI hygiene, keep gitleaks for secrets, and let each report in
|
|
68
|
+
its own words.
|
|
69
|
+
|
|
70
|
+
## Migrating from the built-in scanner
|
|
71
|
+
|
|
72
|
+
Two escape hatches left with the scanner:
|
|
73
|
+
|
|
74
|
+
- `aimhooman allow <path> --scope secret-path` is gone. Existing
|
|
75
|
+
`secret-path` entries in `overrides.json` are dropped on load with a
|
|
76
|
+
warning; the rest of the file keeps working.
|
|
77
|
+
- `aimhooman init --grandfather-secrets` is gone.
|
|
78
|
+
|
|
79
|
+
If secrets already sit in your history, aimhooman was never the fix and
|
|
80
|
+
removing it changes nothing about that: a committed credential is exposed.
|
|
81
|
+
Rotate it, then run `gitleaks git` (or `gitleaks detect`) over the repository
|
|
82
|
+
to find anything else that needs rotation. Scrubbing history is optional and
|
|
83
|
+
secondary; rotation is not.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
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": {
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
"docs/hosts.json",
|
|
24
24
|
"docs/ai-artifacts.gitignore",
|
|
25
25
|
"docs/catalog.md",
|
|
26
|
+
"docs/secrets.md",
|
|
27
|
+
"docs/policy.md",
|
|
28
|
+
"docs/cli-reference.md",
|
|
29
|
+
"docs/faq.md",
|
|
30
|
+
"docs/integrations.md",
|
|
26
31
|
"docs/design/",
|
|
27
32
|
"docs/logo/",
|
|
28
33
|
".claude-plugin/",
|
package/src/args.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function parseArguments(args, definition = {}) {
|
|
|
27
27
|
const inlineValue = equal > 0 ? argument.slice(equal + 1) : undefined;
|
|
28
28
|
const option = byName.get(name);
|
|
29
29
|
if (!option) throw new ArgumentError(`unknown option "${name}"`);
|
|
30
|
-
if (seen.has(option.key)
|
|
30
|
+
if (seen.has(option.key)) {
|
|
31
31
|
throw new ArgumentError(`option "${name}" may only be used once`);
|
|
32
32
|
}
|
|
33
33
|
seen.add(option.key);
|
|
@@ -49,12 +49,7 @@ export function parseArguments(args, definition = {}) {
|
|
|
49
49
|
if (option.choices && !option.choices.includes(value)) {
|
|
50
50
|
throw new ArgumentError(`invalid value for ${name}: "${value}"`);
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
if (!options[option.key]) options[option.key] = [];
|
|
54
|
-
options[option.key].push(value);
|
|
55
|
-
} else {
|
|
56
|
-
options[option.key] = value;
|
|
57
|
-
}
|
|
52
|
+
options[option.key] = value;
|
|
58
53
|
continue;
|
|
59
54
|
}
|
|
60
55
|
positionals.push(argument);
|
package/src/gitx.mjs
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
cpSync,
|
|
4
|
-
lstatSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
mkdtempSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
realpathSync,
|
|
9
|
-
readdirSync,
|
|
10
|
-
readlinkSync,
|
|
11
|
-
renameSync,
|
|
12
|
-
rmSync,
|
|
13
|
-
symlinkSync,
|
|
14
|
-
} from 'node:fs';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
15
3
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
16
4
|
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
17
5
|
|
package/src/hook.mjs
CHANGED
|
@@ -86,12 +86,23 @@ export function hookSessionStart() {
|
|
|
86
86
|
const policy = resolvePolicy(repo, { target: 'worktree' });
|
|
87
87
|
const { engine: eng } = newEngineWithDiagnostics(policy.profile, repo.stateDir);
|
|
88
88
|
const patterns = patternsForRules(eng.rules);
|
|
89
|
-
|
|
89
|
+
// Each refresh is housekeeping with its own silent failure, mirroring
|
|
90
|
+
// the pre-tool-use path: a read-only .git/info must not skip the
|
|
91
|
+
// worktree .gitignore refresh below, and neither may decide anything.
|
|
92
|
+
try {
|
|
93
|
+
applyExclude(repo.excludeFile, patterns);
|
|
94
|
+
} catch {
|
|
95
|
+
/* exclude refresh is best effort */
|
|
96
|
+
}
|
|
90
97
|
// A clone that opted into the committed variant gets the same refresh in
|
|
91
98
|
// its worktree .gitignore; every failure degrades silently, same as the
|
|
92
99
|
// exclude write above.
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
try {
|
|
101
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
102
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* gitignore refresh is best effort */
|
|
95
106
|
}
|
|
96
107
|
} catch {
|
|
97
108
|
/* not a repo; nothing to exclude */
|
package/src/report.mjs
CHANGED
|
@@ -34,10 +34,11 @@ export function human(findings, tone) {
|
|
|
34
34
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
35
35
|
}
|
|
36
36
|
// Render the whole remediation array, not just the first entry. Several
|
|
37
|
-
// rules carry a second line (e.g. "
|
|
38
|
-
// that the previous single-index render
|
|
39
|
-
// rule's fix once — a repeated rule
|
|
40
|
-
// used to reprint the identical fix
|
|
37
|
+
// rules carry a second line (e.g. "or unstage if it is personal" on
|
|
38
|
+
// generic.agent-instructions) that the previous single-index render
|
|
39
|
+
// dropped silently. UT-08: print a rule's fix once — a repeated rule
|
|
40
|
+
// (20 hits of the same path rule) used to reprint the identical fix
|
|
41
|
+
// block for every finding.
|
|
41
42
|
const remedies = f.remediation || [];
|
|
42
43
|
if (remedies.length && fixesPrinted.has(f.ruleId)) {
|
|
43
44
|
out += ` fix: as above for ${f.ruleId}\n`;
|
package/src/scan-session.mjs
CHANGED
|
@@ -132,7 +132,7 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
const incompleteReasons = new Set([
|
|
135
|
-
'metadata-unavailable', 'size-limit', 'total-byte-limit',
|
|
135
|
+
'metadata-unavailable', 'size-limit', 'total-byte-limit',
|
|
136
136
|
'missing-object', 'unexpected-object', 'finding-limit', 'local-input-limit',
|
|
137
137
|
]);
|
|
138
138
|
const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
|
package/src/state.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
readFileSync,
|
|
5
5
|
} from 'node:fs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
|
-
import { atomicWrite } from './atomic-write.mjs';
|
|
7
|
+
import { atomicWrite, withLock } from './atomic-write.mjs';
|
|
8
8
|
import { normalizeGitPath } from './git-path.mjs';
|
|
9
9
|
|
|
10
10
|
// Per-repository state, stored in the common Git dir (never the worktree).
|
|
@@ -105,8 +105,20 @@ export function parseProjectPolicy(text, file = '.aimhooman.json') {
|
|
|
105
105
|
|
|
106
106
|
export function loadConfig(stateDir, root) {
|
|
107
107
|
const project = loadProjectPolicy(root);
|
|
108
|
-
if (project) return { profile: project.profile, source: 'project', file: project.file };
|
|
109
108
|
const file = join(stateDir, 'config.json');
|
|
109
|
+
if (project) {
|
|
110
|
+
// The project policy owns the profile, but the --gitignore opt-in stays
|
|
111
|
+
// per-clone state in config.json; a two-arg caller still needs it. The
|
|
112
|
+
// profile no longer depends on that file here, so a missing or corrupt
|
|
113
|
+
// one just omits the record instead of failing the load.
|
|
114
|
+
const gitignore = readGitignoreRecord(file);
|
|
115
|
+
return {
|
|
116
|
+
profile: project.profile,
|
|
117
|
+
source: 'project',
|
|
118
|
+
file: project.file,
|
|
119
|
+
...(gitignore ? { gitignore } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
110
122
|
let text;
|
|
111
123
|
try {
|
|
112
124
|
text = readFileSync(file, 'utf8');
|
|
@@ -129,6 +141,17 @@ export function loadConfig(stateDir, root) {
|
|
|
129
141
|
};
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
// Best-effort read of the per-clone gitignore record for the project-policy
|
|
145
|
+
// branch above: any failure (absent file, invalid JSON, invalid shape) means
|
|
146
|
+
// no record, never a failed load.
|
|
147
|
+
function readGitignoreRecord(file) {
|
|
148
|
+
try {
|
|
149
|
+
return normalizeLocalConfig(JSON.parse(stripBom(readFileSync(file, 'utf8'))), file).gitignore;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
export function saveConfig(stateDir, config) {
|
|
133
156
|
const file = join(stateDir, 'config.json');
|
|
134
157
|
const normalized = normalizeLocalConfig(config, file);
|
|
@@ -193,7 +216,28 @@ export function loadOverrides(stateDir) {
|
|
|
193
216
|
if (error?.code === 'ENOENT') return { allow: [], deny: [] };
|
|
194
217
|
throw new LocalOverridesError(file, `cannot read file: ${error.message}`, error);
|
|
195
218
|
}
|
|
196
|
-
|
|
219
|
+
const dropped = { count: 0 };
|
|
220
|
+
const overrides = parseOverrides(text, file, dropped);
|
|
221
|
+
if (dropped.count) {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`aimhooman: warning: ${file}: dropped ${dropped.count} override(s) with retired scope "secret-path"; built-in secret scanning was removed in v0.3.0\n`
|
|
224
|
+
);
|
|
225
|
+
// Persist the cleaned file so the migration warns once, not on every
|
|
226
|
+
// command. Re-read under the lock so a concurrent allow/deny write is
|
|
227
|
+
// never clobbered; a failed rewrite only means the warning returns
|
|
228
|
+
// next run, so this stays best effort. One attempt, no queue wait:
|
|
229
|
+
// allow/deny/override/review/policy-review call loadOverrides while
|
|
230
|
+
// already holding this same lock, and the bakery queue would park the
|
|
231
|
+
// inner candidate behind the outer one for the full retry budget
|
|
232
|
+
// before the catch below swallowed the throw.
|
|
233
|
+
try {
|
|
234
|
+
withLock(`${file}.lock`, () => {
|
|
235
|
+
const fresh = parseOverrides(readFileSync(file, 'utf8'), file);
|
|
236
|
+
atomicWriteJson(file, { schema_version: 1, ...fresh });
|
|
237
|
+
}, { retries: 1 });
|
|
238
|
+
} catch { /* best effort */ }
|
|
239
|
+
}
|
|
240
|
+
return overrides;
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
export function saveOverrides(stateDir, overrides) {
|
|
@@ -207,17 +251,17 @@ export function normalizeOverrideTarget(target) {
|
|
|
207
251
|
return normalizeGitPath(target);
|
|
208
252
|
}
|
|
209
253
|
|
|
210
|
-
function parseOverrides(text, file) {
|
|
254
|
+
function parseOverrides(text, file, dropped = { count: 0 }) {
|
|
211
255
|
let value;
|
|
212
256
|
try {
|
|
213
257
|
value = JSON.parse(stripBom(text));
|
|
214
258
|
} catch (error) {
|
|
215
259
|
throw new LocalOverridesError(file, `invalid JSON: ${error.message}`, error);
|
|
216
260
|
}
|
|
217
|
-
return normalizeOverrides(value, file);
|
|
261
|
+
return normalizeOverrides(value, file, dropped);
|
|
218
262
|
}
|
|
219
263
|
|
|
220
|
-
function normalizeOverrides(value, file) {
|
|
264
|
+
function normalizeOverrides(value, file, dropped = { count: 0 }) {
|
|
221
265
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
222
266
|
throw new LocalOverridesError(file, 'root must be a JSON object');
|
|
223
267
|
}
|
|
@@ -230,17 +274,13 @@ function normalizeOverrides(value, file) {
|
|
|
230
274
|
if (value.schema_version !== undefined && value.schema_version !== 1) {
|
|
231
275
|
throw new LocalOverridesError(file, 'schema_version must be 1');
|
|
232
276
|
}
|
|
233
|
-
|
|
234
|
-
|
|
277
|
+
// The caller owns the drop count: loadOverrides warns and persists the
|
|
278
|
+
// cleaned file, saveOverrides drops retired entries silently on the next
|
|
279
|
+
// write.
|
|
280
|
+
return {
|
|
235
281
|
allow: overrideEntries(value.allow, 'allow', file, dropped),
|
|
236
282
|
deny: overrideEntries(value.deny, 'deny', file, dropped),
|
|
237
283
|
};
|
|
238
|
-
if (dropped.count) {
|
|
239
|
-
process.stderr.write(
|
|
240
|
-
`aimhooman: warning: ${file}: dropped ${dropped.count} override(s) with retired scope "secret-path"; built-in secret scanning was removed in v0.3.0\n`
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
return normalized;
|
|
244
284
|
}
|
|
245
285
|
|
|
246
286
|
function overrideEntries(value, key, file, dropped = { count: 0 }) {
|
|
@@ -251,8 +291,8 @@ function overrideEntries(value, key, file, dropped = { count: 0 }) {
|
|
|
251
291
|
return value.map((entry, index) => {
|
|
252
292
|
// Built-in secret scanning and its secret-path override scope were
|
|
253
293
|
// removed in v0.3.0. An overrides file written by an older version may
|
|
254
|
-
// still carry such entries; drop them (
|
|
255
|
-
// instead of failing the whole load.
|
|
294
|
+
// still carry such entries; drop them (loadOverrides warns once and
|
|
295
|
+
// persists the cleaned file) instead of failing the whole load.
|
|
256
296
|
if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.scope === 'secret-path') {
|
|
257
297
|
dropped.count += 1;
|
|
258
298
|
return null;
|