@rmyndharis/aimhooman 0.1.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.
Files changed (54) hide show
  1. package/.agents/rules/aimhooman.md +53 -0
  2. package/.claude-plugin/marketplace.json +23 -0
  3. package/.claude-plugin/plugin.json +9 -0
  4. package/.clinerules/aimhooman.md +53 -0
  5. package/.codex-plugin/plugin.json +37 -0
  6. package/.cursor/rules/aimhooman.mdc +59 -0
  7. package/.gemini/settings.json +7 -0
  8. package/.github/copilot-instructions.md +53 -0
  9. package/.github/hooks/aimhooman.json +22 -0
  10. package/.kiro/steering/aimhooman.md +53 -0
  11. package/.windsurf/rules/aimhooman.md +57 -0
  12. package/AGENTS.md +53 -0
  13. package/CHANGELOG.md +153 -0
  14. package/CODE_OF_CONDUCT.md +128 -0
  15. package/CONTRIBUTING.md +144 -0
  16. package/GEMINI.md +53 -0
  17. package/LICENSE +21 -0
  18. package/README.md +472 -0
  19. package/SECURITY.md +74 -0
  20. package/bin/aimhooman.mjs +1589 -0
  21. package/docs/design/agent-portability.md +73 -0
  22. package/docs/design/frictionless-enforcement.md +135 -0
  23. package/docs/hosts.json +164 -0
  24. package/docs/logo/aimhooman-logo.png +0 -0
  25. package/docs/logo/aimhooman.png +0 -0
  26. package/hooks/hooks.json +29 -0
  27. package/package.json +77 -0
  28. package/rules/attribution.json +142 -0
  29. package/rules/markers.json +88 -0
  30. package/rules/paths.json +407 -0
  31. package/rules/secrets.json +90 -0
  32. package/schemas/overrides.schema.json +134 -0
  33. package/schemas/project-policy.schema.json +12 -0
  34. package/schemas/rule-pack.schema.json +126 -0
  35. package/schemas/scan-report.schema.json +165 -0
  36. package/skills/aimhooman/SKILL.md +65 -0
  37. package/src/args.mjs +72 -0
  38. package/src/atomic-write.mjs +285 -0
  39. package/src/codex-manifest.mjs +65 -0
  40. package/src/exclude.mjs +125 -0
  41. package/src/git-environment.mjs +5 -0
  42. package/src/git-path.mjs +5 -0
  43. package/src/githooks.mjs +813 -0
  44. package/src/gitx.mjs +721 -0
  45. package/src/history-scan.mjs +295 -0
  46. package/src/hook.mjs +2602 -0
  47. package/src/policy-resolver.mjs +200 -0
  48. package/src/report.mjs +63 -0
  49. package/src/rules.mjs +509 -0
  50. package/src/ruleset-text.mjs +29 -0
  51. package/src/scan-session.mjs +152 -0
  52. package/src/scan-target.mjs +697 -0
  53. package/src/scan.mjs +325 -0
  54. package/src/state.mjs +360 -0
@@ -0,0 +1,697 @@
1
+ import { Engine, newEngineWithDiagnostics } from './scan.mjs';
2
+ import { loadOverrides } from './state.mjs';
3
+ import { applyExplicitProfile, applyStrictFloor, resolvePolicy } from './policy-resolver.mjs';
4
+ import {
5
+ GitRevisionError,
6
+ stagedEntries,
7
+ trackedEntries,
8
+ unmergedPaths,
9
+ } from './gitx.mjs';
10
+ import { commitChanges, commitMessage, commitSnapshot, historyRange } from './history-scan.mjs';
11
+ import { DEFAULT_SCAN_LIMITS, scanEntries } from './scan-session.mjs';
12
+ import { loadRulesWithDiagnostics } from './rules.mjs';
13
+
14
+ const PROFILE_RANK = { clean: 0, compliance: 1, strict: 2 };
15
+ const REVIEW_REQUIRED_PATH_RULES = new Set([
16
+ 'generic.agent-instructions',
17
+ 'generic.project-policy',
18
+ ]);
19
+
20
+ class PolicyRulesError extends Error {
21
+ constructor(errors) {
22
+ super(errors.map((error) => error.message).join('; '));
23
+ this.name = 'PolicyRulesError';
24
+ this.errors = errors;
25
+ }
26
+ }
27
+
28
+ export function scanGitTarget(repo, options = {}) {
29
+ const kind = options.kind || 'staged';
30
+ if (kind === 'range') return scanRange(repo, options);
31
+ if (kind === 'commit') return scanCommit(repo, options);
32
+ if (kind === 'staged' || kind === 'tracked') return scanIndex(repo, options);
33
+ throw new TypeError(`unsupported scan target "${kind}"`);
34
+ }
35
+
36
+ export function scanMessage(repo, text, options = {}) {
37
+ const protectHead = (options.target === 'staged' || options.target?.kind === 'staged')
38
+ && options.protectHead !== false;
39
+ const resolved = protectHead
40
+ ? resolveStagedPolicy(repo, options.explicitProfile)
41
+ : {
42
+ policy: effectivePolicy(
43
+ resolvePolicy(repo, { target: options.target || 'worktree' }),
44
+ options.explicitProfile,
45
+ null,
46
+ ),
47
+ head: null,
48
+ };
49
+ const { policy, head } = resolved;
50
+ const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
51
+ const accumulator = createAccumulator(options.limits);
52
+ accumulator.add(loaded.engine.checkMessage(text).map((finding) => decorate(finding, null, policy)));
53
+ accumulator.addSkipped(loaded.engine.takeSkipped());
54
+ return {
55
+ ...result({
56
+ target: 'message',
57
+ policy,
58
+ accumulator,
59
+ diagnostics: loaded.diagnostics,
60
+ messageScanned: true,
61
+ }),
62
+ repair: loaded.engine.fixMessage(text),
63
+ };
64
+ }
65
+
66
+ function scanIndex(repo, options) {
67
+ const kind = options.kind || 'staged';
68
+ const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
69
+ const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
70
+ const accumulator = createAccumulator(options.limits);
71
+ let entries = kind === 'tracked' ? trackedEntries(repo) : stagedEntries(repo);
72
+ const conflicts = unmergedPaths(repo);
73
+ const diagnostics = [...loaded.diagnostics];
74
+
75
+ if (conflicts.length) {
76
+ if (kind === 'staged') {
77
+ const conflictPaths = new Set(conflicts);
78
+ entries = entries.concat(trackedEntries(repo).filter((entry) => (
79
+ entry.stage > 0 && conflictPaths.has(entry.path)
80
+ )));
81
+ }
82
+ diagnostics.push({
83
+ level: 'warning',
84
+ message: `index contains ${conflicts.length} unresolved path(s); all conflict stages were scanned, but no single staged snapshot exists`,
85
+ });
86
+ // A reviewed-path override can suppress a matching path rule, but it
87
+ // cannot turn an unresolved index into a complete commit snapshot.
88
+ accumulator.markIncomplete();
89
+ }
90
+
91
+ if (floor && !isVersionedStrict(rawPolicy)) {
92
+ accumulator.add([protectedPolicyFinding(rawPolicy, policy, null)]);
93
+ }
94
+ scanEntryGroup(repo, loaded.engine, entries, policy, accumulator, {
95
+ allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
96
+ transition: 'staged',
97
+ });
98
+ if (options.messageText !== undefined) {
99
+ accumulator.add(loaded.engine.checkMessage(options.messageText)
100
+ .map((finding) => decorate(finding, null, policy)));
101
+ accumulator.addSkipped(loaded.engine.takeSkipped());
102
+ }
103
+ return result({
104
+ target: kind,
105
+ policy,
106
+ accumulator,
107
+ diagnostics,
108
+ messageScanned: options.messageText !== undefined,
109
+ });
110
+ }
111
+
112
+ export function resolveStagedPolicy(repo, explicitProfile) {
113
+ const rawPolicy = resolvePolicy(repo, { target: 'staged' });
114
+ const baseline = headPolicy(repo);
115
+ const baselineStrict = isVersionedStrict(baseline);
116
+ const head = baseline?.target?.startsWith('commit:') ? baseline.target.slice('commit:'.length) : null;
117
+ const acknowledged = baselineStrict && policyMigrationAllowed(repo, {
118
+ head,
119
+ transition: 'staged',
120
+ oldObjectIds: [baseline.policy_object_id],
121
+ newObjectId: rawPolicy.policy_object_id,
122
+ newMode: rawPolicy.policy_mode,
123
+ });
124
+ const floor = baselineStrict && !acknowledged && !isVersionedStrict(rawPolicy)
125
+ ? 'head-strict-floor'
126
+ : null;
127
+ return {
128
+ rawPolicy,
129
+ floor,
130
+ head,
131
+ acknowledged,
132
+ policy: effectivePolicy(
133
+ rawPolicy,
134
+ explicitProfile,
135
+ floor,
136
+ baselineStrict ? [baseline.policy_object_id] : [],
137
+ ),
138
+ };
139
+ }
140
+
141
+ function scanCommit(repo, options) {
142
+ if (!options.revision) throw new TypeError('commit scan needs a revision');
143
+ const snapshot = commitSnapshot(repo, options.revision);
144
+ const rawPolicy = resolvePolicy(repo, { target: 'commit', revision: snapshot.commit });
145
+ // Bind policy-migration acks to the repository's current HEAD — the state an
146
+ // ack is recorded against (policy-review --head <repo HEAD> --transition X) —
147
+ // matching scanRange and resolveStagedPolicy. Probing with snapshot.commit
148
+ // almost never matched a real ack, so `check --commit X` spuriously blocked
149
+ // transitions that `check --range` honored. Exact-match security on every
150
+ // ack field is unchanged; a null head simply fails closed (no ack honored).
151
+ const headBaseline = headPolicy(repo);
152
+ const head = headBaseline?.target?.startsWith('commit:')
153
+ ? headBaseline.target.slice('commit:'.length) : null;
154
+ const strictParentPolicies = snapshot.parents
155
+ .map((parent) => resolvePolicy(repo, { target: 'commit', revision: parent }))
156
+ .filter(isVersionedStrict);
157
+ const policyMigrationContexts = options.policyMigrationContexts ?? (head ? [{
158
+ head,
159
+ transition: snapshot.commit,
160
+ }] : []);
161
+ const acknowledged = strictParentPolicies.length > 0 && policyMigrationContexts.some((context) => (
162
+ policyMigrationAllowed(repo, {
163
+ head: context?.head,
164
+ transition: context?.storedTransition ?? context?.transition,
165
+ oldObjectIds: strictParentPolicies.map((policy) => policy.policy_object_id),
166
+ newObjectId: rawPolicy.policy_object_id,
167
+ newMode: rawPolicy.policy_mode,
168
+ })
169
+ ));
170
+ const floor = strictParentPolicies.length > 0 && !acknowledged && !isVersionedStrict(rawPolicy)
171
+ ? 'parent-strict-floor'
172
+ : null;
173
+ const policy = effectivePolicy(
174
+ rawPolicy,
175
+ options.explicitProfile,
176
+ floor,
177
+ strictParentPolicies.map((candidate) => candidate.policy_object_id),
178
+ );
179
+ const reviewContexts = options.reviewContexts ?? (head ? [{
180
+ head,
181
+ transition: snapshot.commit,
182
+ }] : []);
183
+ const loaded = engineForPolicy(
184
+ repo,
185
+ policy,
186
+ head,
187
+ undefined,
188
+ undefined,
189
+ reviewContexts,
190
+ );
191
+ const accumulator = createAccumulator(options.limits);
192
+ const diagnostics = [...loaded.diagnostics];
193
+
194
+ if (snapshot.shallowBoundary) {
195
+ const message = 'shallow repository: commit scan cannot prove parent policy; fetch full history (e.g. fetch-depth: 0)';
196
+ if (options.explicitProfile === 'strict' || rawPolicy.profile === 'strict') {
197
+ throw new GitRevisionError(options.revision, message);
198
+ }
199
+ diagnostics.push({ level: 'warning', message });
200
+ accumulator.markIncomplete();
201
+ }
202
+
203
+ if (floor && !isVersionedStrict(rawPolicy)) {
204
+ accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot)]);
205
+ }
206
+ scanEntryGroup(repo, loaded.engine, snapshot.entries, policy, accumulator, {
207
+ reviewPathEntries: snapshot.changes,
208
+ allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
209
+ transition: snapshot.commit,
210
+ });
211
+ accumulator.add(loaded.engine.checkMessage(snapshot.message)
212
+ .map((finding) => decorate(finding, snapshot, policy)));
213
+ accumulator.addSkipped(loaded.engine.takeSkipped());
214
+ return result({
215
+ target: `commit:${snapshot.commit}`,
216
+ policy,
217
+ accumulator,
218
+ diagnostics,
219
+ messageScanned: true,
220
+ commit: snapshot.commit,
221
+ });
222
+ }
223
+
224
+ function scanRange(repo, options) {
225
+ if (!options.range) throw new TypeError('range scan needs a range');
226
+ const history = historyRange(repo, options.range);
227
+ const basePolicy = history.bootstrap
228
+ ? null
229
+ : resolvePolicy(repo, { target: 'commit', revision: history.scanBase });
230
+ const baseStrict = isVersionedStrict(basePolicy);
231
+ const strictLineage = new Map();
232
+ const rawPolicies = new Map();
233
+ if (!history.bootstrap) {
234
+ strictLineage.set(history.scanBase, new Set(baseStrict ? [basePolicy.policy_object_id] : []));
235
+ rawPolicies.set(history.scanBase, basePolicy);
236
+ }
237
+ const accumulator = createAccumulator(options.limits);
238
+ const diagnostics = [];
239
+ if (history.reversed) {
240
+ diagnostics.push({
241
+ level: 'warning',
242
+ message: 'range selected zero commits because the head is an ancestor of the base; the range endpoints may be reversed',
243
+ });
244
+ }
245
+ if (history.shallow) {
246
+ // A shallow clone (e.g. CI fetch-depth: 1) may omit commits in the range,
247
+ // so completeness cannot be proven. Fail closed under strict; under
248
+ // clean/compliance proceed but mark the scan incomplete so the report's
249
+ // `complete` flag and exit code (31) signal the gap machine-readably,
250
+ // matching the documented CI guidance (fetch-depth: 0).
251
+ const message = 'shallow repository: range scan cannot prove completeness; fetch full history (e.g. fetch-depth: 0)';
252
+ if (options.explicitProfile === 'strict' || baseStrict) {
253
+ throw new GitRevisionError(options.range, message);
254
+ }
255
+ diagnostics.push({ level: 'warning', message });
256
+ accumulator.markIncomplete();
257
+ }
258
+ const policies = [];
259
+ let usedStrictFloor = false;
260
+ // Rule packs are read and compiled once for the whole range; per-commit work
261
+ // only constructs an Engine and applies overrides (see engineForPolicy).
262
+ const preloaded = loadRulesWithDiagnostics(repo.stateDir);
263
+ const preloadedOverrides = loadOverrides(repo.stateDir);
264
+
265
+ for (const commit of history.commits) {
266
+ const changes = commitChanges(repo, commit.commit, commit.commit, commit.parents);
267
+ const { message } = commitMessage(repo, commit.commit, commit.commit);
268
+ const rawPolicy = resolvePolicy(repo, { target: 'commit', revision: commit.commit });
269
+ rawPolicies.set(commit.commit, rawPolicy);
270
+ const parentStrictObjects = new Set();
271
+ for (const parent of commit.parents) {
272
+ if (strictLineage.has(parent)) {
273
+ for (const objectId of strictLineage.get(parent)) parentStrictObjects.add(objectId);
274
+ continue;
275
+ }
276
+ let parentPolicy = rawPolicies.get(parent);
277
+ if (!parentPolicy) {
278
+ parentPolicy = resolvePolicy(repo, { target: 'commit', revision: parent });
279
+ rawPolicies.set(parent, parentPolicy);
280
+ }
281
+ if (isVersionedStrict(parentPolicy)) parentStrictObjects.add(parentPolicy.policy_object_id);
282
+ }
283
+ const acknowledged = parentStrictObjects.size > 0 && policyMigrationAllowed(repo, {
284
+ head: history.head,
285
+ transition: commit.commit,
286
+ oldObjectIds: [...parentStrictObjects],
287
+ newObjectId: rawPolicy.policy_object_id,
288
+ newMode: rawPolicy.policy_mode,
289
+ }, preloadedOverrides);
290
+ const floor = parentStrictObjects.size > 0 && !acknowledged && !isVersionedStrict(rawPolicy)
291
+ ? 'parent-strict-floor'
292
+ : null;
293
+ usedStrictFloor ||= Boolean(floor);
294
+ const policy = effectivePolicy(rawPolicy, options.explicitProfile, floor, [...parentStrictObjects]);
295
+ policies.push(policy);
296
+ strictLineage.set(commit.commit, new Set(
297
+ isVersionedStrict(rawPolicy)
298
+ ? [rawPolicy.policy_object_id]
299
+ : acknowledged ? [] : parentStrictObjects
300
+ ));
301
+ const loaded = engineForPolicy(repo, policy, history.head, preloaded, preloadedOverrides);
302
+ diagnostics.push(...loaded.diagnostics);
303
+
304
+ if (floor && !isVersionedStrict(rawPolicy)) {
305
+ accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit)]);
306
+ }
307
+ scanEntryGroup(repo, loaded.engine, changes.entries, policy, accumulator, {
308
+ allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
309
+ transition: commit.commit,
310
+ });
311
+ accumulator.add(loaded.engine.checkMessage(message)
312
+ .map((finding) => decorate(finding, commit, policy)));
313
+ accumulator.addSkipped(loaded.engine.takeSkipped());
314
+ }
315
+
316
+ const policy = rangeReportPolicy(basePolicy, policies, usedStrictFloor, options.explicitProfile);
317
+ return result({
318
+ target: `range:${options.range}`,
319
+ policy,
320
+ accumulator,
321
+ diagnostics,
322
+ messageScanned: true,
323
+ commit: history.head,
324
+ range: {
325
+ base: history.base,
326
+ scan_base: history.scanBase,
327
+ head: history.head,
328
+ commits_scanned: history.commits.length,
329
+ },
330
+ });
331
+ }
332
+
333
+ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}) {
334
+ const reviewPathEntries = options.reviewPathEntries ?? entries;
335
+ scanReviewPathChanges(engine, reviewPathEntries, policy, accumulator, options);
336
+ const reviewPaths = new Set(reviewPathEntries
337
+ .filter((entry) => entry.status !== 'D' && entry.type !== 'deleted')
338
+ .map((entry) => entry.path));
339
+ const scannable = entries.filter((entry) => entry.status !== 'D' && entry.type !== 'deleted');
340
+ const checkedPathObjects = new Set();
341
+ for (const entry of scannable) {
342
+ const pathObject = `${entry.path}\0${entry.oid || ''}\0${entry.mode || ''}`;
343
+ if (checkedPathObjects.has(pathObject)) continue;
344
+ checkedPathObjects.add(pathObject);
345
+ accumulator.add(engine.checkPaths([entry.path], {
346
+ objectId: entry.oid,
347
+ mode: entry.mode,
348
+ transition: options.transition ?? entry.commit,
349
+ ...(!reviewPaths.has(entry.path) ? {
350
+ excludedRuleIds: REVIEW_REQUIRED_PATH_RULES,
351
+ } : {}),
352
+ })
353
+ .map((finding) => decorate(finding, entry, policy)));
354
+ }
355
+ const remaining = accumulator.remaining();
356
+ const scanned = scanEntries(repo, engine, scannable, {
357
+ maxFileBytes: accumulator.limits.maxFileBytes,
358
+ maxTotalBytes: accumulator.remainingBytes(),
359
+ maxFindings: remaining,
360
+ });
361
+ accumulator.addScan(scanned, policy);
362
+ }
363
+
364
+ function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
365
+ for (const entry of entries) {
366
+ const paths = [];
367
+ if (entry.status === 'D' || entry.type === 'deleted') paths.push(entry.path);
368
+ if (entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path) {
369
+ paths.push(entry.sourcePath);
370
+ }
371
+ for (const path of paths) {
372
+ const context = {
373
+ objectId: null,
374
+ mode: null,
375
+ transition: options.transition ?? entry.commit,
376
+ ...(options.allowMissingPolicy && path === '.aimhooman.json'
377
+ ? { transientAllowRules: new Set(['generic.project-policy']) }
378
+ : {}),
379
+ };
380
+ const findings = engine.checkPaths([path], context).filter((finding) => (
381
+ finding.matchedRuleIds?.some((ruleId) => (
382
+ ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
383
+ ))
384
+ ));
385
+ accumulator.add(findings.map((finding) => decorate(finding, entry, policy)));
386
+ }
387
+ }
388
+ }
389
+
390
+ export function engineForPolicy(
391
+ repo,
392
+ policy,
393
+ overrideHead,
394
+ preloaded,
395
+ preloadedOverrides,
396
+ reviewContexts,
397
+ ) {
398
+ const loaded = preloaded
399
+ ? { engine: new Engine(policy.profile, preloaded.rules), errors: preloaded.errors }
400
+ : newEngineWithDiagnostics(policy.profile, repo.stateDir);
401
+ if (loaded.errors.length && policy.profile === 'strict') {
402
+ throw new PolicyRulesError(loaded.errors);
403
+ }
404
+ // Overrides can grant exceptions, so malformed state is never equivalent
405
+ // to an empty set. Every profile fails closed rather than silently changing
406
+ // the repository's effective policy.
407
+ const overrides = preloadedOverrides ?? loadOverrides(repo.stateDir);
408
+ const active = (entries) => entries.filter((entry) => (
409
+ !entry.head || entry.head === overrideHead
410
+ ));
411
+ const activeAllow = active(overrides.allow);
412
+ const ordinaryAllow = activeAllow.filter((entry) => (
413
+ !['reviewed-instruction', 'reviewed-policy-file', 'policy-migration'].includes(entry.scope)
414
+ ));
415
+ const normalizedReviewContexts = reviewContexts === undefined
416
+ ? null
417
+ : reviewContexts.flatMap((context) => {
418
+ const storedTransition = context?.storedTransition ?? context?.transition;
419
+ const scanTransition = context?.scanTransition ?? context?.transition;
420
+ if (typeof context?.head !== 'string'
421
+ || typeof storedTransition !== 'string'
422
+ || typeof scanTransition !== 'string') return [];
423
+ return [{ head: context.head, storedTransition, scanTransition }];
424
+ });
425
+ const reviewed = (scope, ruleId) => {
426
+ const candidates = overrides.allow.filter((entry) => entry.scope === scope);
427
+ if (normalizedReviewContexts === null) {
428
+ return active(candidates).map((entry) => reviewedEngineEntry(entry, ruleId, entry.transition));
429
+ }
430
+ return candidates.flatMap((entry) => normalizedReviewContexts
431
+ .filter((context) => (
432
+ context.head === entry.head && context.storedTransition === entry.transition
433
+ ))
434
+ .map((context) => reviewedEngineEntry(entry, ruleId, context.scanTransition)));
435
+ };
436
+ const reviewedInstructions = reviewed('reviewed-instruction', 'generic.agent-instructions');
437
+ const reviewedPolicies = reviewed('reviewed-policy-file', 'generic.project-policy');
438
+ loaded.engine.setOverrides(
439
+ ordinaryAllow,
440
+ active(overrides.deny),
441
+ [...reviewedInstructions, ...reviewedPolicies],
442
+ );
443
+ const diagnostics = loaded.errors.map((error) => ({ level: 'warning', message: `${error.message}; pack skipped` }));
444
+ return { engine: loaded.engine, diagnostics };
445
+ }
446
+
447
+ function reviewedEngineEntry(entry, ruleId, transition) {
448
+ return {
449
+ target: entry.target,
450
+ ruleId,
451
+ transition,
452
+ newObjectId: entry.newObjectId,
453
+ newMode: entry.newMode,
454
+ };
455
+ }
456
+
457
+ function policyMigrationAllowed(repo, expected, preloadedOverrides) {
458
+ if (!expected.head || !expected.oldObjectIds.length) return false;
459
+ const overrides = preloadedOverrides ?? loadOverrides(repo.stateDir);
460
+ const migrations = overrides.allow.filter((entry) => entry.scope === 'policy-migration');
461
+ return expected.oldObjectIds.every((oldObjectId) => migrations.some((entry) => (
462
+ entry.target === '.aimhooman.json'
463
+ && entry.head === expected.head
464
+ && entry.transition === expected.transition
465
+ && entry.oldObjectId === oldObjectId
466
+ && (entry.newObjectId ?? null) === (expected.newObjectId ?? null)
467
+ && (entry.newMode ?? null) === (expected.newMode ?? null)
468
+ )));
469
+ }
470
+
471
+ function effectivePolicy(raw, explicitProfile, floorSource, enforcedObjectIds = []) {
472
+ const objectIds = [...new Set(enforcedObjectIds.filter(Boolean))].sort();
473
+ const floored = floorSource
474
+ ? {
475
+ ...applyStrictFloor(raw, floorSource),
476
+ ...(objectIds.length ? {
477
+ policy_object_id: objectIds[0],
478
+ enforced_policy_object_ids: objectIds,
479
+ } : {}),
480
+ }
481
+ : raw;
482
+ return applyExplicitProfile(floored, explicitProfile);
483
+ }
484
+
485
+ function headPolicy(repo) {
486
+ try {
487
+ return resolvePolicy(repo, { target: 'commit', revision: 'HEAD' });
488
+ } catch (error) {
489
+ if (error instanceof GitRevisionError) return null;
490
+ throw error;
491
+ }
492
+ }
493
+
494
+ function isVersionedStrict(policy) {
495
+ return Boolean(policy?.policy_object_id && policy.profile === 'strict' && (
496
+ policy.source === 'commit-policy' || policy.source === 'staged-policy' || policy.source === 'worktree-policy'
497
+ ));
498
+ }
499
+
500
+ function protectedPolicyFinding(rawPolicy, policy, entry) {
501
+ const removed = !rawPolicy.policy_object_id;
502
+ const reason = removed
503
+ ? 'A versioned strict project policy cannot be deleted or renamed away without a bound review acknowledgment.'
504
+ : 'A versioned strict project policy cannot be downgraded without a bound review acknowledgment.';
505
+ const remediation = ['Restore the strict policy or use the reviewed migration command bound to this target.'];
506
+ return decorate({
507
+ ruleId: 'generic.project-policy',
508
+ ruleVersion: 1,
509
+ matchedRuleIds: ['generic.project-policy'],
510
+ matchedRules: [{
511
+ ruleId: 'generic.project-policy',
512
+ ruleVersion: 1,
513
+ kind: 'path',
514
+ category: 'policy-config',
515
+ provider: 'aimhooman',
516
+ confidence: 'high',
517
+ decision: 'block',
518
+ reason,
519
+ remediation,
520
+ source: 'builtin',
521
+ }],
522
+ provider: 'aimhooman',
523
+ category: 'policy-config',
524
+ kind: 'path',
525
+ confidence: 'high',
526
+ source: 'builtin',
527
+ decision: 'block',
528
+ path: '.aimhooman.json',
529
+ reason,
530
+ remediation,
531
+ }, entry, policy);
532
+ }
533
+
534
+ function decorate(finding, entry, policy) {
535
+ const commit = entry?.commit;
536
+ const parents = entry?.parents;
537
+ const objectId = entry?.objectId || entry?.oid;
538
+ return {
539
+ ...finding,
540
+ ...(commit ? { commit } : {}),
541
+ ...(parents?.length ? { parents } : {}),
542
+ ...(objectId ? { objectId } : {}),
543
+ ...(entry?.sourcePath ? { sourcePath: entry.sourcePath } : {}),
544
+ ...(entry?.status ? { status: entry.status } : {}),
545
+ scanProfile: policy.profile,
546
+ policySource: policy.source,
547
+ policyObjectId: policy.policy_object_id,
548
+ ...(policy.enforced_policy_object_ids?.length ? {
549
+ policyEnforcedObjectIds: policy.enforced_policy_object_ids,
550
+ } : {}),
551
+ };
552
+ }
553
+
554
+ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfile) {
555
+ if (!policies.length) {
556
+ if (!basePolicy) throw new Error('bootstrap range did not contain a commit');
557
+ // An empty range yields no findings, so the exit code is unaffected, but
558
+ // the reported profile should still honor an explicit --profile request.
559
+ // Apply it only here, after the isVersionedStrict check on the raw base.
560
+ const base = isVersionedStrict(basePolicy) ? applyStrictFloor(basePolicy, 'range-base-strict') : basePolicy;
561
+ return explicitProfile ? applyExplicitProfile(base, explicitProfile) : base;
562
+ }
563
+ const strongest = policies.reduce((selected, policy) => (
564
+ PROFILE_RANK[policy.profile] > PROFILE_RANK[selected.profile] ? policy : selected
565
+ ));
566
+ if (!usedStrictFloor) return { ...strongest, source: policies.length === 1 ? strongest.source : 'per-commit' };
567
+ const enforced = [...new Set(
568
+ policies.flatMap((policy) => policy.enforced_policy_object_ids || [])
569
+ )].sort();
570
+ return {
571
+ ...strongest,
572
+ profile: 'strict',
573
+ source: 'parent-strict-floor',
574
+ ...(enforced.length ? {
575
+ policy_object_id: enforced[0],
576
+ enforced_policy_object_ids: enforced,
577
+ } : {}),
578
+ };
579
+ }
580
+
581
+ function createAccumulator(limits = {}) {
582
+ const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
583
+ const findings = [];
584
+ const findingKeys = new Map();
585
+ const stats = {
586
+ entries: 0,
587
+ blob_files: 0,
588
+ objects_read: 0,
589
+ files_scanned: 0,
590
+ bytes_scanned: 0,
591
+ findings_total: 0,
592
+ findings_returned: 0,
593
+ skipped: {},
594
+ };
595
+ let complete = true;
596
+
597
+ function add(values, total = values.length) {
598
+ stats.findings_total += total;
599
+ for (const finding of values) {
600
+ const key = findingKey(finding);
601
+ if (findingKeys.has(key)) {
602
+ mergeFindingEvidence(findings[findingKeys.get(key)], finding);
603
+ continue;
604
+ }
605
+ if (findings.length >= effectiveLimits.maxFindings) {
606
+ complete = false;
607
+ increment(stats.skipped, 'finding-limit');
608
+ continue;
609
+ }
610
+ findingKeys.set(key, findings.length);
611
+ findings.push(finding);
612
+ }
613
+ stats.findings_returned = findings.length;
614
+ if (total > values.length) complete = false;
615
+ }
616
+
617
+ return {
618
+ limits: effectiveLimits,
619
+ findings,
620
+ stats,
621
+ add,
622
+ addScan(scanned, policy) {
623
+ const decorated = scanned.findings.map((finding) => decorate(finding, finding, policy));
624
+ add(decorated, scanned.stats.findings_total);
625
+ for (const key of ['entries', 'blob_files', 'objects_read', 'files_scanned', 'bytes_scanned']) {
626
+ stats[key] += scanned.stats[key] || 0;
627
+ }
628
+ for (const [reason, count] of Object.entries(scanned.stats.skipped || {})) {
629
+ stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
630
+ }
631
+ if (!scanned.complete) complete = false;
632
+ },
633
+ addSkipped(skipped = {}) {
634
+ for (const [reason, count] of Object.entries(skipped)) {
635
+ stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
636
+ if (reason === 'local-input-limit') complete = false;
637
+ }
638
+ },
639
+ markIncomplete() {
640
+ complete = false;
641
+ },
642
+ remaining() {
643
+ return Math.max(0, effectiveLimits.maxFindings - findings.length);
644
+ },
645
+ remainingBytes() {
646
+ return Math.max(0, effectiveLimits.maxTotalBytes - stats.bytes_scanned);
647
+ },
648
+ isComplete() {
649
+ return complete;
650
+ },
651
+ };
652
+ }
653
+
654
+ function result({ target, policy, accumulator, diagnostics, messageScanned, commit, range }) {
655
+ return {
656
+ target,
657
+ profile: policy.profile,
658
+ policy_source: policy.source,
659
+ policy_object_id: policy.policy_object_id,
660
+ ...(policy.enforced_policy_object_ids?.length ? {
661
+ policy_enforced_object_ids: policy.enforced_policy_object_ids,
662
+ } : {}),
663
+ complete: accumulator.isComplete(),
664
+ stats: accumulator.stats,
665
+ findings: accumulator.findings,
666
+ diagnostics,
667
+ message_scanned: messageScanned,
668
+ ...(commit ? { commit } : {}),
669
+ ...(range ? { range } : {}),
670
+ };
671
+ }
672
+
673
+ function findingKey(finding) {
674
+ return [
675
+ finding.commit || '', finding.path || '', finding.objectId || '', finding.line || '', finding.ruleId,
676
+ finding.decision, finding.text || '',
677
+ ].join('\0');
678
+ }
679
+
680
+ function mergeFindingEvidence(target, source) {
681
+ const parents = [...new Set([...(target.parents || []), ...(source.parents || [])])].sort();
682
+ if (parents.length) target.parents = parents;
683
+ const statuses = [...new Set([
684
+ ...(target.statuses || (target.status ? [target.status] : [])),
685
+ ...(source.statuses || (source.status ? [source.status] : [])),
686
+ ])].sort();
687
+ if (statuses.length > 1) target.statuses = statuses;
688
+ const sourcePaths = [...new Set([
689
+ ...(target.sourcePaths || (target.sourcePath ? [target.sourcePath] : [])),
690
+ ...(source.sourcePaths || (source.sourcePath ? [source.sourcePath] : [])),
691
+ ])].sort();
692
+ if (sourcePaths.length > 1) target.sourcePaths = sourcePaths;
693
+ }
694
+
695
+ function increment(record, key) {
696
+ record[key] = (record[key] || 0) + 1;
697
+ }