@rmyndharis/aimhooman 0.1.7 → 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 +69 -0
- package/README.md +1 -1
- package/bin/aimhooman.mjs +318 -236
- 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 +79 -4
- package/src/report.mjs +27 -2
- package/src/scan-session.mjs +77 -1
- package/src/scan.mjs +40 -7
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
|
@@ -447,10 +447,16 @@ function hookPreToolUse(input) {
|
|
|
447
447
|
// stdin) can hide a commit or run arbitrary commands, and there is no argv
|
|
448
448
|
// to read a --no-verify out of. The earlier message reused the commit text
|
|
449
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.
|
|
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 `|`.
|
|
451
455
|
const opaquePipelineReason = commit
|
|
452
456
|
? unmodelledPrefixReason
|
|
453
|
-
:
|
|
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.';
|
|
454
460
|
const blocks = [];
|
|
455
461
|
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
456
462
|
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
@@ -1961,7 +1967,18 @@ function hasUncertainShellSyntax(command) {
|
|
|
1961
1967
|
quote = char;
|
|
1962
1968
|
continue;
|
|
1963
1969
|
}
|
|
1964
|
-
|
|
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;
|
|
1965
1982
|
if (char === '|' && command[i + 1] !== '|') return true;
|
|
1966
1983
|
if (char === '&' && command[i + 1] !== '&' && command[i - 1] !== '&') return true;
|
|
1967
1984
|
}
|
|
@@ -1971,6 +1988,57 @@ function hasUncertainShellSyntax(command) {
|
|
|
1971
1988
|
});
|
|
1972
1989
|
}
|
|
1973
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
|
+
|
|
1974
2042
|
function shellUnits(s) {
|
|
1975
2043
|
const out = [];
|
|
1976
2044
|
let segment = '';
|
|
@@ -2146,7 +2214,14 @@ function containsOpaquePipeLexeme(command) {
|
|
|
2146
2214
|
continue;
|
|
2147
2215
|
}
|
|
2148
2216
|
if (char === "'" || char === '"') { quote = char; continue; }
|
|
2149
|
-
|
|
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;
|
|
2150
2225
|
if (char === '$' && (next === '(' || next === '{')) return true;
|
|
2151
2226
|
if (char === '<' || char === ';') return true;
|
|
2152
2227
|
if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
|
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
|
+
}
|
package/src/scan.mjs
CHANGED
|
@@ -51,13 +51,35 @@ export class Engine {
|
|
|
51
51
|
decide(base, target, rule, context = {}) {
|
|
52
52
|
if (this.denyPaths.has(target) || this.denyRules.has(rule.id)) return 'block';
|
|
53
53
|
if (context.transientAllowRules?.has(rule.id)) return 'allow';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
// W11 (F2): review binding is profile-dependent.
|
|
55
|
+
// - strict: review binds the EXACT reviewed blob. A changed/copied/
|
|
56
|
+
// symlinked/mode-flipped target is NOT covered — it must be re-
|
|
57
|
+
// reviewed. This is the security property the strict lifecycle tests
|
|
58
|
+
// pin, and strict is the profile that exits 11 on review.
|
|
59
|
+
// - clean/compliance: review is ADVISORY (a stderr message, exit 0),
|
|
60
|
+
// so re-surfacing it on every edit of a reviewed agent-instruction
|
|
61
|
+
// file (CLAUDE.md, AGENTS.md) is pure friction with no security
|
|
62
|
+
// payoff — the security boundary is secret scanning, strict blocks,
|
|
63
|
+
// and reference-transaction. A LIVE-FILE review (newObjectId set)
|
|
64
|
+
// therefore persists per path + rule across edits. A TOMBSTONE
|
|
65
|
+
// review (newObjectId null — a reviewed deletion) keeps the exact
|
|
66
|
+
// match in every profile: a deletion review must not silently cover
|
|
67
|
+
// a later re-adding of the file.
|
|
68
|
+
const strictReview = this.profile === 'strict';
|
|
69
|
+
if (this.scopedAllow.some((entry) => {
|
|
70
|
+
if (entry.target !== target || entry.ruleId !== rule.id) return false;
|
|
71
|
+
if (entry.newObjectId === null) {
|
|
72
|
+
// Tombstone: exact match in every profile.
|
|
73
|
+
return entry.transition === context.transition
|
|
74
|
+
&& entry.newObjectId === context.objectId
|
|
75
|
+
&& entry.newMode === context.mode;
|
|
76
|
+
}
|
|
77
|
+
// Live-file review: exact in strict; path-anchored in clean/compliance.
|
|
78
|
+
return !strictReview
|
|
79
|
+
|| (entry.transition === context.transition
|
|
80
|
+
&& entry.newObjectId === context.objectId
|
|
81
|
+
&& entry.newMode === context.mode);
|
|
82
|
+
})) return 'allow';
|
|
61
83
|
// A rule-level allow cannot bypass the secret-category guard that the
|
|
62
84
|
// path-level allow below enforces: secret rules require an explicit
|
|
63
85
|
// --scope secret-path override on a specific path, never a blanket rule allow.
|
|
@@ -119,7 +141,18 @@ export class Engine {
|
|
|
119
141
|
const categories = Array.isArray(options.categories)
|
|
120
142
|
? new Set(options.categories)
|
|
121
143
|
: null;
|
|
144
|
+
// lineRanges narrows content scanning to changed hunks (W4, bug 12d-F1).
|
|
145
|
+
// When provided (an array of inclusive 1-based {start,end} ranges), only
|
|
146
|
+
// lines falling in a range are evaluated; this stops a file that contains
|
|
147
|
+
// a secret-bearing line ELSEWHERE (a PEM header inside a test string on
|
|
148
|
+
// line 200) from blocking a commit that only edited line 50. Findings
|
|
149
|
+
// keep their real line numbers because lineRecords already anchors them.
|
|
150
|
+
// Absent = scan every line (the pre-W4 behaviour).
|
|
151
|
+
const ranges = Array.isArray(options.lineRanges) && options.lineRanges.length
|
|
152
|
+
? options.lineRanges
|
|
153
|
+
: null;
|
|
122
154
|
for (const record of lineRecords(String(content))) {
|
|
155
|
+
if (ranges && !ranges.some((range) => record.line >= range.start && record.line <= range.end)) continue;
|
|
123
156
|
this.#markLocalInputSkip('code', p, record.text, categories);
|
|
124
157
|
const result = this.#evaluate('code', p, record.text, { categories });
|
|
125
158
|
if (result) out.push(finding(result, { path: p, line: record.line, text: record.text }));
|