@rmyndharis/aimhooman 0.1.0 → 0.1.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/CHANGELOG.md +54 -4
- package/CONTRIBUTING.md +7 -27
- package/README.md +10 -12
- package/SECURITY.md +8 -10
- package/bin/aimhooman.mjs +56 -5
- package/package.json +1 -1
- package/src/atomic-write.mjs +25 -1
- package/src/githooks.mjs +2 -2
- package/src/gitx.mjs +0 -12
- package/src/hook.mjs +117 -3
- package/src/scan-target.mjs +32 -21
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.1] - 2026-07-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Git hooks no longer trap the repository when the aimhooman CLI or Node is
|
|
15
|
+
missing (for example after the package was removed without `aimhooman
|
|
16
|
+
uninstall`). The dispatcher used to exit 127 and abort every commit; it now
|
|
17
|
+
warns once and allows the operation without protection, so a half-removed
|
|
18
|
+
install degrades gracefully instead of breaking Git.
|
|
19
|
+
- The `PreToolUse` guard no longer denies ordinary read-only pipelines. Any pipe
|
|
20
|
+
made the parsed command uncertain, and the clean and compliance profiles read
|
|
21
|
+
that uncertainty as a possibly hidden commit, so everyday lines such as
|
|
22
|
+
`gh issue view 1 | tail -5` were blocked. A pipeline now passes when every
|
|
23
|
+
segment is a known read-only command and the line carries no opaque shell
|
|
24
|
+
syntax; everything else stays denied, including pipe-to-shell, subshells,
|
|
25
|
+
`eval`, and readers that execute their own input. `strict` is unchanged.
|
|
26
|
+
- A bare `allow <path>` for a path that matches a secret rule (for example
|
|
27
|
+
`.env.minimal`) no longer reports success while leaving the block in place.
|
|
28
|
+
It now fails closed and directs to `--scope secret-path`, the only scope that
|
|
29
|
+
can silence a secret, so a local override cannot hide a possible leaked key.
|
|
30
|
+
- Detect secrets renamed to a neutral path. A path-only secret such as `.env`
|
|
31
|
+
was missed when moved to a name the destination scan does not match, because its
|
|
32
|
+
content carries no PEM, AWS, or token shape; the rename-source review now retains
|
|
33
|
+
secret-category findings and reports them on the destination path where the bytes
|
|
34
|
+
live, so clean-profile repair unstages the blob that carries the secret. Deleting
|
|
35
|
+
a secret path remains a non-finding.
|
|
36
|
+
- Retry the atomic rename that commits a state write when Windows reports a
|
|
37
|
+
transient `EPERM`, `EACCES`, or `EBUSY`. An antivirus or indexer holding a
|
|
38
|
+
handle on the file aimhooman had just written could kill a lock contender at
|
|
39
|
+
its ticket publication, which surfaced as unrelated failures (a lifecycle-queue
|
|
40
|
+
timeout, or a repair that appeared not to run). A persistent failure, any other
|
|
41
|
+
error code, and every non-Windows platform still fail immediately, and the
|
|
42
|
+
original file is never left partially written.
|
|
43
|
+
- The clean-profile repair now verifies it cleared every target and re-runs the
|
|
44
|
+
unstage when a transient git operation under heavy load left a path staged, so
|
|
45
|
+
the repair no longer reports success while an artifact rides through.
|
|
46
|
+
- Derive the post-repair empty-commit hint from the staged paths captured
|
|
47
|
+
before repair instead of a second git read after `git restore --staged`. That
|
|
48
|
+
read followed an index write and could transiently report the wrong state
|
|
49
|
+
under heavy load; the derivation is deterministic.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- The release workflow runs `npm run verify` before publish instead of `npm test`,
|
|
54
|
+
so the tarball-manifest check and the installed-hook smoke test gate the published
|
|
55
|
+
tag, matching the push workflow.
|
|
56
|
+
- Non-hook commands no longer load the PreToolUse shell parser at startup; it is
|
|
57
|
+
imported only for the `hook` subcommand, which speeds up `init`, `uninstall`,
|
|
58
|
+
`check`, `status`, and the rest and gives the lifecycle-lock queue more headroom
|
|
59
|
+
on slow runners.
|
|
60
|
+
|
|
10
61
|
## [0.1.0] - 2026-07-15
|
|
11
62
|
|
|
12
63
|
First public release. aimhooman keeps AI coding-agent residue out of Git history
|
|
@@ -30,10 +81,9 @@ commit messages, and AI markers left in code.
|
|
|
30
81
|
- Scan every index stage during unresolved conflicts so a secret on either side
|
|
31
82
|
cannot be missed.
|
|
32
83
|
- Authorize protected-path CI only when the exact workflow-run attempt's actor and
|
|
33
|
-
triggering actor, including their IDs, match the
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
closed with no reviewer fallback.
|
|
84
|
+
triggering actor, including their IDs, match the repository owner returned by
|
|
85
|
+
GitHub. Bind that authorization to exact commits, paths, blobs, modes, deletion
|
|
86
|
+
tombstones, and policy migrations; changes not attributed to the owner fail closed.
|
|
37
87
|
- Keep the published local rule-pack schema compatible with strict JSON Schema
|
|
38
88
|
validators.
|
|
39
89
|
- Prove npm version absence and forward dist-tag movement with bounded retries
|
package/CONTRIBUTING.md
CHANGED
|
@@ -107,38 +107,18 @@ Commits must read as if a human wrote them:
|
|
|
107
107
|
|
|
108
108
|
By participating, you agree to abide by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
workflow but no pull-request approval, code-owner review, last-push approval, or
|
|
112
|
-
other reviewer. The repository has no `CODEOWNERS` fallback.
|
|
113
|
-
|
|
114
|
-
Changes to agent instructions or `.aimhooman.json` are protected-path changes. CI
|
|
115
|
-
verifies the pinned repository and owner login plus numeric IDs through the GitHub
|
|
116
|
-
API, then fetches the exact workflow-run attempt. GitHub must attribute the
|
|
117
|
-
attempt's `actor` and `triggering_actor` to that owner's login and numeric ID. CI
|
|
118
|
-
binds that authorization to the exact
|
|
119
|
-
head, transition commit, path, resulting blob and regular-file mode, or deletion
|
|
120
|
-
tombstone. Policy migrations additionally bind the old and new policy objects.
|
|
121
|
-
Another commit, attempt, path result, mode, or migration needs fresh authorization.
|
|
122
|
-
A protected-path change by any non-owner fails closed.
|
|
123
|
-
|
|
124
|
-
The owner account and its credentials are the repository trust root. These checks
|
|
125
|
-
verify GitHub's actor attribution and bind the resulting objects; they do not prove
|
|
126
|
-
an interactive human action, provide independent review, or protect against a
|
|
127
|
-
compromised owner credential.
|
|
110
|
+
## Releases
|
|
128
111
|
|
|
129
112
|
Releases publish to npm automatically when a `v*` tag is pushed. The workflow
|
|
130
|
-
installs dependencies, runs the test suite, and publishes with npm build provenance
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
and the matching README version badge, then tag and push:
|
|
113
|
+
installs dependencies, runs the test suite, and publishes with npm build provenance.
|
|
114
|
+
To cut a release, bump the version (keep `package.json`, `.claude-plugin/plugin.json`,
|
|
115
|
+
`.codex-plugin/plugin.json`, and `.claude-plugin/marketplace.json` in sync), add the
|
|
116
|
+
`## [version]` CHANGELOG entry and the matching README version badge, then:
|
|
135
117
|
|
|
136
118
|
```sh
|
|
137
119
|
git tag -a v0.1.0 -m v0.1.0
|
|
138
120
|
git push origin v0.1.0
|
|
139
121
|
```
|
|
140
122
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
and rotate it regularly. Do not run `npm publish` or move dist-tags manually while
|
|
144
|
-
a release job is pending or running.
|
|
123
|
+
Use a granular, publish-only npm token with 2FA enabled, and avoid publishing or
|
|
124
|
+
moving dist-tags manually while a release job is running.
|
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.1-blue" alt="v0.1.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">
|
|
@@ -286,17 +286,15 @@ Under `strict`, policy files and agent instructions produce review-required find
|
|
|
286
286
|
The product's `review` and `policy-review` commands record local, object-bound decisions;
|
|
287
287
|
an ordinary path allow cannot satisfy either finding.
|
|
288
288
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
mode, or
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
fallback. The owner account and its credentials are the trust root, so this is owner
|
|
299
|
-
authorization, not independent review.
|
|
289
|
+
For a protected-path change, CI verifies the pinned repository and owner login plus
|
|
290
|
+
numeric IDs through the GitHub API, then inspects the exact workflow-run attempt.
|
|
291
|
+
GitHub must attribute both `actor` and `triggering_actor`, including their numeric IDs,
|
|
292
|
+
to that owner. CI then binds the authorization to the exact head, transition commit,
|
|
293
|
+
path, resulting blob and regular-file mode, or deletion tombstone. A strict-policy
|
|
294
|
+
migration also binds its old and new policy objects. A different attempt, commit, path
|
|
295
|
+
result, mode, or policy transition needs fresh authorization. A change not attributed to
|
|
296
|
+
the owner fails closed. This is owner authorization verified through GitHub attribution,
|
|
297
|
+
not independent review.
|
|
300
298
|
|
|
301
299
|
## Overrides
|
|
302
300
|
|
package/SECURITY.md
CHANGED
|
@@ -54,16 +54,14 @@ only the latest minor release will receive security fixes.
|
|
|
54
54
|
- A committed `.aimhooman.json` provides the versioned team profile. Invalid
|
|
55
55
|
project policy fails closed; per-clone allow/deny entries remain local and
|
|
56
56
|
should be governed by team process where compliance requires it.
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
reviewer. These checks verify GitHub attribution, not an interactive human action,
|
|
66
|
-
and cannot defend against malicious or compromised owner credentials.
|
|
57
|
+
- For protected-path changes (agent instruction files, `.aimhooman.json`), CI resolves
|
|
58
|
+
the repository and owner through the GitHub API and fetches the exact workflow-run
|
|
59
|
+
attempt. GitHub must attribute both the actor and triggering actor to the owner's
|
|
60
|
+
login and numeric ID. The resulting decision is bound to the exact head, transition
|
|
61
|
+
commit, path, blob, and regular-file mode (or deletion tombstone); a policy migration
|
|
62
|
+
also binds its old and new objects. Changes not attributed to the owner fail closed.
|
|
63
|
+
These checks verify GitHub actor attribution, not an interactive human action, and
|
|
64
|
+
cannot defend against a compromised owner credential.
|
|
67
65
|
- The release pipeline pins actions to immutable commit SHAs and publishes with npm
|
|
68
66
|
build provenance. Pushing a `v*` tag runs the workflow: it installs dependencies,
|
|
69
67
|
runs the test suite, then publishes to npm authenticated by the `NPM_TOKEN` secret
|
package/bin/aimhooman.mjs
CHANGED
|
@@ -8,11 +8,11 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
|
8
8
|
import {
|
|
9
9
|
GitRevisionError,
|
|
10
10
|
gitConfig,
|
|
11
|
-
hasStagedChanges,
|
|
12
11
|
introducedCommits,
|
|
13
12
|
openRepo,
|
|
14
13
|
readCommitPath,
|
|
15
14
|
readStagedPath,
|
|
15
|
+
stagedPaths,
|
|
16
16
|
stagedRenameSources,
|
|
17
17
|
unstagePaths,
|
|
18
18
|
withIndexFromTree,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
21
21
|
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
22
22
|
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
23
|
-
import { runHook } from '../src/hook.mjs';
|
|
24
23
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
25
24
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
26
25
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
@@ -284,9 +283,41 @@ function cmdPrecommit(args) {
|
|
|
284
283
|
}
|
|
285
284
|
}
|
|
286
285
|
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
286
|
+
// The empty-commit hint is derived from the staged paths captured before
|
|
287
|
+
// repair, not from a second git read after `git restore --staged`. That
|
|
288
|
+
// post-repair read followed an index write and could transiently report
|
|
289
|
+
// the wrong state under heavy CI load, flaking the repair tests.
|
|
290
|
+
// unstagePaths is atomic (it throws on failure), so when every staged
|
|
291
|
+
// path is a repair target the index matches HEAD afterward. The capture
|
|
292
|
+
// is best-effort and runs before the repair: if this read fails the
|
|
293
|
+
// unstage still runs and the hint is omitted (empty stays false) rather
|
|
294
|
+
// than skipping the repair or blocking the commit.
|
|
295
|
+
let stagedBefore;
|
|
296
|
+
try {
|
|
297
|
+
stagedBefore = stagedPaths(repo);
|
|
298
|
+
} catch {
|
|
299
|
+
stagedBefore = null;
|
|
300
|
+
}
|
|
287
301
|
unstagePaths(repo, [...unstageTargets]);
|
|
288
|
-
|
|
289
|
-
|
|
302
|
+
// Under heavy CI load a `git restore --staged` or the rename-source
|
|
303
|
+
// detection above can transiently leave a target staged, which flakes
|
|
304
|
+
// the repair tests and would let an artifact ride through. Re-detect
|
|
305
|
+
// rename sources and re-unstage any still-staged target until every
|
|
306
|
+
// target is gone or the budget is reached.
|
|
307
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
308
|
+
let stillStaged;
|
|
309
|
+
try {
|
|
310
|
+
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
311
|
+
stillStaged = new Set(stagedPaths(repo));
|
|
312
|
+
} catch {
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
const pending = [...unstageTargets].filter((path) => stillStaged.has(path));
|
|
316
|
+
if (!pending.length) break;
|
|
317
|
+
unstagePaths(repo, pending);
|
|
318
|
+
}
|
|
319
|
+
const empty = stagedBefore !== null
|
|
320
|
+
&& stagedBefore.every((path) => unstageTargets.has(path));
|
|
290
321
|
process.stderr.write(
|
|
291
322
|
`aimhooman: unstaged ${paths.length} AI artifact(s) from this commit: ${paths.map(visible).join(', ')}${empty ? ' (nothing else staged — the commit will be empty)' : ''}\n`
|
|
292
323
|
);
|
|
@@ -885,6 +916,19 @@ function cmdOverride(args, allow) {
|
|
|
885
916
|
`secret rules cannot be allowed at --scope rule (that would suppress every matching secret path under every profile); use --scope secret-path <path> to allow a specific secret path`,
|
|
886
917
|
);
|
|
887
918
|
}
|
|
919
|
+
// A path that matches a secret rule cannot be silenced by a path (or rule)
|
|
920
|
+
// allow: only --scope secret-path suppresses secret findings, deliberately,
|
|
921
|
+
// so a local override cannot hide a possible leaked key. Without this check
|
|
922
|
+
// a bare `allow .env.minimal` would report success but leave the block in
|
|
923
|
+
// place, which reads as a broken allow.
|
|
924
|
+
if (allow && scope !== 'secret-path'
|
|
925
|
+
&& engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
|
|
926
|
+
throw new ArgumentError(
|
|
927
|
+
`"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
|
|
928
|
+
+ '(a local override must not hide a possible leaked key); '
|
|
929
|
+
+ 'use --scope secret-path to explicitly allow this specific path',
|
|
930
|
+
);
|
|
931
|
+
}
|
|
888
932
|
if (scope === 'secret-path' && !allow) {
|
|
889
933
|
throw new ArgumentError('--scope secret-path is only valid with allow');
|
|
890
934
|
}
|
|
@@ -1563,7 +1607,14 @@ if (argv[0] === 'hook') {
|
|
|
1563
1607
|
console.error('aimhooman: hook requires exactly one supported event');
|
|
1564
1608
|
process.exit(20);
|
|
1565
1609
|
}
|
|
1566
|
-
|
|
1610
|
+
// hook.mjs (the PreToolUse shell parser) is by far the largest module and is
|
|
1611
|
+
// only needed for the `hook` subcommand. Loading it lazily cuts the heaviest
|
|
1612
|
+
// parse cost from every other command's startup, so the lifecycle-lock
|
|
1613
|
+
// candidate is published earlier. This widens the margin under the queue-wait
|
|
1614
|
+
// budget rather than guaranteeing it: openRepo's git spawns still set the
|
|
1615
|
+
// floor on slow runners.
|
|
1616
|
+
import('../src/hook.mjs')
|
|
1617
|
+
.then(({ runHook }) => runHook(hookArgs))
|
|
1567
1618
|
.then((code) => {
|
|
1568
1619
|
// Drain the permission decision before exiting: on a piped stdout,
|
|
1569
1620
|
// process.exit() can cut off the emit() write and drop a deny. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
|
|
5
5
|
"homepage": "https://github.com/rmyndharis/aimhooman#readme",
|
|
6
6
|
"repository": {
|
package/src/atomic-write.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function atomicWrite(file, data, options = {}) {
|
|
|
39
39
|
operations.sync(descriptor);
|
|
40
40
|
operations.close(descriptor);
|
|
41
41
|
descriptor = undefined;
|
|
42
|
-
operations
|
|
42
|
+
renameWithRetry(operations, temporary, file, options.renameRetries);
|
|
43
43
|
try {
|
|
44
44
|
syncDirectory(directory, operations);
|
|
45
45
|
} catch (error) {
|
|
@@ -57,6 +57,30 @@ export function atomicWrite(file, data, options = {}) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Windows fails a rename with EPERM/EACCES/EBUSY while another handle is open on
|
|
61
|
+
// either path — an antivirus or indexer scanning the file aimhooman just wrote is
|
|
62
|
+
// enough, and it clears within milliseconds. Observed on CI as a lock contender
|
|
63
|
+
// dying at its ticket publication, which then read as a lifecycle-queue timeout.
|
|
64
|
+
// The rename is the atomic commit point, so a retry either lands the complete
|
|
65
|
+
// file or leaves the original untouched; it can never publish a partial write.
|
|
66
|
+
// A non-transient error (and every code on other platforms) still throws at once.
|
|
67
|
+
const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
68
|
+
|
|
69
|
+
function renameWithRetry(operations, temporary, file, retries = 20) {
|
|
70
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
71
|
+
try {
|
|
72
|
+
operations.rename(temporary, file);
|
|
73
|
+
return;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const retryable = process.platform === 'win32'
|
|
76
|
+
&& TRANSIENT_RENAME_CODES.has(error?.code)
|
|
77
|
+
&& attempt + 1 < retries;
|
|
78
|
+
if (!retryable) throw error;
|
|
79
|
+
waitForLock(5);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
function existingMode(file) {
|
|
61
85
|
try {
|
|
62
86
|
return statSync(file).mode & 0o777;
|
package/src/githooks.mjs
CHANGED
|
@@ -581,8 +581,8 @@ AIMHOOMAN_NODE=${shq(nodePath)}
|
|
|
581
581
|
AIMHOOMAN_PATH=${shq(shellPathValue)}
|
|
582
582
|
${captureTree}${captureTransaction}run_aimhooman() {
|
|
583
583
|
if [ ! -f "$AIMHOOMAN_CLI" ] || [ ! -f "$AIMHOOMAN_NODE" ]; then
|
|
584
|
-
echo "aimhooman: guard unavailable
|
|
585
|
-
return
|
|
584
|
+
echo "aimhooman: guard unavailable (aimhooman CLI or Node is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
|
|
585
|
+
return 0
|
|
586
586
|
fi
|
|
587
587
|
(
|
|
588
588
|
unset NODE_OPTIONS NODE_PATH NODE_REPL_EXTERNAL_MODULE NODE_EXTRA_CA_CERTS
|
package/src/gitx.mjs
CHANGED
|
@@ -612,18 +612,6 @@ export function trackedEntries(repo) {
|
|
|
612
612
|
return enrichEntries(repo, entries);
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
// hasStagedChanges checks the complete index delta, including deletions that
|
|
616
|
-
// are intentionally excluded from content scanning.
|
|
617
|
-
export function hasStagedChanges(repo) {
|
|
618
|
-
try {
|
|
619
|
-
gitBuf(['diff', '--cached', '--quiet', '--'], repo.root);
|
|
620
|
-
return false;
|
|
621
|
-
} catch (error) {
|
|
622
|
-
if (error?.status === 1) return true;
|
|
623
|
-
throw error;
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
615
|
// withIndexFromTree exposes an immutable tree through Git's staged-index APIs.
|
|
628
616
|
// It is used by commit-msg after the dispatcher snapshots the would-be commit
|
|
629
617
|
// tree, so a chained hook cannot switch the policy by mutating the live index.
|
package/src/hook.mjs
CHANGED
|
@@ -101,7 +101,12 @@ function hookPreToolUse(input) {
|
|
|
101
101
|
const executorCommand = command(input, executor);
|
|
102
102
|
if (executorCommand === null) return unknownExecutorShape(input, executor.name);
|
|
103
103
|
const rawParsed = parseGit(executorCommand);
|
|
104
|
-
if (nonPosixExecutor(executor.name))
|
|
104
|
+
if (nonPosixExecutor(executor.name)) {
|
|
105
|
+
rawParsed.uncertainShell = true;
|
|
106
|
+
// The benign-pipeline classifier is POSIX-only; never exempt a non-POSIX
|
|
107
|
+
// shell line (pwsh/fish/cmd/nu) from opaque-commit-hiding treatment.
|
|
108
|
+
rawParsed.opaqueCommitHiding = true;
|
|
109
|
+
}
|
|
105
110
|
if (executorTargetSyntaxUncertain(executor.name, executorCommand, rawParsed)
|
|
106
111
|
&& (rawParsed.addPaths.length > 0 || rawParsed.commands.some(isProtectedMutation))) {
|
|
107
112
|
return emitDecision(
|
|
@@ -364,7 +369,7 @@ function hookPreToolUse(input) {
|
|
|
364
369
|
const prefixBypass = parsed.commands.some((candidate) => (
|
|
365
370
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
366
371
|
&& candidate.prefixRisk
|
|
367
|
-
)) || (parsed.
|
|
372
|
+
)) || (parsed.opaqueCommitHiding && parsed.commands.length === 0 && parsed.prefixRisk);
|
|
368
373
|
const aliasBypass = parsed.commands.some((candidate) => (
|
|
369
374
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
370
375
|
&& candidate.inlineAliasRisk
|
|
@@ -379,7 +384,13 @@ function hookPreToolUse(input) {
|
|
|
379
384
|
const unmodelledPrefixReason =
|
|
380
385
|
'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
|
|
381
386
|
const blocks = [];
|
|
382
|
-
|
|
387
|
+
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
388
|
+
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
389
|
+
// (gh ... | tail) was scanned and denied as if it staged a secret.
|
|
390
|
+
// opaqueCommitHiding keeps every commit-hiding shape (subshells,
|
|
391
|
+
// substitution, script-feeds, code-executing/unlisted pipe segments) while
|
|
392
|
+
// excluding pipelines of known read-only commands.
|
|
393
|
+
const potentialCommit = commit || parsed.opaqueCommitHiding;
|
|
383
394
|
if (potentialCommit && repo) {
|
|
384
395
|
try {
|
|
385
396
|
const entries = stagedEntries(repo);
|
|
@@ -1055,11 +1066,17 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
|
|
|
1055
1066
|
: commands.some((candidate) => candidate.verb === 'commit' && candidate.futureIndex)
|
|
1056
1067
|
? 'future-index'
|
|
1057
1068
|
: 'direct';
|
|
1069
|
+
// opaqueCommitHiding narrows uncertainShell to uncertainty that can hide or
|
|
1070
|
+
// feed a commit (subshells, substitution, script-feeds, code-executing or
|
|
1071
|
+
// unlisted pipe segments). A bare pipeline of known read-only commands
|
|
1072
|
+
// (gh ... | tail) cannot, so it is not treated as a potential commit.
|
|
1073
|
+
const opaqueCommitHiding = uncertainShell && !benignReadOnlyPipeline(cmd);
|
|
1058
1074
|
return {
|
|
1059
1075
|
commit,
|
|
1060
1076
|
noVerify,
|
|
1061
1077
|
bypassHooks,
|
|
1062
1078
|
uncertainShell,
|
|
1079
|
+
opaqueCommitHiding,
|
|
1063
1080
|
classification,
|
|
1064
1081
|
environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
|
|
1065
1082
|
prefixRisk,
|
|
@@ -1737,6 +1754,103 @@ function shellUnits(s) {
|
|
|
1737
1754
|
return out;
|
|
1738
1755
|
}
|
|
1739
1756
|
|
|
1757
|
+
// SAFE_PIPE_EXECUTABLES are commands permitted in a "benign read-only pipeline":
|
|
1758
|
+
// they take only data arguments, never run a sub-command, never execute code,
|
|
1759
|
+
// never write files, and cannot bypass Git hooks. Anything not listed is treated
|
|
1760
|
+
// as potentially able to hide or feed a commit (fail-closed). Deliberately
|
|
1761
|
+
// excluded: shells and interpreters, awk/sed/ed/vim, sqlite3/psql/dc/bc/octave
|
|
1762
|
+
// and other stdin-as-program readers, git, tee (writes files), env/xargs/find
|
|
1763
|
+
// (run sub-commands), and curl/wget (fetch arbitrary content to a pipe).
|
|
1764
|
+
const SAFE_PIPE_EXECUTABLES = new Set([
|
|
1765
|
+
'cat', 'tac', 'head', 'tail', 'tr', 'cut', 'paste', 'fold', 'fmt', 'expand',
|
|
1766
|
+
'unexpand', 'rev', 'nl', 'pr', 'column', 'sort', 'uniq', 'comm', 'tsort',
|
|
1767
|
+
'shuf', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'xxd', 'od',
|
|
1768
|
+
'hexdump', 'base64', 'base32', 'md5sum', 'sha1sum', 'sha224sum', 'sha256sum',
|
|
1769
|
+
'sha384sum', 'sha512sum', 'cksum', 'b2sum', 'sum', 'file', 'ls', 'exa', 'eza',
|
|
1770
|
+
'lsd', 'tree', 'du', 'df', 'stat', 'date', 'whoami', 'id', 'uname',
|
|
1771
|
+
'hostname', 'uptime', 'printenv', 'pwd', 'basename', 'dirname', 'realpath',
|
|
1772
|
+
'seq', 'yes', 'echo', 'printf', 'test', 'true', 'false', 'jq', 'yq', 'bat',
|
|
1773
|
+
'gh', '[',
|
|
1774
|
+
]);
|
|
1775
|
+
|
|
1776
|
+
// containsOpaquePipeLexeme is a quote-aware scan for shell constructs that can
|
|
1777
|
+
// hide or feed a commit or subcommand: command substitution, subshells, brace
|
|
1778
|
+
// expansion/groups, script-feed redirects, and command separators. Output
|
|
1779
|
+
// redirects (>, >>) and fd-dups such as 2>&1 are intentionally NOT opaque: they
|
|
1780
|
+
// cannot feed code into a pipe sink. A bare & (background) is opaque unless it
|
|
1781
|
+
// is part of an fd-dup (>& / <&) or &&.
|
|
1782
|
+
function containsOpaquePipeLexeme(command) {
|
|
1783
|
+
let quote = '';
|
|
1784
|
+
let escaped = false;
|
|
1785
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1786
|
+
const char = command[i];
|
|
1787
|
+
const prev = command[i - 1];
|
|
1788
|
+
const next = command[i + 1];
|
|
1789
|
+
if (escaped) { escaped = false; continue; }
|
|
1790
|
+
if (char === '\\' && quote !== "'") { escaped = true; continue; }
|
|
1791
|
+
if (quote) {
|
|
1792
|
+
if (quote === '"' && (char === '`' || (char === '$' && next === '('))) return true;
|
|
1793
|
+
if (char === quote) quote = '';
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
1797
|
+
if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}') return true;
|
|
1798
|
+
if (char === '$' && (next === '(' || next === '{')) return true;
|
|
1799
|
+
if (char === '<' || char === ';') return true;
|
|
1800
|
+
if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
|
|
1801
|
+
}
|
|
1802
|
+
return false;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// splitPipeSegments splits a command on an unquoted pipe (|) that is not part
|
|
1806
|
+
// of ||. Unlike shellUnits it does not break on &, so 2>&1 stays within a
|
|
1807
|
+
// segment.
|
|
1808
|
+
function splitPipeSegments(command) {
|
|
1809
|
+
const segments = [];
|
|
1810
|
+
let segment = '';
|
|
1811
|
+
let quote = '';
|
|
1812
|
+
let escaped = false;
|
|
1813
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1814
|
+
const char = command[i];
|
|
1815
|
+
if (escaped) { segment += char; escaped = false; continue; }
|
|
1816
|
+
if (char === '\\' && quote !== "'") { segment += char; escaped = true; continue; }
|
|
1817
|
+
if (quote) { segment += char; if (char === quote) quote = ''; continue; }
|
|
1818
|
+
if (char === "'" || char === '"') { segment += char; quote = char; continue; }
|
|
1819
|
+
if (char === '|' && command[i + 1] !== '|') { segments.push(segment); segment = ''; continue; }
|
|
1820
|
+
segment += char;
|
|
1821
|
+
}
|
|
1822
|
+
segments.push(segment);
|
|
1823
|
+
return segments;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// pipelineSegmentExecutable returns the shellExecutable basename of the first
|
|
1827
|
+
// non-environment-assignment token in a pipe segment, or null when the segment
|
|
1828
|
+
// has no command. A leading redirect token (e.g. 2>&1 before the command) is
|
|
1829
|
+
// treated as unlisted -> null -> not benign (fail-closed), which is safe.
|
|
1830
|
+
function pipelineSegmentExecutable(segment) {
|
|
1831
|
+
for (const word of shellWords(segment.trim())) {
|
|
1832
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
|
|
1833
|
+
return shellExecutable(word);
|
|
1834
|
+
}
|
|
1835
|
+
return null;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// benignReadOnlyPipeline reports whether a command is a bare pipeline whose
|
|
1839
|
+
// every segment is a known non-code-executing, non-mutating command with no
|
|
1840
|
+
// opaque shell syntax. Such a pipe cannot hide or feed a commit, so it is not a
|
|
1841
|
+
// "potential commit" for the unmodelled-prefix guard. Fail-closed: any opaque
|
|
1842
|
+
// lexeme, unlisted executable, missing executable, or non-pipe returns false.
|
|
1843
|
+
function benignReadOnlyPipeline(command) {
|
|
1844
|
+
if (!command.includes('|')) return false;
|
|
1845
|
+
if (containsOpaquePipeLexeme(command)) return false;
|
|
1846
|
+
const segments = splitPipeSegments(command);
|
|
1847
|
+
if (segments.length < 2) return false;
|
|
1848
|
+
return segments.every((segment) => {
|
|
1849
|
+
const executable = pipelineSegmentExecutable(segment);
|
|
1850
|
+
return executable !== null && SAFE_PIPE_EXECUTABLES.has(executable);
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1740
1854
|
function pipedShellCommitInvocations(source, initialCwd) {
|
|
1741
1855
|
const units = shellUnits(source);
|
|
1742
1856
|
const commits = [];
|
package/src/scan-target.mjs
CHANGED
|
@@ -363,27 +363,38 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
363
363
|
|
|
364
364
|
function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
|
|
365
365
|
for (const entry of entries) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
366
|
+
// A deleted path is gone, so only structural policy rules (agent
|
|
367
|
+
// instructions, project policy) can still matter: deleting a secret or a
|
|
368
|
+
// session file is hygiene, not a violation. A renamed-away path is
|
|
369
|
+
// different — its content survives under a new name, so a path-only
|
|
370
|
+
// secret (e.g. .env) must still fire or a `git mv` to a neutral name
|
|
371
|
+
// would slip past the destination scan, which only catches content-shaped
|
|
372
|
+
// secrets. That finding is reported on the destination path (where the
|
|
373
|
+
// bytes now live) so clean-profile repair unstages the blob that carries
|
|
374
|
+
// the secret rather than the old name.
|
|
375
|
+
const deleted = entry.status === 'D' || entry.type === 'deleted';
|
|
376
|
+
const renamed = entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path;
|
|
377
|
+
if (!deleted && !renamed) continue;
|
|
378
|
+
const sourcePath = deleted ? entry.path : entry.sourcePath;
|
|
379
|
+
const context = {
|
|
380
|
+
objectId: null,
|
|
381
|
+
mode: null,
|
|
382
|
+
transition: options.transition ?? entry.commit,
|
|
383
|
+
...(options.allowMissingPolicy && sourcePath === '.aimhooman.json'
|
|
384
|
+
? { transientAllowRules: new Set(['generic.project-policy']) }
|
|
385
|
+
: {}),
|
|
386
|
+
};
|
|
387
|
+
const findings = engine.checkPaths([sourcePath], context).filter((finding) => {
|
|
388
|
+
if (finding.matchedRuleIds?.some((ruleId) => (
|
|
389
|
+
ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
|
|
390
|
+
))) return true;
|
|
391
|
+
return renamed && finding.category === 'secret';
|
|
392
|
+
});
|
|
393
|
+
accumulator.add(findings.map((finding) => decorate(
|
|
394
|
+
renamed && finding.category === 'secret' ? { ...finding, path: entry.path } : finding,
|
|
395
|
+
entry,
|
|
396
|
+
policy,
|
|
397
|
+
)));
|
|
387
398
|
}
|
|
388
399
|
}
|
|
389
400
|
|