@rmyndharis/aimhooman 0.1.0 → 0.1.2
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 +201 -5
- package/CONTRIBUTING.md +43 -30
- package/README.md +14 -14
- package/SECURITY.md +8 -10
- package/bin/aimhooman.mjs +99 -28
- package/package.json +1 -1
- package/rules/attribution.json +7 -7
- package/src/atomic-write.mjs +32 -1
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +114 -20
- package/src/gitx.mjs +48 -277
- package/src/history-scan.mjs +4 -1
- package/src/hook.mjs +145 -9
- package/src/scan-session.mjs +2 -1
- package/src/scan-target.mjs +51 -23
package/src/hook.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
6
7
|
import { newEngineWithDiagnostics } from './scan.mjs';
|
|
7
8
|
import { openRepo, stagedEntries } from './gitx.mjs';
|
|
8
9
|
import { applyExclude, patternsForRules } from './exclude.mjs';
|
|
@@ -101,7 +102,12 @@ function hookPreToolUse(input) {
|
|
|
101
102
|
const executorCommand = command(input, executor);
|
|
102
103
|
if (executorCommand === null) return unknownExecutorShape(input, executor.name);
|
|
103
104
|
const rawParsed = parseGit(executorCommand);
|
|
104
|
-
if (nonPosixExecutor(executor.name))
|
|
105
|
+
if (nonPosixExecutor(executor.name)) {
|
|
106
|
+
rawParsed.uncertainShell = true;
|
|
107
|
+
// The benign-pipeline classifier is POSIX-only; never exempt a non-POSIX
|
|
108
|
+
// shell line (pwsh/fish/cmd/nu) from opaque-commit-hiding treatment.
|
|
109
|
+
rawParsed.opaqueCommitHiding = true;
|
|
110
|
+
}
|
|
105
111
|
if (executorTargetSyntaxUncertain(executor.name, executorCommand, rawParsed)
|
|
106
112
|
&& (rawParsed.addPaths.length > 0 || rawParsed.commands.some(isProtectedMutation))) {
|
|
107
113
|
return emitDecision(
|
|
@@ -325,7 +331,11 @@ function hookPreToolUse(input) {
|
|
|
325
331
|
}
|
|
326
332
|
}
|
|
327
333
|
let eng;
|
|
334
|
+
// diagnosticWarning is about the rule pack and drives the messages below;
|
|
335
|
+
// hygieneWarning is housekeeping that never touches the decision. Kept apart
|
|
336
|
+
// so the "invalid local pack skipped" wording stays true.
|
|
328
337
|
let diagnosticWarning = '';
|
|
338
|
+
let hygieneWarning = '';
|
|
329
339
|
try {
|
|
330
340
|
const loaded = repo
|
|
331
341
|
? engineForPolicy(repo, policy, policy.head)
|
|
@@ -338,7 +348,6 @@ function hookPreToolUse(input) {
|
|
|
338
348
|
return emitDecision('deny', `aimhooman strict policy could not load local rules: ${diagnosticWarning}`);
|
|
339
349
|
}
|
|
340
350
|
}
|
|
341
|
-
if (repo) applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
342
351
|
} catch (e) {
|
|
343
352
|
const reason = e?.name === 'LocalOverridesError'
|
|
344
353
|
? `aimhooman cannot load local overrides: ${e.message}`
|
|
@@ -347,6 +356,17 @@ function hookPreToolUse(input) {
|
|
|
347
356
|
if (profile === 'strict') return emitDecision('deny', reason);
|
|
348
357
|
return emitDecision('allow', `${reason}; continuing because profile=${profile}`);
|
|
349
358
|
}
|
|
359
|
+
// Refreshing the excludes is gitignore hygiene, not part of the verdict:
|
|
360
|
+
// pre-commit never writes them and still answers. Kept out of the block
|
|
361
|
+
// above so a read-only .git/info (CI checkout, read-only volume, a
|
|
362
|
+
// repository owned by another user) cannot decide what is allowed.
|
|
363
|
+
if (repo) {
|
|
364
|
+
try {
|
|
365
|
+
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
366
|
+
} catch (e) {
|
|
367
|
+
hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
350
370
|
// A strict policy cannot make a meaningful guarantee if Git's own guards
|
|
351
371
|
// are explicitly bypassed. Deny before the shell can stage-and-commit in a
|
|
352
372
|
// single command, when inspecting the future index would be impossible.
|
|
@@ -364,7 +384,7 @@ function hookPreToolUse(input) {
|
|
|
364
384
|
const prefixBypass = parsed.commands.some((candidate) => (
|
|
365
385
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
366
386
|
&& candidate.prefixRisk
|
|
367
|
-
)) || (parsed.
|
|
387
|
+
)) || (parsed.opaqueCommitHiding && parsed.commands.length === 0 && parsed.prefixRisk);
|
|
368
388
|
const aliasBypass = parsed.commands.some((candidate) => (
|
|
369
389
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
370
390
|
&& candidate.inlineAliasRisk
|
|
@@ -379,7 +399,13 @@ function hookPreToolUse(input) {
|
|
|
379
399
|
const unmodelledPrefixReason =
|
|
380
400
|
'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
|
|
381
401
|
const blocks = [];
|
|
382
|
-
|
|
402
|
+
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
403
|
+
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
404
|
+
// (gh ... | tail) was scanned and denied as if it staged a secret.
|
|
405
|
+
// opaqueCommitHiding keeps every commit-hiding shape (subshells,
|
|
406
|
+
// substitution, script-feeds, code-executing/unlisted pipe segments) while
|
|
407
|
+
// excluding pipelines of known read-only commands.
|
|
408
|
+
const potentialCommit = commit || parsed.opaqueCommitHiding;
|
|
383
409
|
if (potentialCommit && repo) {
|
|
384
410
|
try {
|
|
385
411
|
const entries = stagedEntries(repo);
|
|
@@ -457,6 +483,9 @@ function hookPreToolUse(input) {
|
|
|
457
483
|
if (diagnosticWarning) {
|
|
458
484
|
return emitDecision('allow', `aimhooman warning: ${diagnosticWarning}; invalid local pack skipped`);
|
|
459
485
|
}
|
|
486
|
+
if (hygieneWarning) {
|
|
487
|
+
return emitDecision('allow', `aimhooman warning: ${hygieneWarning}`);
|
|
488
|
+
}
|
|
460
489
|
return 0;
|
|
461
490
|
}
|
|
462
491
|
|
|
@@ -484,10 +513,8 @@ function hookPreToolUse(input) {
|
|
|
484
513
|
}
|
|
485
514
|
const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
|
|
486
515
|
const advisory = advisoryReason(blocks, guarded, bypassed ? bypassContext : '');
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
diagnosticWarning ? `${advisory} Warning: ${diagnosticWarning}.` : advisory
|
|
490
|
-
);
|
|
516
|
+
const notes = [diagnosticWarning, hygieneWarning].filter(Boolean).join('; ');
|
|
517
|
+
return emitDecision('allow', notes ? `${advisory} Warning: ${notes}.` : advisory);
|
|
491
518
|
}
|
|
492
519
|
|
|
493
520
|
function toolName(i) {
|
|
@@ -1055,11 +1082,17 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
|
|
|
1055
1082
|
: commands.some((candidate) => candidate.verb === 'commit' && candidate.futureIndex)
|
|
1056
1083
|
? 'future-index'
|
|
1057
1084
|
: 'direct';
|
|
1085
|
+
// opaqueCommitHiding narrows uncertainShell to uncertainty that can hide or
|
|
1086
|
+
// feed a commit (subshells, substitution, script-feeds, code-executing or
|
|
1087
|
+
// unlisted pipe segments). A bare pipeline of known read-only commands
|
|
1088
|
+
// (gh ... | tail) cannot, so it is not treated as a potential commit.
|
|
1089
|
+
const opaqueCommitHiding = uncertainShell && !benignReadOnlyPipeline(cmd);
|
|
1058
1090
|
return {
|
|
1059
1091
|
commit,
|
|
1060
1092
|
noVerify,
|
|
1061
1093
|
bypassHooks,
|
|
1062
1094
|
uncertainShell,
|
|
1095
|
+
opaqueCommitHiding,
|
|
1063
1096
|
classification,
|
|
1064
1097
|
environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
|
|
1065
1098
|
prefixRisk,
|
|
@@ -1196,7 +1229,12 @@ function configuredPushReceiver(repo) {
|
|
|
1196
1229
|
return execFileSync(
|
|
1197
1230
|
'git',
|
|
1198
1231
|
['config', '--get-regexp', '^remote\\..*\\.receivepack$'],
|
|
1199
|
-
{
|
|
1232
|
+
{
|
|
1233
|
+
cwd: repo.root,
|
|
1234
|
+
encoding: 'utf8',
|
|
1235
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1236
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1237
|
+
},
|
|
1200
1238
|
).trim().length > 0;
|
|
1201
1239
|
} catch (error) {
|
|
1202
1240
|
if (error?.status === 1) return false;
|
|
@@ -1431,6 +1469,7 @@ function readGitAlias(cwd, name) {
|
|
|
1431
1469
|
cwd,
|
|
1432
1470
|
encoding: 'utf8',
|
|
1433
1471
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1472
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1434
1473
|
}).trim();
|
|
1435
1474
|
} catch {
|
|
1436
1475
|
return null;
|
|
@@ -1737,6 +1776,103 @@ function shellUnits(s) {
|
|
|
1737
1776
|
return out;
|
|
1738
1777
|
}
|
|
1739
1778
|
|
|
1779
|
+
// SAFE_PIPE_EXECUTABLES are commands permitted in a "benign read-only pipeline":
|
|
1780
|
+
// they take only data arguments, never run a sub-command, never execute code,
|
|
1781
|
+
// never write files, and cannot bypass Git hooks. Anything not listed is treated
|
|
1782
|
+
// as potentially able to hide or feed a commit (fail-closed). Deliberately
|
|
1783
|
+
// excluded: shells and interpreters, awk/sed/ed/vim, sqlite3/psql/dc/bc/octave
|
|
1784
|
+
// and other stdin-as-program readers, git, tee (writes files), env/xargs/find
|
|
1785
|
+
// (run sub-commands), and curl/wget (fetch arbitrary content to a pipe).
|
|
1786
|
+
const SAFE_PIPE_EXECUTABLES = new Set([
|
|
1787
|
+
'cat', 'tac', 'head', 'tail', 'tr', 'cut', 'paste', 'fold', 'fmt', 'expand',
|
|
1788
|
+
'unexpand', 'rev', 'nl', 'pr', 'column', 'sort', 'uniq', 'comm', 'tsort',
|
|
1789
|
+
'shuf', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'xxd', 'od',
|
|
1790
|
+
'hexdump', 'base64', 'base32', 'md5sum', 'sha1sum', 'sha224sum', 'sha256sum',
|
|
1791
|
+
'sha384sum', 'sha512sum', 'cksum', 'b2sum', 'sum', 'file', 'ls', 'exa', 'eza',
|
|
1792
|
+
'lsd', 'tree', 'du', 'df', 'stat', 'date', 'whoami', 'id', 'uname',
|
|
1793
|
+
'hostname', 'uptime', 'printenv', 'pwd', 'basename', 'dirname', 'realpath',
|
|
1794
|
+
'seq', 'yes', 'echo', 'printf', 'test', 'true', 'false', 'jq', 'yq', 'bat',
|
|
1795
|
+
'gh', '[',
|
|
1796
|
+
]);
|
|
1797
|
+
|
|
1798
|
+
// containsOpaquePipeLexeme is a quote-aware scan for shell constructs that can
|
|
1799
|
+
// hide or feed a commit or subcommand: command substitution, subshells, brace
|
|
1800
|
+
// expansion/groups, script-feed redirects, and command separators. Output
|
|
1801
|
+
// redirects (>, >>) and fd-dups such as 2>&1 are intentionally NOT opaque: they
|
|
1802
|
+
// cannot feed code into a pipe sink. A bare & (background) is opaque unless it
|
|
1803
|
+
// is part of an fd-dup (>& / <&) or &&.
|
|
1804
|
+
function containsOpaquePipeLexeme(command) {
|
|
1805
|
+
let quote = '';
|
|
1806
|
+
let escaped = false;
|
|
1807
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1808
|
+
const char = command[i];
|
|
1809
|
+
const prev = command[i - 1];
|
|
1810
|
+
const next = command[i + 1];
|
|
1811
|
+
if (escaped) { escaped = false; continue; }
|
|
1812
|
+
if (char === '\\' && quote !== "'") { escaped = true; continue; }
|
|
1813
|
+
if (quote) {
|
|
1814
|
+
if (quote === '"' && (char === '`' || (char === '$' && next === '('))) return true;
|
|
1815
|
+
if (char === quote) quote = '';
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
if (char === "'" || char === '"') { quote = char; continue; }
|
|
1819
|
+
if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}') return true;
|
|
1820
|
+
if (char === '$' && (next === '(' || next === '{')) return true;
|
|
1821
|
+
if (char === '<' || char === ';') return true;
|
|
1822
|
+
if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
|
|
1823
|
+
}
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// splitPipeSegments splits a command on an unquoted pipe (|) that is not part
|
|
1828
|
+
// of ||. Unlike shellUnits it does not break on &, so 2>&1 stays within a
|
|
1829
|
+
// segment.
|
|
1830
|
+
function splitPipeSegments(command) {
|
|
1831
|
+
const segments = [];
|
|
1832
|
+
let segment = '';
|
|
1833
|
+
let quote = '';
|
|
1834
|
+
let escaped = false;
|
|
1835
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1836
|
+
const char = command[i];
|
|
1837
|
+
if (escaped) { segment += char; escaped = false; continue; }
|
|
1838
|
+
if (char === '\\' && quote !== "'") { segment += char; escaped = true; continue; }
|
|
1839
|
+
if (quote) { segment += char; if (char === quote) quote = ''; continue; }
|
|
1840
|
+
if (char === "'" || char === '"') { segment += char; quote = char; continue; }
|
|
1841
|
+
if (char === '|' && command[i + 1] !== '|') { segments.push(segment); segment = ''; continue; }
|
|
1842
|
+
segment += char;
|
|
1843
|
+
}
|
|
1844
|
+
segments.push(segment);
|
|
1845
|
+
return segments;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// pipelineSegmentExecutable returns the shellExecutable basename of the first
|
|
1849
|
+
// non-environment-assignment token in a pipe segment, or null when the segment
|
|
1850
|
+
// has no command. A leading redirect token (e.g. 2>&1 before the command) is
|
|
1851
|
+
// treated as unlisted -> null -> not benign (fail-closed), which is safe.
|
|
1852
|
+
function pipelineSegmentExecutable(segment) {
|
|
1853
|
+
for (const word of shellWords(segment.trim())) {
|
|
1854
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
|
|
1855
|
+
return shellExecutable(word);
|
|
1856
|
+
}
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// benignReadOnlyPipeline reports whether a command is a bare pipeline whose
|
|
1861
|
+
// every segment is a known non-code-executing, non-mutating command with no
|
|
1862
|
+
// opaque shell syntax. Such a pipe cannot hide or feed a commit, so it is not a
|
|
1863
|
+
// "potential commit" for the unmodelled-prefix guard. Fail-closed: any opaque
|
|
1864
|
+
// lexeme, unlisted executable, missing executable, or non-pipe returns false.
|
|
1865
|
+
function benignReadOnlyPipeline(command) {
|
|
1866
|
+
if (!command.includes('|')) return false;
|
|
1867
|
+
if (containsOpaquePipeLexeme(command)) return false;
|
|
1868
|
+
const segments = splitPipeSegments(command);
|
|
1869
|
+
if (segments.length < 2) return false;
|
|
1870
|
+
return segments.every((segment) => {
|
|
1871
|
+
const executable = pipelineSegmentExecutable(segment);
|
|
1872
|
+
return executable !== null && SAFE_PIPE_EXECUTABLES.has(executable);
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1740
1876
|
function pipedShellCommitInvocations(source, initialCwd) {
|
|
1741
1877
|
const units = shellUnits(source);
|
|
1742
1878
|
const commits = [];
|
package/src/scan-session.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { gitEnvironment } from './git-environment.mjs';
|
|
2
|
+
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_SCAN_LIMITS = Object.freeze({
|
|
5
5
|
maxFileBytes: 2 << 20,
|
|
@@ -112,6 +112,7 @@ function readObjects(repo, objectIds, expectedBytes = 0) {
|
|
|
112
112
|
input: Buffer.from(unique.join('\n') + '\n'),
|
|
113
113
|
encoding: 'buffer',
|
|
114
114
|
maxBuffer: Math.max(2 * 1024 * 1024, expectedBytes + unique.length * 256 + 1024),
|
|
115
|
+
timeout: GIT_TIMEOUT_MS,
|
|
115
116
|
});
|
|
116
117
|
const objects = new Map();
|
|
117
118
|
const failures = [];
|
package/src/scan-target.mjs
CHANGED
|
@@ -49,6 +49,7 @@ export function scanMessage(repo, text, options = {}) {
|
|
|
49
49
|
const { policy, head } = resolved;
|
|
50
50
|
const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
|
|
51
51
|
const accumulator = createAccumulator(options.limits);
|
|
52
|
+
accumulator.addSkipped(loaded.skipped);
|
|
52
53
|
accumulator.add(loaded.engine.checkMessage(text).map((finding) => decorate(finding, null, policy)));
|
|
53
54
|
accumulator.addSkipped(loaded.engine.takeSkipped());
|
|
54
55
|
return {
|
|
@@ -68,6 +69,7 @@ function scanIndex(repo, options) {
|
|
|
68
69
|
const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
|
|
69
70
|
const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
|
|
70
71
|
const accumulator = createAccumulator(options.limits);
|
|
72
|
+
accumulator.addSkipped(loaded.skipped);
|
|
71
73
|
let entries = kind === 'tracked' ? trackedEntries(repo) : stagedEntries(repo);
|
|
72
74
|
const conflicts = unmergedPaths(repo);
|
|
73
75
|
const diagnostics = [...loaded.diagnostics];
|
|
@@ -189,6 +191,7 @@ function scanCommit(repo, options) {
|
|
|
189
191
|
reviewContexts,
|
|
190
192
|
);
|
|
191
193
|
const accumulator = createAccumulator(options.limits);
|
|
194
|
+
accumulator.addSkipped(loaded.skipped);
|
|
192
195
|
const diagnostics = [...loaded.diagnostics];
|
|
193
196
|
|
|
194
197
|
if (snapshot.shallowBoundary) {
|
|
@@ -261,6 +264,9 @@ function scanRange(repo, options) {
|
|
|
261
264
|
// only constructs an Engine and applies overrides (see engineForPolicy).
|
|
262
265
|
const preloaded = loadRulesWithDiagnostics(repo.stateDir);
|
|
263
266
|
const preloadedOverrides = loadOverrides(repo.stateDir);
|
|
267
|
+
// Counted once for the range, not once per commit: the same packs back every
|
|
268
|
+
// commit's engine, so the per-commit loaded.skipped would inflate the tally.
|
|
269
|
+
accumulator.addSkipped(packSkipped(preloaded.errors));
|
|
264
270
|
|
|
265
271
|
for (const commit of history.commits) {
|
|
266
272
|
const changes = commitChanges(repo, commit.commit, commit.commit, commit.parents);
|
|
@@ -363,27 +369,38 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
363
369
|
|
|
364
370
|
function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
|
|
365
371
|
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
|
-
}
|
|
372
|
+
// A deleted path is gone, so only structural policy rules (agent
|
|
373
|
+
// instructions, project policy) can still matter: deleting a secret or a
|
|
374
|
+
// session file is hygiene, not a violation. A renamed-away path is
|
|
375
|
+
// different — its content survives under a new name, so a path-only
|
|
376
|
+
// secret (e.g. .env) must still fire or a `git mv` to a neutral name
|
|
377
|
+
// would slip past the destination scan, which only catches content-shaped
|
|
378
|
+
// secrets. That finding is reported on the destination path (where the
|
|
379
|
+
// bytes now live) so clean-profile repair unstages the blob that carries
|
|
380
|
+
// the secret rather than the old name.
|
|
381
|
+
const deleted = entry.status === 'D' || entry.type === 'deleted';
|
|
382
|
+
const renamed = entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path;
|
|
383
|
+
if (!deleted && !renamed) continue;
|
|
384
|
+
const sourcePath = deleted ? entry.path : entry.sourcePath;
|
|
385
|
+
const context = {
|
|
386
|
+
objectId: null,
|
|
387
|
+
mode: null,
|
|
388
|
+
transition: options.transition ?? entry.commit,
|
|
389
|
+
...(options.allowMissingPolicy && sourcePath === '.aimhooman.json'
|
|
390
|
+
? { transientAllowRules: new Set(['generic.project-policy']) }
|
|
391
|
+
: {}),
|
|
392
|
+
};
|
|
393
|
+
const findings = engine.checkPaths([sourcePath], context).filter((finding) => {
|
|
394
|
+
if (finding.matchedRuleIds?.some((ruleId) => (
|
|
395
|
+
ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
|
|
396
|
+
))) return true;
|
|
397
|
+
return renamed && finding.category === 'secret';
|
|
398
|
+
});
|
|
399
|
+
accumulator.add(findings.map((finding) => decorate(
|
|
400
|
+
renamed && finding.category === 'secret' ? { ...finding, path: entry.path } : finding,
|
|
401
|
+
entry,
|
|
402
|
+
policy,
|
|
403
|
+
)));
|
|
387
404
|
}
|
|
388
405
|
}
|
|
389
406
|
|
|
@@ -441,7 +458,14 @@ export function engineForPolicy(
|
|
|
441
458
|
[...reviewedInstructions, ...reviewedPolicies],
|
|
442
459
|
);
|
|
443
460
|
const diagnostics = loaded.errors.map((error) => ({ level: 'warning', message: `${error.message}; pack skipped` }));
|
|
444
|
-
return { engine: loaded.engine, diagnostics };
|
|
461
|
+
return { engine: loaded.engine, diagnostics, skipped: packSkipped(loaded.errors) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// packSkipped turns rule-pack load failures into a counted skip reason. A pack
|
|
465
|
+
// that never loaded is a hole in coverage, not an empty result: strict throws
|
|
466
|
+
// above, and clean/compliance proceed but must not report a complete scan.
|
|
467
|
+
function packSkipped(errors) {
|
|
468
|
+
return errors.length ? { 'local-pack-error': errors.length } : {};
|
|
445
469
|
}
|
|
446
470
|
|
|
447
471
|
function reviewedEngineEntry(entry, ruleId, transition) {
|
|
@@ -578,6 +602,10 @@ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfil
|
|
|
578
602
|
};
|
|
579
603
|
}
|
|
580
604
|
|
|
605
|
+
// Skip reasons that mean a rule never ran, as opposed to a rule running and
|
|
606
|
+
// matching nothing. Either way the scan cannot claim to have covered its input.
|
|
607
|
+
const INCOMPLETE_SKIP_REASONS = new Set(['local-input-limit', 'local-pack-error']);
|
|
608
|
+
|
|
581
609
|
function createAccumulator(limits = {}) {
|
|
582
610
|
const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
|
|
583
611
|
const findings = [];
|
|
@@ -633,7 +661,7 @@ function createAccumulator(limits = {}) {
|
|
|
633
661
|
addSkipped(skipped = {}) {
|
|
634
662
|
for (const [reason, count] of Object.entries(skipped)) {
|
|
635
663
|
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
636
|
-
if (reason
|
|
664
|
+
if (INCOMPLETE_SKIP_REASONS.has(reason)) complete = false;
|
|
637
665
|
}
|
|
638
666
|
},
|
|
639
667
|
markIncomplete() {
|