@rmyndharis/aimhooman 0.1.6 → 0.1.8
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 +108 -0
- package/README.md +2 -2
- package/bin/aimhooman.mjs +368 -242
- package/docs/design/frictionless-enforcement.md +7 -4
- package/package.json +1 -1
- package/rules/paths.json +65 -16
- package/rules/secrets.json +3 -3
- package/src/githooks.mjs +51 -6
- package/src/gitx.mjs +10 -0
- package/src/hook.mjs +137 -5
- package/src/report.mjs +27 -2
- package/src/scan-session.mjs +77 -1
- package/src/scan-target.mjs +23 -5
- package/src/scan.mjs +40 -7
|
@@ -55,15 +55,18 @@ enforcement requires local or global hook setup.
|
|
|
55
55
|
| 2. Agent guard | PreToolUse reports paths and rejects unprovable protected Git mutations | plugin | advisory for paths; fail-closed for boundary bypass |
|
|
56
56
|
| 3. Strict policy | `strict` profile blocks instead of repairing (exit 10) | both | block |
|
|
57
57
|
| 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation or incomplete scan |
|
|
58
|
-
| 5. Final ref check | prepared `reference-transaction`
|
|
58
|
+
| 5. Final ref check | prepared `reference-transaction` scans what each commit introduced to `HEAD` or a branch changes (and the message of commits authored locally) | git hook | blocks a violation or incomplete scan |
|
|
59
59
|
|
|
60
60
|
Default profile `clean` uses layers 0, 1, 2, 4, and 5. Successful repair keeps the
|
|
61
61
|
ordinary path low-friction. The pinned-tree and final-ref scans deliberately stop if
|
|
62
|
-
a block remains
|
|
63
|
-
|
|
62
|
+
a block remains on a path the commit actually changes, or if any scan is incomplete.
|
|
63
|
+
A file already in history is not re-tried on every later commit: the final ref check
|
|
64
|
+
judges a commit by its change set, so an inherited path no longer blocks unrelated
|
|
65
|
+
work. Use `aimhooman check --tracked` (or scan the PR range in CI) to surface legacy
|
|
66
|
+
secrets without bricking the branch.
|
|
64
67
|
Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
|
|
65
68
|
hook checks dispatcher integrity there without scanning unresolved references,
|
|
66
|
-
then keeps the
|
|
69
|
+
then keeps the scan veto at `prepared`, after Git has locked them.
|
|
67
70
|
|
|
68
71
|
### Frictionless agent guard (the everyday commands run)
|
|
69
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
|
|
5
5
|
"homepage": "https://github.com/rmyndharis/aimhooman#readme",
|
|
6
6
|
"repository": {
|
package/rules/paths.json
CHANGED
|
@@ -253,7 +253,7 @@
|
|
|
253
253
|
},
|
|
254
254
|
{
|
|
255
255
|
"id": "secret.private-key",
|
|
256
|
-
"version":
|
|
256
|
+
"version": 3,
|
|
257
257
|
"provider": "generic",
|
|
258
258
|
"category": "secret",
|
|
259
259
|
"confidence": "high",
|
|
@@ -274,10 +274,11 @@
|
|
|
274
274
|
"strict": "block",
|
|
275
275
|
"compliance": "block"
|
|
276
276
|
},
|
|
277
|
-
"reason": "
|
|
277
|
+
"reason": "A path matching a private-key filename (id_rsa, id_ed25519, *.p12, *.pfx, ...) is blocked by name — content is not inspected. If this is a real key, it must never be committed; if the name is coincidental, rename the file.",
|
|
278
278
|
"remediation": [
|
|
279
279
|
"git restore --staged <path>",
|
|
280
|
-
"
|
|
280
|
+
"if this is a real key, rotate it if it was ever exposed",
|
|
281
|
+
"if the name is coincidental (a fixture, a doc), rename the file so it no longer matches a private-key pattern"
|
|
281
282
|
]
|
|
282
283
|
},
|
|
283
284
|
{
|
|
@@ -391,10 +392,22 @@
|
|
|
391
392
|
"category": "ephemeral-state",
|
|
392
393
|
"confidence": "high",
|
|
393
394
|
"kind": "path",
|
|
394
|
-
"match": {
|
|
395
|
-
|
|
395
|
+
"match": {
|
|
396
|
+
"path_case": "insensitive",
|
|
397
|
+
"paths": [
|
|
398
|
+
".playwright-mcp/**",
|
|
399
|
+
"**/.playwright-mcp/**"
|
|
400
|
+
]
|
|
401
|
+
},
|
|
402
|
+
"actions": {
|
|
403
|
+
"clean": "block",
|
|
404
|
+
"strict": "block",
|
|
405
|
+
"compliance": "block"
|
|
406
|
+
},
|
|
396
407
|
"reason": "Playwright MCP session artifacts are local, not repository content.",
|
|
397
|
-
"remediation": [
|
|
408
|
+
"remediation": [
|
|
409
|
+
"git restore --staged <path>"
|
|
410
|
+
]
|
|
398
411
|
},
|
|
399
412
|
{
|
|
400
413
|
"id": "remember.state",
|
|
@@ -403,10 +416,22 @@
|
|
|
403
416
|
"category": "ephemeral-state",
|
|
404
417
|
"confidence": "high",
|
|
405
418
|
"kind": "path",
|
|
406
|
-
"match": {
|
|
407
|
-
|
|
419
|
+
"match": {
|
|
420
|
+
"path_case": "insensitive",
|
|
421
|
+
"paths": [
|
|
422
|
+
".remember/**",
|
|
423
|
+
"**/.remember/**"
|
|
424
|
+
]
|
|
425
|
+
},
|
|
426
|
+
"actions": {
|
|
427
|
+
"clean": "block",
|
|
428
|
+
"strict": "block",
|
|
429
|
+
"compliance": "block"
|
|
430
|
+
},
|
|
408
431
|
"reason": "Remember second-brain data is local, not repository content.",
|
|
409
|
-
"remediation": [
|
|
432
|
+
"remediation": [
|
|
433
|
+
"git restore --staged <path>"
|
|
434
|
+
]
|
|
410
435
|
},
|
|
411
436
|
{
|
|
412
437
|
"id": "superpowers.state",
|
|
@@ -415,10 +440,22 @@
|
|
|
415
440
|
"category": "ephemeral-state",
|
|
416
441
|
"confidence": "high",
|
|
417
442
|
"kind": "path",
|
|
418
|
-
"match": {
|
|
419
|
-
|
|
443
|
+
"match": {
|
|
444
|
+
"path_case": "insensitive",
|
|
445
|
+
"paths": [
|
|
446
|
+
".superpowers/**",
|
|
447
|
+
"**/.superpowers/**"
|
|
448
|
+
]
|
|
449
|
+
},
|
|
450
|
+
"actions": {
|
|
451
|
+
"clean": "block",
|
|
452
|
+
"strict": "block",
|
|
453
|
+
"compliance": "block"
|
|
454
|
+
},
|
|
420
455
|
"reason": "Superpowers plugin state is local, not repository content.",
|
|
421
|
-
"remediation": [
|
|
456
|
+
"remediation": [
|
|
457
|
+
"git restore --staged <path>"
|
|
458
|
+
]
|
|
422
459
|
},
|
|
423
460
|
{
|
|
424
461
|
"id": "agent.state",
|
|
@@ -427,9 +464,21 @@
|
|
|
427
464
|
"category": "ephemeral-state",
|
|
428
465
|
"confidence": "high",
|
|
429
466
|
"kind": "path",
|
|
430
|
-
"match": {
|
|
431
|
-
|
|
467
|
+
"match": {
|
|
468
|
+
"path_case": "insensitive",
|
|
469
|
+
"paths": [
|
|
470
|
+
".agent/**",
|
|
471
|
+
"**/.agent/**"
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
"actions": {
|
|
475
|
+
"clean": "block",
|
|
476
|
+
"strict": "block",
|
|
477
|
+
"compliance": "block"
|
|
478
|
+
},
|
|
432
479
|
"reason": "Generic agent state is local, not repository content.",
|
|
433
|
-
"remediation": [
|
|
480
|
+
"remediation": [
|
|
481
|
+
"git restore --staged <path>"
|
|
482
|
+
]
|
|
434
483
|
}
|
|
435
|
-
]
|
|
484
|
+
]
|
package/rules/secrets.json
CHANGED
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"id": "secret.aws-key-content",
|
|
48
|
-
"version":
|
|
48
|
+
"version": 3,
|
|
49
49
|
"provider": "aws",
|
|
50
50
|
"category": "secret",
|
|
51
51
|
"confidence": "high",
|
|
52
52
|
"kind": "code",
|
|
53
53
|
"match": {
|
|
54
54
|
"content": [
|
|
55
|
-
"(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])",
|
|
55
|
+
"(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?<!EXAMPLEKEY)(?![A-Za-z0-9/+=])",
|
|
56
56
|
"\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
|
|
57
57
|
]
|
|
58
58
|
},
|
|
@@ -89,4 +89,4 @@
|
|
|
89
89
|
"Remove the token from the index, revoke or rotate it, and use the provider's secret store."
|
|
90
90
|
]
|
|
91
91
|
}
|
|
92
|
-
]
|
|
92
|
+
]
|
package/src/githooks.mjs
CHANGED
|
@@ -43,14 +43,25 @@ function hooksDir(repo) {
|
|
|
43
43
|
// Inside the worktree is not the same as ours. A hooks directory Git tracks
|
|
44
44
|
// (husky, the vanilla .githooks pattern) is repository content: a dispatcher
|
|
45
45
|
// written there stages this machine's absolute CLI, Node, and PATH for
|
|
46
|
-
// everyone who clones.
|
|
47
|
-
|
|
46
|
+
// everyone who clones. An UNTRACKED hooks directory inside the worktree
|
|
47
|
+
// (a freshly created `.husky` before the first commit, a team-local
|
|
48
|
+
// `.team-hooks`) is repository content in waiting: the next `git add`
|
|
49
|
+
// stages it, carrying the same machine-local paths into history. Treat both
|
|
50
|
+
// as repository content so neither receives a dispatcher — UNLESS the team
|
|
51
|
+
// has explicitly excluded the path (.gitignore or .git/info/exclude), which
|
|
52
|
+
// is the opt-in that says "this hooks dir stays local". A hooks path inside
|
|
53
|
+
// `.git/` (the default, or a custom one) is local Git plumbing and is safe
|
|
54
|
+
// to install into regardless.
|
|
55
|
+
const worktreeContent = inside && !insideGitDir(repo, dir) && !gitIgnored(repo, dir);
|
|
56
|
+
const trackedByGit = inside && trackedPath(repo, dir);
|
|
57
|
+
const tracked = trackedByGit || worktreeContent;
|
|
48
58
|
const repositoryOwned = inside && !tracked;
|
|
49
59
|
const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
|
|
50
60
|
if (shared) {
|
|
51
61
|
const where = scope ? `${scope} scope` : 'a non-local scope';
|
|
52
62
|
let reason = where;
|
|
53
|
-
if (
|
|
63
|
+
if (trackedByGit) reason = `${where}, tracked by this repository`;
|
|
64
|
+
else if (worktreeContent) reason = `${where}, inside the worktree so Git will track it on the next add (add it to .gitignore or .git/info/exclude to manage it locally)`;
|
|
54
65
|
else if (localScope && !inside) reason = `${where}, outside this repository`;
|
|
55
66
|
return {
|
|
56
67
|
dir,
|
|
@@ -106,10 +117,34 @@ function trackedPath(repo, path) {
|
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// gitIgnored reports whether Git would ignore path (via .gitignore,
|
|
121
|
+
// .git/info/exclude, or core.excludesfile). This is the opt-in that lets a team
|
|
122
|
+
// keep a worktree hooks dir local: an excluded path will not be staged by the
|
|
123
|
+
// next `git add`, so a dispatcher written there cannot leak machine-local
|
|
124
|
+
// absolute paths into history. Used to decide whether a worktree hooksPath is
|
|
125
|
+
// repository content in waiting (refuse) or intentionally local (install). A
|
|
126
|
+
// Git that cannot answer counts as NOT ignored, so we fail closed and refuse
|
|
127
|
+
// (the safer side for a leak that writes machine paths into shared history).
|
|
128
|
+
function gitIgnored(repo, path) {
|
|
129
|
+
try {
|
|
130
|
+
execFileSync(
|
|
131
|
+
'git',
|
|
132
|
+
['check-ignore', '--quiet', '--', String(path)],
|
|
133
|
+
{ cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS },
|
|
134
|
+
);
|
|
135
|
+
// git check-ignore exits 0 when the path IS ignored, 1 when it is not.
|
|
136
|
+
return true;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Non-zero exit means "not ignored" (exit 1) or "git failed". Either way,
|
|
139
|
+
// treat as not-ignored so the worktree-content guard stays on.
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
function canonicalPath(path) {
|
|
110
145
|
let current = resolve(path);
|
|
111
146
|
const tail = [];
|
|
112
|
-
for (
|
|
147
|
+
for (; ;) {
|
|
113
148
|
try {
|
|
114
149
|
return resolve(realpathSync(current), ...tail);
|
|
115
150
|
} catch (error) {
|
|
@@ -631,9 +666,19 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
631
666
|
) || exit $?
|
|
632
667
|
`
|
|
633
668
|
: '';
|
|
669
|
+
// The chained predecessor is sourced in a subshell, not exec'd. Git runs the
|
|
670
|
+
// dispatcher with $0 set to the original hook path (the dispatcher replaced
|
|
671
|
+
// it in place), and a subshell inherits $0, so sourcing lets the predecessor
|
|
672
|
+
// see the original $0 and resolve $(dirname "$0") to its real directory —
|
|
673
|
+
// the pattern husky and vanilla .githooks hooks use to find sibling scripts.
|
|
674
|
+
// Exec'ing "$CHAINED" would set the predecessor's $0 to the backup path and
|
|
675
|
+
// break that resolution. Sourcing runs the predecessor in this dispatcher's
|
|
676
|
+
// shell (/bin/sh -p), so its shebang is honoured only for sh-compatible
|
|
677
|
+
// scripts; bash-only predecessors are out of scope. exit inside the
|
|
678
|
+
// predecessor exits the subshell and propagates via || exit $?.
|
|
634
679
|
const chainedInvocation = name === 'reference-transaction'
|
|
635
|
-
? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | "$CHAINED"
|
|
636
|
-
: ` "$CHAINED"
|
|
680
|
+
? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | ( . "$CHAINED" ) || exit $?`
|
|
681
|
+
: ` ( . "$CHAINED" ) || exit $?`;
|
|
637
682
|
const aimCommand = name === 'commit-msg'
|
|
638
683
|
? 'commitmsg "$1" --tree "$AIMHOOMAN_COMMIT_TREE"'
|
|
639
684
|
: name === 'reference-transaction'
|
package/src/gitx.mjs
CHANGED
|
@@ -177,6 +177,16 @@ export function stagedPaths(repo) {
|
|
|
177
177
|
], repo.root));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// stagedTreeSha writes the index to a tree object and returns its SHA, the same
|
|
181
|
+
// value `git write-tree` produces in the commit-msg dispatcher. Used by the
|
|
182
|
+
// pre-commit/commit-msg marker dedup (W5): pre-commit records this sha when it
|
|
183
|
+
// scans clean, and commit-msg skips its duplicate tree scan when the sha still
|
|
184
|
+
// matches. The index is unchanged between the two hooks in a normal commit, so
|
|
185
|
+
// the sha is stable; any staging mutation invalidates it automatically.
|
|
186
|
+
export function stagedTreeSha(repo) {
|
|
187
|
+
return gitStr(['write-tree'], repo.root);
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
// Unmerged index entries have only stages 1-3 and are omitted by the ordinary
|
|
181
191
|
// cached diff. Expose their paths so callers cannot mistake a conflicted index
|
|
182
192
|
// for a complete, clean staged snapshot.
|
package/src/hook.mjs
CHANGED
|
@@ -443,6 +443,20 @@ function hookPreToolUse(input) {
|
|
|
443
443
|
: '--no-verify or shell indirection bypasses the pre-commit guard';
|
|
444
444
|
const unmodelledPrefixReason =
|
|
445
445
|
'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
|
|
446
|
+
// A pipeline whose sink can execute code (a shell, an interpreter fed on
|
|
447
|
+
// stdin) can hide a commit or run arbitrary commands, and there is no argv
|
|
448
|
+
// to read a --no-verify out of. The earlier message reused the commit text
|
|
449
|
+
// above, which told a developer to "retry the commit" for a command that
|
|
450
|
+
// was not a commit at all — this names the real shape instead. The message
|
|
451
|
+
// is conditional on whether a pipe is actually present: opaque syntax
|
|
452
|
+
// without a pipe (a subshell, a brace group, command substitution) should
|
|
453
|
+
// not tell the developer to "drop the `| bash` segment" on a command that
|
|
454
|
+
// has no `|`.
|
|
455
|
+
const opaquePipelineReason = commit
|
|
456
|
+
? unmodelledPrefixReason
|
|
457
|
+
: hasUnquotedPipe(executorCommand)
|
|
458
|
+
? 'aimhooman cannot prove this pipeline is read-only: a shell or code-executing segment can hide a commit or run arbitrary commands. Drop that segment (for example the `| bash`) and run the pieces separately.'
|
|
459
|
+
: 'aimhooman cannot prove this command is read-only: it uses shell syntax (a subshell, command substitution, script-feed redirect, background job, or brace group) that can hide a commit or run arbitrary commands. Run the pieces separately.';
|
|
446
460
|
const blocks = [];
|
|
447
461
|
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
448
462
|
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
@@ -522,7 +536,7 @@ function hookPreToolUse(input) {
|
|
|
522
536
|
if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
|
|
523
537
|
return emitDecision(
|
|
524
538
|
'deny',
|
|
525
|
-
|
|
539
|
+
opaquePipelineReason,
|
|
526
540
|
);
|
|
527
541
|
}
|
|
528
542
|
// Nothing found and nothing to object to, so emit nothing and leave the
|
|
@@ -552,7 +566,7 @@ function hookPreToolUse(input) {
|
|
|
552
566
|
if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
|
|
553
567
|
return emitDecision(
|
|
554
568
|
'deny',
|
|
555
|
-
|
|
569
|
+
opaquePipelineReason,
|
|
556
570
|
);
|
|
557
571
|
}
|
|
558
572
|
const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
|
|
@@ -861,6 +875,41 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
|
|
|
861
875
|
environmentRisk,
|
|
862
876
|
'git',
|
|
863
877
|
);
|
|
878
|
+
// A passthrough prefix (time, timeout, nice, ...) execs the inner
|
|
879
|
+
// command with the same argv in the same place, so it cannot
|
|
880
|
+
// inject a flag or hide a --no-verify the way eval/sudo/bash -c
|
|
881
|
+
// can. Re-parse the inner command from its `git` token so the
|
|
882
|
+
// commit is judged exactly as if the prefix were not there
|
|
883
|
+
// (--no-verify still denied, a clean commit still allowed). The
|
|
884
|
+
// carve-out only applies when nothing else injects risk;
|
|
885
|
+
// otherwise the wrapper falls back to the closed uncertain path.
|
|
886
|
+
const passthroughExecutable = PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))
|
|
887
|
+
&& environmentRisk.length === 0
|
|
888
|
+
&& !targetEnvironmentRisk
|
|
889
|
+
&& !indirectTargetEnvironmentRisk
|
|
890
|
+
&& !commandTargetUncertain
|
|
891
|
+
&& !indirect.targetUncertain
|
|
892
|
+
&& !indirect.pathDialectUncertain
|
|
893
|
+
&& !commandPathDialectUncertain
|
|
894
|
+
&& !unwrapped.uncertain
|
|
895
|
+
? shellExecutable(g0)
|
|
896
|
+
: null;
|
|
897
|
+
if (passthroughExecutable) {
|
|
898
|
+
const gitIndex = toks.findIndex((token, index) => (
|
|
899
|
+
index > 0 && isGitExecutable(token.replace(/^\(+/, '').replace(/\)+$/, ''))
|
|
900
|
+
));
|
|
901
|
+
if (gitIndex > 0) {
|
|
902
|
+
const inner = parseGit(toks.slice(gitIndex).join(' '), unwrapped.cwd);
|
|
903
|
+
commit ||= inner.commit;
|
|
904
|
+
noVerify ||= inner.noVerify;
|
|
905
|
+
bypassHooks ||= inner.bypassHooks;
|
|
906
|
+
uncertainShell ||= inner.uncertainShell;
|
|
907
|
+
addPaths.push(...inner.addPaths);
|
|
908
|
+
commands.push(...inner.commands);
|
|
909
|
+
sawAdd ||= inner.addPaths.length > 0;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
864
913
|
commit = true;
|
|
865
914
|
uncertainShell = true;
|
|
866
915
|
commands.push({
|
|
@@ -883,7 +932,8 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
|
|
|
883
932
|
|| indirect.pathDialectUncertain,
|
|
884
933
|
});
|
|
885
934
|
}
|
|
886
|
-
if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))
|
|
935
|
+
if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))
|
|
936
|
+
&& !PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))) {
|
|
887
937
|
prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
|
|
888
938
|
prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
|
|
889
939
|
prefixRisk = true;
|
|
@@ -1917,7 +1967,18 @@ function hasUncertainShellSyntax(command) {
|
|
|
1917
1967
|
quote = char;
|
|
1918
1968
|
continue;
|
|
1919
1969
|
}
|
|
1920
|
-
|
|
1970
|
+
// `{`/`}` are ambiguous: brace expansion (`{a,b}`, `{owner}`, `x.{js,ts}`)
|
|
1971
|
+
// is argument-level and cannot hide a commit or feed code into a pipe
|
|
1972
|
+
// sink, while a brace group (`{ list; }`) is control flow and can. Brace
|
|
1973
|
+
// expansion touches the surrounding word (no separator before the `{`, no
|
|
1974
|
+
// space/`;` after it); a brace-group `{` stands as its own token. PowerShell
|
|
1975
|
+
// `{ }` script blocks are handled separately via the non-POSIX executor
|
|
1976
|
+
// path, so this POSIX distinction never loosens that.
|
|
1977
|
+
if (char === '{' || char === '}') {
|
|
1978
|
+
if (isBraceGroupToken(command, i)) return true;
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
if (char === '`' || char === '(' || char === ')' || char === '<' || char === '>') return true;
|
|
1921
1982
|
if (char === '|' && command[i + 1] !== '|') return true;
|
|
1922
1983
|
if (char === '&' && command[i + 1] !== '&' && command[i - 1] !== '&') return true;
|
|
1923
1984
|
}
|
|
@@ -1927,6 +1988,57 @@ function hasUncertainShellSyntax(command) {
|
|
|
1927
1988
|
});
|
|
1928
1989
|
}
|
|
1929
1990
|
|
|
1991
|
+
// BRACE_GROUP_LEAD chars may appear immediately before a brace-group token.
|
|
1992
|
+
// `:` is included so `cmd: { ... }` is handled, though it is not POSIX shell.
|
|
1993
|
+
const BRACE_GROUP_LEAD = new Set(['', ' ', '\t', '\n', ';', '&', '|', '(', ')']);
|
|
1994
|
+
// BRACE_GROUP_OPEN_TRAIL chars may follow a `{` that opens a brace group: the
|
|
1995
|
+
// reserved word `{` must be followed by whitespace, `;`, or end of string.
|
|
1996
|
+
const BRACE_GROUP_OPEN_TRAIL = new Set([' ', '\t', '\n', ';', '']);
|
|
1997
|
+
// BRACE_GROUP_CLOSE_LEAD chars precede a `}` that closes a brace group: the
|
|
1998
|
+
// closer must follow `;` or whitespace (the end of the group body).
|
|
1999
|
+
const BRACE_GROUP_CLOSE_LEAD = new Set([' ', '\t', '\n', ';', '']);
|
|
2000
|
+
|
|
2001
|
+
// isBraceGroupToken reports whether `{`/`}` at position i in command is a
|
|
2002
|
+
// POSIX brace-group token (reserved-word `{` or its closer `}`) rather than a
|
|
2003
|
+
// brace-expansion character. Brace expansion `{a,b}` / `{owner}` / `x.{js,ts}`
|
|
2004
|
+
// is argument-level and cannot feed code into a pipe sink or run a subcommand,
|
|
2005
|
+
// so it must not make a command "opaque". A brace group `{ list; }` is
|
|
2006
|
+
// control flow and can, so it stays opaque. PowerShell script blocks are
|
|
2007
|
+
// handled by the non-POSIX executor path, not here.
|
|
2008
|
+
function isBraceGroupToken(command, i) {
|
|
2009
|
+
const char = command[i];
|
|
2010
|
+
const prev = command[i - 1] ?? '';
|
|
2011
|
+
const next = command[i + 1] ?? '';
|
|
2012
|
+
if (char === '{') {
|
|
2013
|
+
if (!BRACE_GROUP_LEAD.has(prev)) return false;
|
|
2014
|
+
if (!BRACE_GROUP_OPEN_TRAIL.has(next)) return false;
|
|
2015
|
+
return true;
|
|
2016
|
+
}
|
|
2017
|
+
// `}` closer: must follow `;` or whitespace.
|
|
2018
|
+
return BRACE_GROUP_CLOSE_LEAD.has(prev);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// hasUnquotedPipe reports whether command contains a pipe `|` that is not
|
|
2022
|
+
// `||`, outside of any quote. Used to decide whether the opaque-pipeline deny
|
|
2023
|
+
// message should mention a pipe at all — the original message always named
|
|
2024
|
+
// `| bash` even for commands with no pipe.
|
|
2025
|
+
function hasUnquotedPipe(command) {
|
|
2026
|
+
let quote = '';
|
|
2027
|
+
let escaped = false;
|
|
2028
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
2029
|
+
const char = command[i];
|
|
2030
|
+
if (escaped) { escaped = false; continue; }
|
|
2031
|
+
if (char === '\\' && quote !== "'") { escaped = true; continue; }
|
|
2032
|
+
if (quote) {
|
|
2033
|
+
if (char === quote) quote = '';
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
2037
|
+
if (char === '|' && command[i + 1] !== '|') return true;
|
|
2038
|
+
}
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1930
2042
|
function shellUnits(s) {
|
|
1931
2043
|
const out = [];
|
|
1932
2044
|
let segment = '';
|
|
@@ -2102,7 +2214,14 @@ function containsOpaquePipeLexeme(command) {
|
|
|
2102
2214
|
continue;
|
|
2103
2215
|
}
|
|
2104
2216
|
if (char === "'" || char === '"') { quote = char; continue; }
|
|
2105
|
-
|
|
2217
|
+
// Brace expansion (`{a,b}`, `{owner}`) is not opaque: it only expands to
|
|
2218
|
+
// arguments for the same command and cannot feed code into a pipe sink.
|
|
2219
|
+
// Brace groups (`{ list; }`) are opaque. See isBraceGroupToken.
|
|
2220
|
+
if (char === '{' || char === '}') {
|
|
2221
|
+
if (isBraceGroupToken(command, i)) return true;
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
if (char === '`' || char === '(' || char === ')') return true;
|
|
2106
2225
|
if (char === '$' && (next === '(' || next === '{')) return true;
|
|
2107
2226
|
if (char === '<' || char === ';') return true;
|
|
2108
2227
|
if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
|
|
@@ -2687,6 +2806,19 @@ const UNCERTAIN_TARGET_INDIRECT_EXECUTORS = new Set([
|
|
|
2687
2806
|
'systemd-run', 'unshare', 'watch', 'wsl', 'xargs',
|
|
2688
2807
|
]);
|
|
2689
2808
|
|
|
2809
|
+
// Passthrough prefixes exec the inner command verbatim: the same argv, the same
|
|
2810
|
+
// cwd, the same target. `time`, `timeout`, `nice`, `ionice` and friends only
|
|
2811
|
+
// measure or schedule — they cannot inject a flag, rewrite the command line, or
|
|
2812
|
+
// hide a --no-verify the way eval/sudo/bash -c can. An indirect commit seen
|
|
2813
|
+
// through one of these is therefore treated as direct, so `time git commit` is
|
|
2814
|
+
// judged like `git commit` instead of being refused as opaque indirection. The
|
|
2815
|
+
// carve-out only applies when nothing else is risky (no environment override,
|
|
2816
|
+
// no uncertain target); otherwise the wrapper falls back to the closed path.
|
|
2817
|
+
const PASSTHROUGH_PREFIX_EXECUTORS = new Set([
|
|
2818
|
+
'time', 'timeout', 'nice', 'ionice', 'chrt', 'taskset', 'numactl',
|
|
2819
|
+
'stdbuf', 'setsid', 'nohup', 'prlimit', 'faketime',
|
|
2820
|
+
]);
|
|
2821
|
+
|
|
2690
2822
|
function indirectCommitInvocation(words, initialCwd) {
|
|
2691
2823
|
if (!words.length) return false;
|
|
2692
2824
|
const executable = shellExecutable(words[0]);
|
package/src/report.mjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// Human-facing and JSON reporting for aimhooman findings.
|
|
2
2
|
|
|
3
|
+
// HUMAN_FINDING_CAP bounds the per-invocation stderr output. A scan that fires
|
|
4
|
+
// many findings of the same rule (a vendored OpenSSL corpus can produce 99) used
|
|
5
|
+
// to emit hundreds of near-identical lines with no truncation marker. The cap
|
|
6
|
+
// prints the first HUMAN_FINDING_CAP finding blocks, then a single summary line
|
|
7
|
+
// for the rest. The JSON report (aimhooman review / --json) is never capped.
|
|
8
|
+
const HUMAN_FINDING_CAP = 20;
|
|
9
|
+
|
|
3
10
|
export function human(findings, tone) {
|
|
4
11
|
if (!findings.length) return '';
|
|
5
12
|
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
@@ -8,6 +15,13 @@ export function human(findings, tone) {
|
|
|
8
15
|
for (const f of findings) {
|
|
9
16
|
if (f.decision === 'block') block += 1;
|
|
10
17
|
else if (f.decision === 'review') review += 1;
|
|
18
|
+
}
|
|
19
|
+
// Render findings in order up to the cap, then collapse the rest into one
|
|
20
|
+
// summary line. Keeps the first findings fully visible (the actionable ones)
|
|
21
|
+
// while bounding stderr for repeated-rule scans.
|
|
22
|
+
const shown = findings.slice(0, HUMAN_FINDING_CAP);
|
|
23
|
+
const hidden = findings.length - shown.length;
|
|
24
|
+
for (const f of shown) {
|
|
11
25
|
const loc = f.path
|
|
12
26
|
? `${visible(f.path)}${f.line ? `:${f.line}` : ''}`
|
|
13
27
|
: (f.line ? `commit message line ${f.line}` : '');
|
|
@@ -18,10 +32,21 @@ export function human(findings, tone) {
|
|
|
18
32
|
if (f.text && f.text.trim()) {
|
|
19
33
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
20
34
|
}
|
|
21
|
-
|
|
35
|
+
// Render the whole remediation array, not just the first entry. Several
|
|
36
|
+
// rules carry a second line (e.g. "rotate the key if it was ever exposed")
|
|
37
|
+
// that the previous single-index render dropped silently.
|
|
38
|
+
for (const remedy of (f.remediation || [])) {
|
|
39
|
+
out += ` fix: ${remedy}\n`;
|
|
40
|
+
}
|
|
22
41
|
out += '\n';
|
|
23
42
|
}
|
|
24
|
-
|
|
43
|
+
if (hidden > 0) {
|
|
44
|
+
out += `… and ${hidden} more ${hidden === 1 ? 'finding' : 'findings'} (run 'aimhooman review' for the full list)\n\n`;
|
|
45
|
+
}
|
|
46
|
+
const findingWord = findings.length === 1 ? 'finding' : 'findings';
|
|
47
|
+
const blockWord = block === 1 ? 'block' : 'blocks';
|
|
48
|
+
const reviewWord = review === 1 ? 'review' : 'reviews';
|
|
49
|
+
out += `${findings.length} ${findingWord}: ${block} ${blockWord}, ${review} ${reviewWord}\n`;
|
|
25
50
|
return out;
|
|
26
51
|
}
|
|
27
52
|
|
package/src/scan-session.mjs
CHANGED
|
@@ -86,6 +86,11 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
86
86
|
stats.objects_read = batch.objects.size;
|
|
87
87
|
for (const failure of batch.failures) increment(stats.skipped, failure.reason);
|
|
88
88
|
|
|
89
|
+
// Precompute changed-line ranges for all text candidates in ONE batched
|
|
90
|
+
// diff per (commit, parent) pair (W4 + perf). A missing path key means
|
|
91
|
+
// "scan the whole blob" (staged/snapshot/root entries, or a diff failure).
|
|
92
|
+
const lineRanges = collectLineRanges(repo, candidates);
|
|
93
|
+
|
|
89
94
|
const findings = [];
|
|
90
95
|
for (const entry of candidates) {
|
|
91
96
|
const blob = batch.objects.get(entry.oid);
|
|
@@ -110,7 +115,9 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
110
115
|
});
|
|
111
116
|
} else {
|
|
112
117
|
stats.files_scanned += 1;
|
|
113
|
-
|
|
118
|
+
const ranges = lineRanges.get(entry.path);
|
|
119
|
+
matched = engine.checkContent(entry.path, blob.toString('utf8'),
|
|
120
|
+
ranges && ranges.length ? { lineRanges: ranges } : {});
|
|
114
121
|
}
|
|
115
122
|
stats.findings_total += matched.length;
|
|
116
123
|
for (const finding of matched) {
|
|
@@ -236,3 +243,72 @@ function isBinary(buffer) {
|
|
|
236
243
|
for (let index = 0; index < length; index++) if (buffer[index] === 0) return true;
|
|
237
244
|
return false;
|
|
238
245
|
}
|
|
246
|
+
|
|
247
|
+
// collectLineRanges builds a Map<path, ranges[]> for all entries whose content
|
|
248
|
+
// scan can be narrowed to changed hunks (W4, bug 12d-F1). It batches the work
|
|
249
|
+
// into ONE `git diff --unified=0` per (commit, parent) pair rather than one diff
|
|
250
|
+
// per file — a commit touching 50 files would otherwise pay ~650ms of per-file
|
|
251
|
+
// git subprocess startup. Entries are grouped by their first parent; a normal
|
|
252
|
+
// commit (single parent) is a single diff call covering every file.
|
|
253
|
+
//
|
|
254
|
+
// Entries without commit/parents (tracked snapshots, staged views, root
|
|
255
|
+
// commits) are omitted from the map; the caller treats a missing key as "scan
|
|
256
|
+
// the whole blob" — the safe side for a secret scanner is to scan MORE, not
|
|
257
|
+
// less, so a failure to compute hunks falls back to the pre-W4 behaviour.
|
|
258
|
+
function collectLineRanges(repo, entries) {
|
|
259
|
+
// Group text-candidate entries by their first parent to batch the diffs.
|
|
260
|
+
// Key by `${commit}\0${parent}` so a merge with multiple parents produces
|
|
261
|
+
// one diff per distinct (commit, parent) pair.
|
|
262
|
+
const groups = new Map();
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (entry.type === 'blob' && !entry.commit) continue; // staged/snapshot: no diff
|
|
265
|
+
const parent = entry.parents?.[0];
|
|
266
|
+
if (!entry.commit || !parent) continue; // root commit or no parent: whole-blob
|
|
267
|
+
const key = `${entry.commit}\0${parent}`;
|
|
268
|
+
if (!groups.has(key)) groups.set(key, { commit: entry.commit, parent });
|
|
269
|
+
}
|
|
270
|
+
const byPath = new Map();
|
|
271
|
+
for (const { commit, parent } of groups.values()) {
|
|
272
|
+
let output;
|
|
273
|
+
try {
|
|
274
|
+
output = execFileSync('git', [
|
|
275
|
+
'-c', 'core.quotePath=false',
|
|
276
|
+
'diff', '--unified=0', '--no-color', '--no-prefix',
|
|
277
|
+
parent, commit,
|
|
278
|
+
], {
|
|
279
|
+
cwd: repo.root,
|
|
280
|
+
env: gitEnvironment(),
|
|
281
|
+
encoding: 'utf8',
|
|
282
|
+
timeout: GIT_TIMEOUT_MS,
|
|
283
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
284
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
continue; // diff failed → those paths stay absent → whole-blob scan
|
|
288
|
+
}
|
|
289
|
+
// Parse the multi-file diff: track the current file from the `+++ path`
|
|
290
|
+
// line, and attribute each `@@` hunk header to it. core.quotePath=false
|
|
291
|
+
// keeps spaces and non-ASCII paths literal so they match entry.path (which
|
|
292
|
+
// comes from git diff --raw -z, also unquoted). Paths containing newlines
|
|
293
|
+
// are not handled, but they fall back to a whole-blob scan (safe).
|
|
294
|
+
let currentPath = null;
|
|
295
|
+
for (const line of output.split('\n')) {
|
|
296
|
+
// With --no-prefix the +++ line is `+++ path` (no b/ prefix).
|
|
297
|
+
const plusMatch = /^\+\+\+ (.+)$/.exec(line);
|
|
298
|
+
if (plusMatch) {
|
|
299
|
+
currentPath = plusMatch[1] === '/dev/null' ? null : plusMatch[1];
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
303
|
+
if (!match || !currentPath) continue;
|
|
304
|
+
const start = Number(match[1]);
|
|
305
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
306
|
+
if (start === 0 || count === 0) continue; // pure deletion, no new content
|
|
307
|
+
const range = { start, end: start + count - 1 };
|
|
308
|
+
const existing = byPath.get(currentPath);
|
|
309
|
+
if (existing) existing.push(range);
|
|
310
|
+
else byPath.set(currentPath, [range]);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return byPath;
|
|
314
|
+
}
|