@rmyndharis/aimhooman 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +1 -1
- package/CHANGELOG.md +91 -2
- package/README.md +11 -6
- package/bin/aimhooman.mjs +231 -91
- package/docs/cli-reference.md +120 -0
- package/docs/faq.md +62 -0
- package/docs/integrations.md +108 -0
- package/docs/policy.md +82 -0
- package/docs/secrets.md +83 -0
- package/package.json +6 -1
- package/src/args.mjs +2 -7
- package/src/githooks.mjs +65 -5
- package/src/gitx.mjs +1 -13
- package/src/hook.mjs +14 -3
- package/src/report.mjs +22 -8
- package/src/scan-session.mjs +14 -6
- package/src/scan-target.mjs +55 -19
- package/src/state.mjs +56 -16
package/src/report.mjs
CHANGED
|
@@ -9,13 +9,17 @@ const HUMAN_FINDING_CAP = 20;
|
|
|
9
9
|
|
|
10
10
|
export function human(findings, tone) {
|
|
11
11
|
if (!findings.length) return '';
|
|
12
|
-
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
13
12
|
let block = 0;
|
|
14
13
|
let review = 0;
|
|
15
14
|
for (const f of findings) {
|
|
16
15
|
if (f.decision === 'block') block += 1;
|
|
17
16
|
else if (f.decision === 'review') review += 1;
|
|
18
17
|
}
|
|
18
|
+
// The banner means "aimhooman stopped or reshaped your operation". A pure
|
|
19
|
+
// advisory (review findings only, the commit proceeds untouched) prints
|
|
20
|
+
// without it — otherwise the banner fires on allowed work and stops
|
|
21
|
+
// meaning anything when a real block lands.
|
|
22
|
+
let out = tone === 'professional' || block === 0 ? '\n' : '\nnot very hooman.\n\n';
|
|
19
23
|
// Render findings in order up to the cap, then collapse the rest into one
|
|
20
24
|
// summary line. Keeps the first findings fully visible (the actionable ones)
|
|
21
25
|
// while bounding stderr for repeated-rule scans.
|
|
@@ -34,10 +38,11 @@ export function human(findings, tone) {
|
|
|
34
38
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
35
39
|
}
|
|
36
40
|
// Render the whole remediation array, not just the first entry. Several
|
|
37
|
-
// rules carry a second line (e.g. "
|
|
38
|
-
// that the previous single-index render
|
|
39
|
-
// rule's fix once — a repeated rule
|
|
40
|
-
// used to reprint the identical fix
|
|
41
|
+
// rules carry a second line (e.g. "or unstage if it is personal" on
|
|
42
|
+
// generic.agent-instructions) that the previous single-index render
|
|
43
|
+
// dropped silently. UT-08: print a rule's fix once — a repeated rule
|
|
44
|
+
// (20 hits of the same path rule) used to reprint the identical fix
|
|
45
|
+
// block for every finding.
|
|
41
46
|
const remedies = f.remediation || [];
|
|
42
47
|
if (remedies.length && fixesPrinted.has(f.ruleId)) {
|
|
43
48
|
out += ` fix: as above for ${f.ruleId}\n`;
|
|
@@ -91,8 +96,13 @@ function isSensitive(finding) {
|
|
|
91
96
|
// final ref boundary (failClosedIncomplete). Frictionless profiles warn and
|
|
92
97
|
// continue — the reference-transaction guard still scans introduced commits
|
|
93
98
|
// with failClosedIncomplete set, so the skipped content is checked before any
|
|
94
|
-
// ref moves.
|
|
95
|
-
|
|
99
|
+
// ref moves. One exception at that final boundary: a scan whose only gap is
|
|
100
|
+
// 'size-limit' (a file over maxFileBytes went unscanned for content rules —
|
|
101
|
+
// path rules still ran) warns and continues on frictionless profiles. Blocking
|
|
102
|
+
// the whole commit there cost developers a legit large file for a marker risk
|
|
103
|
+
// that clean/compliance treat as advisory anyway. Strict stays fail-closed on
|
|
104
|
+
// any gap, size-limit included.
|
|
105
|
+
export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false, incompleteReasons = [] } = {}) {
|
|
96
106
|
let block = false;
|
|
97
107
|
let review = false;
|
|
98
108
|
for (const f of findings) {
|
|
@@ -100,7 +110,11 @@ export function exitCode(findings, profile, complete = true, { failClosedIncompl
|
|
|
100
110
|
else if (f.decision === 'review') review = true;
|
|
101
111
|
}
|
|
102
112
|
if (block) return 10;
|
|
103
|
-
if (!complete && (profile === 'strict' || failClosedIncomplete))
|
|
113
|
+
if (!complete && (profile === 'strict' || failClosedIncomplete)) {
|
|
114
|
+
const sizeLimitOnly = incompleteReasons.length > 0
|
|
115
|
+
&& incompleteReasons.every((reason) => reason === 'size-limit');
|
|
116
|
+
if (profile === 'strict' || !sizeLimitOnly) return 31;
|
|
117
|
+
}
|
|
104
118
|
if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
|
|
105
119
|
return 0;
|
|
106
120
|
}
|
package/src/scan-session.mjs
CHANGED
|
@@ -7,6 +7,17 @@ export const DEFAULT_SCAN_LIMITS = Object.freeze({
|
|
|
7
7
|
maxFindings: 1000,
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
+
// Skip reasons that mean a rule never ran on its input, so the scan cannot
|
|
11
|
+
// claim full coverage. What that costs is the caller's call: strict stops on
|
|
12
|
+
// any of them; the final ref guard stops on all but 'size-limit' (see
|
|
13
|
+
// exitCode in report.mjs). 'local-pack-error' never originates here — it is
|
|
14
|
+
// tallied by scan-target — but it belongs to the same contract.
|
|
15
|
+
export const FATAL_SKIP_REASONS = new Set([
|
|
16
|
+
'metadata-unavailable', 'size-limit', 'total-byte-limit',
|
|
17
|
+
'missing-object', 'unexpected-object', 'finding-limit',
|
|
18
|
+
'local-input-limit', 'local-pack-error',
|
|
19
|
+
]);
|
|
20
|
+
|
|
10
21
|
export function scanEntries(repo, engine, entries, options = {}) {
|
|
11
22
|
const limits = { ...DEFAULT_SCAN_LIMITS, ...options };
|
|
12
23
|
const stats = {
|
|
@@ -131,12 +142,9 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
131
142
|
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
]);
|
|
138
|
-
const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
|
|
139
|
-
return { findings, complete, stats };
|
|
145
|
+
const complete = !Object.keys(stats.skipped).some((reason) => FATAL_SKIP_REASONS.has(reason));
|
|
146
|
+
const incompleteReasons = Object.keys(stats.skipped).filter((reason) => FATAL_SKIP_REASONS.has(reason));
|
|
147
|
+
return { findings, complete, incompleteReasons, stats };
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
function readObjects(repo, objectIds, expectedBytes = 0) {
|
package/src/scan-target.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
unmergedPaths,
|
|
9
9
|
} from './gitx.mjs';
|
|
10
10
|
import { commitChanges, commitMessage, commitSnapshot, historyRange } from './history-scan.mjs';
|
|
11
|
-
import { DEFAULT_SCAN_LIMITS, scanEntries } from './scan-session.mjs';
|
|
11
|
+
import { DEFAULT_SCAN_LIMITS, FATAL_SKIP_REASONS, scanEntries } from './scan-session.mjs';
|
|
12
12
|
import { loadRulesWithDiagnostics } from './rules.mjs';
|
|
13
13
|
|
|
14
14
|
// A tiebreak for the one profile a range report has to name, not a strength
|
|
@@ -75,7 +75,7 @@ export function scanMessage(repo, text, options = {}) {
|
|
|
75
75
|
|
|
76
76
|
function scanIndex(repo, options) {
|
|
77
77
|
const kind = options.kind || 'staged';
|
|
78
|
-
const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
|
|
78
|
+
const { rawPolicy, floor, policy, head, acknowledged, baselineObjectId } = resolveStagedPolicy(repo, options.explicitProfile);
|
|
79
79
|
const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
|
|
80
80
|
const accumulator = createAccumulator(options.limits);
|
|
81
81
|
accumulator.addSkipped(loaded.skipped);
|
|
@@ -96,11 +96,14 @@ function scanIndex(repo, options) {
|
|
|
96
96
|
});
|
|
97
97
|
// A reviewed-path override can suppress a matching path rule, but it
|
|
98
98
|
// cannot turn an unresolved index into a complete commit snapshot.
|
|
99
|
-
accumulator.markIncomplete();
|
|
99
|
+
accumulator.markIncomplete('index-unresolved');
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
103
|
-
accumulator.add([protectedPolicyFinding(rawPolicy, policy, null
|
|
103
|
+
accumulator.add([protectedPolicyFinding(rawPolicy, policy, null, {
|
|
104
|
+
oldObjectId: baselineObjectId,
|
|
105
|
+
transition: 'staged',
|
|
106
|
+
})]);
|
|
104
107
|
}
|
|
105
108
|
scanEntryGroup(repo, loaded.engine, entries, policy, accumulator, {
|
|
106
109
|
allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
|
|
@@ -140,6 +143,7 @@ export function resolveStagedPolicy(repo, explicitProfile) {
|
|
|
140
143
|
floor,
|
|
141
144
|
head,
|
|
142
145
|
acknowledged,
|
|
146
|
+
baselineObjectId: baselineStrict ? baseline.policy_object_id : null,
|
|
143
147
|
policy: effectivePolicy(
|
|
144
148
|
rawPolicy,
|
|
145
149
|
explicitProfile,
|
|
@@ -209,11 +213,14 @@ function scanCommit(repo, options) {
|
|
|
209
213
|
throw new GitRevisionError(options.revision, message);
|
|
210
214
|
}
|
|
211
215
|
diagnostics.push({ level: 'warning', message });
|
|
212
|
-
accumulator.markIncomplete();
|
|
216
|
+
accumulator.markIncomplete('shallow-repository');
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
216
|
-
accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot
|
|
220
|
+
accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot, {
|
|
221
|
+
oldObjectId: strictParentPolicies[0]?.policy_object_id ?? null,
|
|
222
|
+
transition: snapshot.commit,
|
|
223
|
+
})]);
|
|
217
224
|
}
|
|
218
225
|
// A commit is judged by what it changes, not by the whole tree it inherits.
|
|
219
226
|
// Scanning snapshot.entries (a full `ls-tree`) re-applied path rules to
|
|
@@ -284,7 +291,7 @@ function scanRange(repo, options) {
|
|
|
284
291
|
throw new GitRevisionError(options.range, message);
|
|
285
292
|
}
|
|
286
293
|
diagnostics.push({ level: 'warning', message });
|
|
287
|
-
accumulator.markIncomplete();
|
|
294
|
+
accumulator.markIncomplete('shallow-repository');
|
|
288
295
|
}
|
|
289
296
|
const policies = [];
|
|
290
297
|
let usedStrictFloor = false;
|
|
@@ -336,7 +343,10 @@ function scanRange(repo, options) {
|
|
|
336
343
|
diagnostics.push(...loaded.diagnostics);
|
|
337
344
|
|
|
338
345
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
339
|
-
accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit
|
|
346
|
+
accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit, {
|
|
347
|
+
oldObjectId: [...parentStrictObjects][0] ?? null,
|
|
348
|
+
transition: commit.commit,
|
|
349
|
+
})]);
|
|
340
350
|
}
|
|
341
351
|
scanEntryGroup(repo, loaded.engine, changes.entries, policy, accumulator, {
|
|
342
352
|
allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
|
|
@@ -556,12 +566,27 @@ function isVersionedStrict(policy) {
|
|
|
556
566
|
));
|
|
557
567
|
}
|
|
558
568
|
|
|
559
|
-
function protectedPolicyFinding(rawPolicy, policy, entry) {
|
|
569
|
+
function protectedPolicyFinding(rawPolicy, policy, entry, migration = {}) {
|
|
560
570
|
const removed = !rawPolicy.policy_object_id;
|
|
561
571
|
const reason = removed
|
|
562
572
|
? 'A versioned strict project policy cannot be deleted or renamed away without a bound review acknowledgment.'
|
|
563
573
|
: 'A versioned strict project policy cannot be downgraded without a bound review acknowledgment.';
|
|
564
|
-
|
|
574
|
+
// Name the exact command, oids included: "use the reviewed migration
|
|
575
|
+
// command" sent developers to the docs for a three-flag incantation they
|
|
576
|
+
// could not guess (field-test rerun). The staged form binds to the live
|
|
577
|
+
// index; the transition form binds to the commit that carries the change.
|
|
578
|
+
const remediation = ['Restore the strict policy, or unstage the change to keep the current baseline.'];
|
|
579
|
+
if (migration.oldObjectId && migration.transition) {
|
|
580
|
+
const newOid = rawPolicy.policy_object_id ?? 'missing';
|
|
581
|
+
const form = migration.transition === 'staged'
|
|
582
|
+
? '--staged'
|
|
583
|
+
: `--transition ${migration.transition}`;
|
|
584
|
+
remediation.push(
|
|
585
|
+
`or acknowledge the migration: aimhooman policy-review --head HEAD ${form} --old ${migration.oldObjectId} --new ${newOid} --reason "reviewed policy migration"`
|
|
586
|
+
);
|
|
587
|
+
} else {
|
|
588
|
+
remediation.push('or acknowledge the migration with a reviewed aimhooman policy-review invocation.');
|
|
589
|
+
}
|
|
565
590
|
return decorate({
|
|
566
591
|
ruleId: 'generic.project-policy',
|
|
567
592
|
ruleVersion: 1,
|
|
@@ -637,10 +662,10 @@ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfil
|
|
|
637
662
|
};
|
|
638
663
|
}
|
|
639
664
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
665
|
+
// The canonical set of fatal skip reasons lives in scan-session.mjs
|
|
666
|
+
// (FATAL_SKIP_REASONS); the accumulator records which of them fired so callers
|
|
667
|
+
// can tell "only an oversized file went unscanned" apart from gaps that must
|
|
668
|
+
// stay fail-closed.
|
|
644
669
|
function createAccumulator(limits = {}) {
|
|
645
670
|
const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
|
|
646
671
|
const findings = [];
|
|
@@ -657,6 +682,12 @@ function createAccumulator(limits = {}) {
|
|
|
657
682
|
skippedPaths: {},
|
|
658
683
|
};
|
|
659
684
|
let complete = true;
|
|
685
|
+
const incompleteReasons = new Set();
|
|
686
|
+
|
|
687
|
+
function flagIncomplete(reason) {
|
|
688
|
+
complete = false;
|
|
689
|
+
incompleteReasons.add(reason);
|
|
690
|
+
}
|
|
660
691
|
|
|
661
692
|
function add(values, total = values.length) {
|
|
662
693
|
stats.findings_total += total;
|
|
@@ -667,7 +698,7 @@ function createAccumulator(limits = {}) {
|
|
|
667
698
|
continue;
|
|
668
699
|
}
|
|
669
700
|
if (findings.length >= effectiveLimits.maxFindings) {
|
|
670
|
-
|
|
701
|
+
flagIncomplete('finding-limit');
|
|
671
702
|
increment(stats.skipped, 'finding-limit');
|
|
672
703
|
continue;
|
|
673
704
|
}
|
|
@@ -675,7 +706,7 @@ function createAccumulator(limits = {}) {
|
|
|
675
706
|
findings.push(finding);
|
|
676
707
|
}
|
|
677
708
|
stats.findings_returned = findings.length;
|
|
678
|
-
if (total > values.length)
|
|
709
|
+
if (total > values.length) flagIncomplete('finding-limit');
|
|
679
710
|
}
|
|
680
711
|
|
|
681
712
|
return {
|
|
@@ -698,16 +729,17 @@ function createAccumulator(limits = {}) {
|
|
|
698
729
|
if (stats.skippedPaths[reason].length < 10) stats.skippedPaths[reason].push(entry);
|
|
699
730
|
}
|
|
700
731
|
}
|
|
732
|
+
for (const reason of scanned.incompleteReasons || []) incompleteReasons.add(reason);
|
|
701
733
|
if (!scanned.complete) complete = false;
|
|
702
734
|
},
|
|
703
735
|
addSkipped(skipped = {}) {
|
|
704
736
|
for (const [reason, count] of Object.entries(skipped)) {
|
|
705
737
|
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
706
|
-
if (
|
|
738
|
+
if (FATAL_SKIP_REASONS.has(reason)) flagIncomplete(reason);
|
|
707
739
|
}
|
|
708
740
|
},
|
|
709
|
-
markIncomplete() {
|
|
710
|
-
|
|
741
|
+
markIncomplete(reason = 'target-unverifiable') {
|
|
742
|
+
flagIncomplete(reason);
|
|
711
743
|
},
|
|
712
744
|
remaining() {
|
|
713
745
|
return Math.max(0, effectiveLimits.maxFindings - findings.length);
|
|
@@ -718,6 +750,9 @@ function createAccumulator(limits = {}) {
|
|
|
718
750
|
isComplete() {
|
|
719
751
|
return complete;
|
|
720
752
|
},
|
|
753
|
+
get incompleteReasons() {
|
|
754
|
+
return [...incompleteReasons];
|
|
755
|
+
},
|
|
721
756
|
};
|
|
722
757
|
}
|
|
723
758
|
|
|
@@ -731,6 +766,7 @@ function result({ target, policy, accumulator, diagnostics, messageScanned, comm
|
|
|
731
766
|
policy_enforced_object_ids: policy.enforced_policy_object_ids,
|
|
732
767
|
} : {}),
|
|
733
768
|
complete: accumulator.isComplete(),
|
|
769
|
+
incomplete_reasons: accumulator.incompleteReasons,
|
|
734
770
|
stats: accumulator.stats,
|
|
735
771
|
findings: accumulator.findings,
|
|
736
772
|
diagnostics,
|
package/src/state.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
readFileSync,
|
|
5
5
|
} from 'node:fs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
|
-
import { atomicWrite } from './atomic-write.mjs';
|
|
7
|
+
import { atomicWrite, withLock } from './atomic-write.mjs';
|
|
8
8
|
import { normalizeGitPath } from './git-path.mjs';
|
|
9
9
|
|
|
10
10
|
// Per-repository state, stored in the common Git dir (never the worktree).
|
|
@@ -105,8 +105,20 @@ export function parseProjectPolicy(text, file = '.aimhooman.json') {
|
|
|
105
105
|
|
|
106
106
|
export function loadConfig(stateDir, root) {
|
|
107
107
|
const project = loadProjectPolicy(root);
|
|
108
|
-
if (project) return { profile: project.profile, source: 'project', file: project.file };
|
|
109
108
|
const file = join(stateDir, 'config.json');
|
|
109
|
+
if (project) {
|
|
110
|
+
// The project policy owns the profile, but the --gitignore opt-in stays
|
|
111
|
+
// per-clone state in config.json; a two-arg caller still needs it. The
|
|
112
|
+
// profile no longer depends on that file here, so a missing or corrupt
|
|
113
|
+
// one just omits the record instead of failing the load.
|
|
114
|
+
const gitignore = readGitignoreRecord(file);
|
|
115
|
+
return {
|
|
116
|
+
profile: project.profile,
|
|
117
|
+
source: 'project',
|
|
118
|
+
file: project.file,
|
|
119
|
+
...(gitignore ? { gitignore } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
110
122
|
let text;
|
|
111
123
|
try {
|
|
112
124
|
text = readFileSync(file, 'utf8');
|
|
@@ -129,6 +141,17 @@ export function loadConfig(stateDir, root) {
|
|
|
129
141
|
};
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
// Best-effort read of the per-clone gitignore record for the project-policy
|
|
145
|
+
// branch above: any failure (absent file, invalid JSON, invalid shape) means
|
|
146
|
+
// no record, never a failed load.
|
|
147
|
+
function readGitignoreRecord(file) {
|
|
148
|
+
try {
|
|
149
|
+
return normalizeLocalConfig(JSON.parse(stripBom(readFileSync(file, 'utf8'))), file).gitignore;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
export function saveConfig(stateDir, config) {
|
|
133
156
|
const file = join(stateDir, 'config.json');
|
|
134
157
|
const normalized = normalizeLocalConfig(config, file);
|
|
@@ -193,7 +216,28 @@ export function loadOverrides(stateDir) {
|
|
|
193
216
|
if (error?.code === 'ENOENT') return { allow: [], deny: [] };
|
|
194
217
|
throw new LocalOverridesError(file, `cannot read file: ${error.message}`, error);
|
|
195
218
|
}
|
|
196
|
-
|
|
219
|
+
const dropped = { count: 0 };
|
|
220
|
+
const overrides = parseOverrides(text, file, dropped);
|
|
221
|
+
if (dropped.count) {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`aimhooman: warning: ${file}: dropped ${dropped.count} override(s) with retired scope "secret-path"; built-in secret scanning was removed in v0.3.0\n`
|
|
224
|
+
);
|
|
225
|
+
// Persist the cleaned file so the migration warns once, not on every
|
|
226
|
+
// command. Re-read under the lock so a concurrent allow/deny write is
|
|
227
|
+
// never clobbered; a failed rewrite only means the warning returns
|
|
228
|
+
// next run, so this stays best effort. One attempt, no queue wait:
|
|
229
|
+
// allow/deny/override/review/policy-review call loadOverrides while
|
|
230
|
+
// already holding this same lock, and the bakery queue would park the
|
|
231
|
+
// inner candidate behind the outer one for the full retry budget
|
|
232
|
+
// before the catch below swallowed the throw.
|
|
233
|
+
try {
|
|
234
|
+
withLock(`${file}.lock`, () => {
|
|
235
|
+
const fresh = parseOverrides(readFileSync(file, 'utf8'), file);
|
|
236
|
+
atomicWriteJson(file, { schema_version: 1, ...fresh });
|
|
237
|
+
}, { retries: 1 });
|
|
238
|
+
} catch { /* best effort */ }
|
|
239
|
+
}
|
|
240
|
+
return overrides;
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
export function saveOverrides(stateDir, overrides) {
|
|
@@ -207,17 +251,17 @@ export function normalizeOverrideTarget(target) {
|
|
|
207
251
|
return normalizeGitPath(target);
|
|
208
252
|
}
|
|
209
253
|
|
|
210
|
-
function parseOverrides(text, file) {
|
|
254
|
+
function parseOverrides(text, file, dropped = { count: 0 }) {
|
|
211
255
|
let value;
|
|
212
256
|
try {
|
|
213
257
|
value = JSON.parse(stripBom(text));
|
|
214
258
|
} catch (error) {
|
|
215
259
|
throw new LocalOverridesError(file, `invalid JSON: ${error.message}`, error);
|
|
216
260
|
}
|
|
217
|
-
return normalizeOverrides(value, file);
|
|
261
|
+
return normalizeOverrides(value, file, dropped);
|
|
218
262
|
}
|
|
219
263
|
|
|
220
|
-
function normalizeOverrides(value, file) {
|
|
264
|
+
function normalizeOverrides(value, file, dropped = { count: 0 }) {
|
|
221
265
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
222
266
|
throw new LocalOverridesError(file, 'root must be a JSON object');
|
|
223
267
|
}
|
|
@@ -230,17 +274,13 @@ function normalizeOverrides(value, file) {
|
|
|
230
274
|
if (value.schema_version !== undefined && value.schema_version !== 1) {
|
|
231
275
|
throw new LocalOverridesError(file, 'schema_version must be 1');
|
|
232
276
|
}
|
|
233
|
-
|
|
234
|
-
|
|
277
|
+
// The caller owns the drop count: loadOverrides warns and persists the
|
|
278
|
+
// cleaned file, saveOverrides drops retired entries silently on the next
|
|
279
|
+
// write.
|
|
280
|
+
return {
|
|
235
281
|
allow: overrideEntries(value.allow, 'allow', file, dropped),
|
|
236
282
|
deny: overrideEntries(value.deny, 'deny', file, dropped),
|
|
237
283
|
};
|
|
238
|
-
if (dropped.count) {
|
|
239
|
-
process.stderr.write(
|
|
240
|
-
`aimhooman: warning: ${file}: dropped ${dropped.count} override(s) with retired scope "secret-path"; built-in secret scanning was removed in v0.3.0\n`
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
return normalized;
|
|
244
284
|
}
|
|
245
285
|
|
|
246
286
|
function overrideEntries(value, key, file, dropped = { count: 0 }) {
|
|
@@ -251,8 +291,8 @@ function overrideEntries(value, key, file, dropped = { count: 0 }) {
|
|
|
251
291
|
return value.map((entry, index) => {
|
|
252
292
|
// Built-in secret scanning and its secret-path override scope were
|
|
253
293
|
// removed in v0.3.0. An overrides file written by an older version may
|
|
254
|
-
// still carry such entries; drop them (
|
|
255
|
-
// instead of failing the whole load.
|
|
294
|
+
// still carry such entries; drop them (loadOverrides warns once and
|
|
295
|
+
// persists the cleaned file) instead of failing the whole load.
|
|
256
296
|
if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.scope === 'secret-path') {
|
|
257
297
|
dropped.count += 1;
|
|
258
298
|
return null;
|