@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/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. "rotate the key if it was ever exposed")
38
- // that the previous single-index render dropped silently. UT-08: print a
39
- // rule's fix once — a repeated rule (20 hits of the same secret rule)
40
- // used to reprint the identical fix block for every finding.
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
- export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false } = {}) {
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)) return 31;
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
  }
@@ -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 incompleteReasons = new Set([
135
- 'metadata-unavailable', 'size-limit', 'total-byte-limit', 'object-read-failed',
136
- 'missing-object', 'unexpected-object', 'finding-limit', 'local-input-limit',
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) {
@@ -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
- const remediation = ['Restore the strict policy or use the reviewed migration command bound to this target.'];
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
- // Skip reasons that mean a rule never ran, as opposed to a rule running and
641
- // matching nothing. Either way the scan cannot claim to have covered its input.
642
- const INCOMPLETE_SKIP_REASONS = new Set(['local-input-limit', 'local-pack-error']);
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
- complete = false;
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) complete = false;
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 (INCOMPLETE_SKIP_REASONS.has(reason)) complete = false;
738
+ if (FATAL_SKIP_REASONS.has(reason)) flagIncomplete(reason);
707
739
  }
708
740
  },
709
- markIncomplete() {
710
- complete = false;
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
- return parseOverrides(text, file);
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
- const dropped = { count: 0 };
234
- const normalized = {
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 (one warning per file, below)
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;