@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
package/src/scan.mjs ADDED
@@ -0,0 +1,325 @@
1
+ import {
2
+ loadRules,
3
+ loadRulesWithDiagnostics,
4
+ actionFor,
5
+ matchesPath,
6
+ matchesContent,
7
+ MAX_LOCAL_MATCH_INPUT,
8
+ } from './rules.mjs';
9
+ import { normalizeGitPath } from './git-path.mjs';
10
+
11
+ const DECISION_RANK = { allow: 0, review: 1, block: 2 };
12
+ const CATEGORY_RANK = new Map([['secret', 2], ['policy-config', 1]]);
13
+
14
+ // Engine evaluates paths, messages, and content against a rule set under a
15
+ // profile, honoring local allow/deny overrides.
16
+ export class Engine {
17
+ constructor(profile, rules) {
18
+ this.profile = profile;
19
+ this.rules = rules;
20
+ this.allowPaths = new Set();
21
+ this.allowRules = new Set();
22
+ this.allowSecretPaths = new Set();
23
+ this.denyPaths = new Set();
24
+ this.denyRules = new Set();
25
+ this.scopedAllow = [];
26
+ this.skipped = {};
27
+ }
28
+
29
+ // setOverrides keeps path and rule scopes separate. String entries remain
30
+ // accepted for the small public Engine API and are inferred from known IDs.
31
+ setOverrides(allow, deny, scopedAllow = []) {
32
+ const allowed = this.#splitOverrides(allow);
33
+ const denied = this.#splitOverrides(deny);
34
+ this.allowPaths = allowed.paths;
35
+ this.allowRules = allowed.rules;
36
+ this.allowSecretPaths = allowed.secretPaths;
37
+ this.denyPaths = denied.paths;
38
+ this.denyRules = denied.rules;
39
+ this.scopedAllow = scopedAllow.filter((entry) => (
40
+ entry
41
+ && typeof entry.target === 'string'
42
+ && typeof entry.ruleId === 'string'
43
+ && typeof entry.transition === 'string'
44
+ && (typeof entry.newObjectId === 'string' || entry.newObjectId === null)
45
+ && (entry.newMode === '100644' || entry.newMode === '100755' || entry.newMode === null)
46
+ ));
47
+ }
48
+
49
+ // decide applies overrides to one rule. The caller reduces every resolved
50
+ // match, so a rule-level allow cannot hide a different matching rule.
51
+ decide(base, target, rule, context = {}) {
52
+ if (this.denyPaths.has(target) || this.denyRules.has(rule.id)) return 'block';
53
+ if (context.transientAllowRules?.has(rule.id)) return 'allow';
54
+ if (this.scopedAllow.some((entry) => (
55
+ entry.target === target
56
+ && entry.ruleId === rule.id
57
+ && entry.transition === context.transition
58
+ && entry.newObjectId === context.objectId
59
+ && entry.newMode === context.mode
60
+ ))) return 'allow';
61
+ // A rule-level allow cannot bypass the secret-category guard that the
62
+ // path-level allow below enforces: secret rules require an explicit
63
+ // --scope secret-path override on a specific path, never a blanket rule allow.
64
+ if (this.allowRules.has(rule.id) && rule.category !== 'secret') return 'allow';
65
+ // A --scope secret-path allow is the explicit override for secret-category
66
+ // rules only; it must not also suppress a non-secret rule that happens to
67
+ // match the same path (principle of least privilege).
68
+ if (this.allowSecretPaths.has(target) && rule.category === 'secret') return 'allow';
69
+ if (this.allowPaths.has(target) && rule.category !== 'secret') return 'allow';
70
+ return base;
71
+ }
72
+
73
+ #splitOverrides(entries) {
74
+ const result = { paths: new Set(), rules: new Set(), secretPaths: new Set() };
75
+ for (const entry of entries || []) {
76
+ const target = typeof entry === 'string' ? entry : entry?.target;
77
+ if (!target) continue;
78
+ const scope = typeof entry === 'string'
79
+ ? (this.lookup(target) ? 'rule' : 'path')
80
+ : (entry.scope ?? (this.lookup(target) ? 'rule' : 'path'));
81
+ if (scope === 'rule') result.rules.add(target);
82
+ else if (scope === 'secret-path') result.secretPaths.add(target);
83
+ else if (scope === 'path') result.paths.add(target);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ lookup(id) {
89
+ return this.rules.find((r) => r.id === id) || null;
90
+ }
91
+
92
+ checkPaths(paths, context = {}) {
93
+ const out = [];
94
+ const seen = new Set();
95
+ for (const raw of paths || []) {
96
+ const p = normalize(raw);
97
+ if (!p || seen.has(p)) continue;
98
+ seen.add(p);
99
+ const result = this.#evaluate('path', p, undefined, context);
100
+ if (result) out.push(finding(result, { path: p }));
101
+ else if (this.denyPaths.has(p)) out.push(pathDenyFinding(p));
102
+ }
103
+ return out;
104
+ }
105
+
106
+ checkMessage(text) {
107
+ const out = [];
108
+ for (const record of lineRecords(String(text ?? ''))) {
109
+ this.#markLocalInputSkip('message', '', record.text);
110
+ const result = this.#evaluate('message', '', record.text);
111
+ if (result) out.push(finding(result, { line: record.line, text: record.text }));
112
+ }
113
+ return out;
114
+ }
115
+
116
+ checkContent(path, content, options = {}) {
117
+ const out = [];
118
+ const p = normalize(path);
119
+ const categories = Array.isArray(options.categories)
120
+ ? new Set(options.categories)
121
+ : null;
122
+ for (const record of lineRecords(String(content))) {
123
+ this.#markLocalInputSkip('code', p, record.text, categories);
124
+ const result = this.#evaluate('code', p, record.text, { categories });
125
+ if (result) out.push(finding(result, { path: p, line: record.line, text: record.text }));
126
+ }
127
+ return out;
128
+ }
129
+
130
+ // Repair is deliberately narrower than detection: only clean-profile block
131
+ // findings backed by validated whole-line metadata may remove bytes.
132
+ fixMessage(text) {
133
+ const value = String(text ?? '');
134
+ if (this.profile !== 'clean') return { cleaned: value, removed: [] };
135
+ const candidates = this.checkMessage(value).filter((f) => (
136
+ f.decision === 'block' && f.autofix === 'remove-whole-line'
137
+ ));
138
+ if (!candidates.length) return { cleaned: value, removed: [] };
139
+ const records = lineRecords(value);
140
+ const terminatedLines = new Set(records
141
+ .filter((record) => record.end > record.start && value[record.end - 1] === '\n')
142
+ .map((record) => record.line));
143
+ const removed = candidates.filter((finding) => terminatedLines.has(finding.line));
144
+ if (!removed.length) return { cleaned: value, removed: [] };
145
+ const drop = new Set(removed.map((f) => f.line));
146
+ const ranges = [];
147
+ for (const record of records.filter((candidate) => drop.has(candidate.line))) {
148
+ const previous = ranges.at(-1);
149
+ if (previous && record.start <= previous.end) previous.end = Math.max(previous.end, record.end);
150
+ else ranges.push({ start: record.start, end: record.end });
151
+ }
152
+ let cleaned = '';
153
+ let cursor = 0;
154
+ for (const range of ranges) {
155
+ cleaned += value.slice(cursor, range.start);
156
+ cursor = range.end;
157
+ }
158
+ cleaned += value.slice(cursor);
159
+ return { cleaned, removed };
160
+ }
161
+
162
+ takeSkipped() {
163
+ const skipped = this.skipped;
164
+ this.skipped = {};
165
+ return skipped;
166
+ }
167
+
168
+ #markLocalInputSkip(kind, target, text, categories = null) {
169
+ if (text.length <= MAX_LOCAL_MATCH_INPUT) return;
170
+ const applicable = this.rules.some((rule) => {
171
+ if (rule.source !== 'local' || rule.kind !== kind) return false;
172
+ if (categories && !categories.has(rule.category)) return false;
173
+ if (kind !== 'code') return true;
174
+ const candidate = rule.pathCaseInsensitive ? target.toLowerCase() : target;
175
+ if (rule.pathRes.length && !rule.pathRes.some((regexp) => regexp.test(candidate))) return false;
176
+ return !rule.exceptRes.some((regexp) => regexp.test(candidate));
177
+ });
178
+ if (applicable) this.skipped['local-input-limit'] = (this.skipped['local-input-limit'] || 0) + 1;
179
+ }
180
+
181
+ #evaluate(kind, target, text, context = {}) {
182
+ const matches = [];
183
+ for (const rule of this.rules) {
184
+ if (rule.kind !== kind) continue;
185
+ if (context.excludedRuleIds?.has(rule.id)) continue;
186
+ if (context.categories && !context.categories.has(rule.category)) continue;
187
+ const matched = kind === 'path'
188
+ ? matchesPath(rule, target)
189
+ : matchesContent(rule, text, kind === 'code' ? target : undefined);
190
+ if (!matched) continue;
191
+ matches.push({
192
+ rule,
193
+ decision: this.decide(actionFor(rule, this.profile), target, rule, context),
194
+ });
195
+ }
196
+ if (!matches.length) return null;
197
+
198
+ matches.sort(compareMatch);
199
+ const decision = matches.reduce((current, match) => (
200
+ DECISION_RANK[match.decision] > DECISION_RANK[current] ? match.decision : current
201
+ ), 'allow');
202
+ if (decision === 'allow') return null;
203
+
204
+ const primary = matches.find((match) => match.decision === decision);
205
+ const autofix = matches.some((match) => (
206
+ match.decision === 'block' && match.rule.autofix === 'remove-whole-line'
207
+ )) ? 'remove-whole-line' : undefined;
208
+ return { matches, primary, decision, autofix };
209
+ }
210
+ }
211
+
212
+ function compareMatch(left, right) {
213
+ const decision = DECISION_RANK[right.decision] - DECISION_RANK[left.decision];
214
+ if (decision) return decision;
215
+ const category = (CATEGORY_RANK.get(right.rule.category) || 0) - (CATEGORY_RANK.get(left.rule.category) || 0);
216
+ if (category) return category;
217
+ const source = Number(right.rule.source !== 'local') - Number(left.rule.source !== 'local');
218
+ if (source) return source;
219
+ return compareText(left.rule.id, right.rule.id);
220
+ }
221
+
222
+ function finding(result, extra) {
223
+ const rule = result.primary.rule;
224
+ const matchedRules = [...result.matches]
225
+ .sort((left, right) => compareText(left.rule.id, right.rule.id))
226
+ .map((match) => ({
227
+ ruleId: match.rule.id,
228
+ ruleVersion: match.rule.version ?? null,
229
+ kind: match.rule.kind,
230
+ category: match.rule.category,
231
+ provider: match.rule.provider,
232
+ confidence: match.rule.confidence ?? null,
233
+ decision: match.decision,
234
+ reason: match.rule.reason,
235
+ remediation: match.rule.remediation || [],
236
+ source: match.rule.source || 'builtin',
237
+ }));
238
+ const out = {
239
+ ruleId: rule.id,
240
+ ruleVersion: rule.version ?? null,
241
+ matchedRuleIds: matchedRules.map((match) => match.ruleId),
242
+ matchedRules,
243
+ kind: rule.kind,
244
+ category: rule.category,
245
+ provider: rule.provider,
246
+ confidence: rule.confidence ?? null,
247
+ decision: result.decision,
248
+ reason: rule.reason,
249
+ remediation: rule.remediation || [],
250
+ source: rule.source || 'builtin',
251
+ ...extra,
252
+ };
253
+ if (result.autofix) out.autofix = result.autofix;
254
+ return out;
255
+ }
256
+
257
+ function pathDenyFinding(path) {
258
+ return {
259
+ ruleId: 'override.path-deny',
260
+ ruleVersion: 1,
261
+ matchedRuleIds: ['override.path-deny'],
262
+ matchedRules: [{
263
+ ruleId: 'override.path-deny',
264
+ ruleVersion: 1,
265
+ kind: 'path',
266
+ category: 'local-override',
267
+ provider: 'aimhooman',
268
+ confidence: 'high',
269
+ decision: 'block',
270
+ reason: 'A local deny override blocks this path.',
271
+ remediation: ['Remove the deny override before committing this path.'],
272
+ source: 'local',
273
+ }],
274
+ kind: 'path',
275
+ category: 'local-override',
276
+ provider: 'aimhooman',
277
+ confidence: 'high',
278
+ decision: 'block',
279
+ reason: 'A local deny override blocks this path.',
280
+ remediation: ['Remove the deny override before committing this path.'],
281
+ source: 'local',
282
+ path,
283
+ };
284
+ }
285
+
286
+ function compareText(left, right) {
287
+ return left < right ? -1 : left > right ? 1 : 0;
288
+ }
289
+
290
+ function lineRecords(value) {
291
+ const records = [];
292
+ let start = 0;
293
+ let line = 1;
294
+ while (start < value.length) {
295
+ const newline = value.indexOf('\n', start);
296
+ const end = newline < 0 ? value.length : newline + 1;
297
+ let textEnd = newline < 0 ? value.length : newline;
298
+ if (textEnd > start && value[textEnd - 1] === '\r') textEnd -= 1;
299
+ records.push({ start, end, line, text: value.slice(start, textEnd) });
300
+ start = end;
301
+ line += 1;
302
+ }
303
+ if (value.length === 0 || value.endsWith('\n')) {
304
+ records.push({ start: value.length, end: value.length, line, text: '' });
305
+ }
306
+ return records;
307
+ }
308
+
309
+ function normalize(p) {
310
+ return normalizeGitPath(p);
311
+ }
312
+
313
+ // newEngine builds an engine for a profile with the embedded rule packs loaded.
314
+ // When stateDir is given, local rule packs from <stateDir>/rules/*.json are
315
+ // appended after the core packs.
316
+ export function newEngine(profile, stateDir) {
317
+ return new Engine(profile, loadRules(stateDir));
318
+ }
319
+
320
+ // Diagnostic constructor lets hook/CLI callers choose profile-specific handling
321
+ // for malformed local packs while always retaining validated built-in rules.
322
+ export function newEngineWithDiagnostics(profile, stateDir) {
323
+ const { rules, errors } = loadRulesWithDiagnostics(stateDir);
324
+ return { engine: new Engine(profile, rules), errors };
325
+ }
package/src/state.mjs ADDED
@@ -0,0 +1,360 @@
1
+ import {
2
+ lstatSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { atomicWrite } from './atomic-write.mjs';
8
+ import { normalizeGitPath } from './git-path.mjs';
9
+
10
+ // Per-repository state, stored in the common Git dir (never the worktree).
11
+
12
+ const PROFILES = new Set(['clean', 'strict', 'compliance']);
13
+ const OVERRIDE_SCOPES = new Set([
14
+ 'path', 'rule', 'secret-path', 'reviewed-instruction', 'reviewed-policy-file', 'policy-migration',
15
+ ]);
16
+ const OVERRIDE_FIELDS = new Set([
17
+ 'target', 'scope', 'reason', 'actor', 'at', 'head', 'transition', 'oldObjectId', 'newObjectId', 'newMode',
18
+ ]);
19
+ const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
20
+ const REVIEWED_MODE = /^(?:100644|100755)$/;
21
+ const RFC3339_DATE_TIME = new RegExp(
22
+ '^\\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\\d|3[01])'
23
+ + 't(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?'
24
+ + '(?:z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$',
25
+ 'i',
26
+ );
27
+
28
+ export class ProjectPolicyError extends Error {
29
+ constructor(file, detail, cause) {
30
+ super(`project policy "${file}": ${detail}`);
31
+ this.name = 'ProjectPolicyError';
32
+ this.file = file;
33
+ if (cause) this.cause = cause;
34
+ }
35
+ }
36
+
37
+ export class LocalConfigError extends Error {
38
+ constructor(file, detail, cause) {
39
+ super(`local config "${file}": ${detail}`);
40
+ this.name = 'LocalConfigError';
41
+ this.file = file;
42
+ if (cause) this.cause = cause;
43
+ }
44
+ }
45
+
46
+ export class LocalOverridesError extends Error {
47
+ constructor(file, detail, cause) {
48
+ super(`local overrides "${file}": ${detail}`);
49
+ this.name = 'LocalOverridesError';
50
+ this.file = file;
51
+ if (cause) this.cause = cause;
52
+ }
53
+ }
54
+
55
+ export function loadProjectPolicy(root) {
56
+ if (!root) return null;
57
+ const file = join(root, '.aimhooman.json');
58
+ let stat;
59
+ try {
60
+ stat = lstatSync(file);
61
+ } catch (error) {
62
+ if (error?.code === 'ENOENT') return null;
63
+ throw new ProjectPolicyError(file, `cannot inspect file: ${error.message}`, error);
64
+ }
65
+ if (!stat.isFile()) {
66
+ throw new ProjectPolicyError(file, 'must be a regular file, not a symlink or special file');
67
+ }
68
+ let text;
69
+ try {
70
+ text = readFileSync(file, 'utf8');
71
+ } catch (error) {
72
+ throw new ProjectPolicyError(file, `cannot read file: ${error.message}`, error);
73
+ }
74
+ return parseProjectPolicy(text, file);
75
+ }
76
+
77
+ // Strip a single leading UTF-8 BOM: Node's utf8 decoder keeps it, and
78
+ // JSON.parse rejects it with a misleading "invalid JSON" syntax error.
79
+ function stripBom(text) {
80
+ return typeof text === 'string' && text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
81
+ }
82
+
83
+ export function parseProjectPolicy(text, file = '.aimhooman.json') {
84
+ let policy;
85
+ try {
86
+ policy = JSON.parse(stripBom(text));
87
+ } catch (error) {
88
+ throw new ProjectPolicyError(file, `invalid JSON: ${error.message}`, error);
89
+ }
90
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
91
+ throw new ProjectPolicyError(file, 'root must be a JSON object');
92
+ }
93
+ const unknown = Object.keys(policy).filter((key) => !['schema_version', 'profile'].includes(key));
94
+ if (unknown.length) {
95
+ throw new ProjectPolicyError(file, `unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
96
+ }
97
+ if (policy.schema_version !== 1) {
98
+ throw new ProjectPolicyError(file, 'schema_version must be 1');
99
+ }
100
+ if (!PROFILES.has(policy.profile)) {
101
+ throw new ProjectPolicyError(file, 'profile must be clean, strict, or compliance');
102
+ }
103
+ return { schemaVersion: 1, profile: policy.profile, file };
104
+ }
105
+
106
+ export function loadConfig(stateDir, root) {
107
+ const project = loadProjectPolicy(root);
108
+ if (project) return { profile: project.profile, source: 'project', file: project.file };
109
+ const file = join(stateDir, 'config.json');
110
+ let text;
111
+ try {
112
+ text = readFileSync(file, 'utf8');
113
+ } catch (error) {
114
+ if (error?.code === 'ENOENT') return { profile: 'clean', source: 'default' };
115
+ throw new LocalConfigError(file, `cannot read file: ${error.message}`, error);
116
+ }
117
+ let config;
118
+ try {
119
+ config = JSON.parse(stripBom(text));
120
+ } catch (error) {
121
+ throw new LocalConfigError(file, `invalid JSON: ${error.message}`, error);
122
+ }
123
+ const normalized = normalizeLocalConfig(config, file);
124
+ return { profile: normalized.profile, source: 'local', file };
125
+ }
126
+
127
+ export function saveConfig(stateDir, config) {
128
+ const file = join(stateDir, 'config.json');
129
+ const normalized = normalizeLocalConfig(config, file);
130
+ atomicWriteJson(file, { schema_version: 1, profile: normalized.profile });
131
+ }
132
+
133
+ function normalizeLocalConfig(config, file) {
134
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
135
+ throw new LocalConfigError(file, 'root must be a JSON object');
136
+ }
137
+ const unknown = Object.keys(config).filter((key) => !['schema_version', 'profile'].includes(key));
138
+ if (unknown.length) {
139
+ throw new LocalConfigError(file, `unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
140
+ }
141
+ // Unversioned files written by pre-release builds remain readable. Every
142
+ // subsequent save writes schema_version=1, while unknown/future versions
143
+ // fail closed instead of being interpreted under the wrong contract.
144
+ if (config.schema_version !== undefined && config.schema_version !== 1) {
145
+ throw new LocalConfigError(file, 'schema_version must be 1');
146
+ }
147
+ if (!PROFILES.has(config.profile)) {
148
+ throw new LocalConfigError(file, 'profile must be clean, strict, or compliance');
149
+ }
150
+ return { profile: config.profile };
151
+ }
152
+
153
+ export function loadOverrides(stateDir) {
154
+ const file = join(stateDir, 'overrides.json');
155
+ let text;
156
+ try {
157
+ text = readFileSync(file, 'utf8');
158
+ } catch (error) {
159
+ if (error?.code === 'ENOENT') return { allow: [], deny: [] };
160
+ throw new LocalOverridesError(file, `cannot read file: ${error.message}`, error);
161
+ }
162
+ return parseOverrides(text, file);
163
+ }
164
+
165
+ export function saveOverrides(stateDir, overrides) {
166
+ const file = join(stateDir, 'overrides.json');
167
+ const normalized = normalizeOverrides(overrides, file);
168
+ atomicWriteJson(file, { schema_version: 1, ...normalized });
169
+ }
170
+
171
+ export function normalizeOverrideTarget(target) {
172
+ if (typeof target !== 'string') return '';
173
+ return normalizeGitPath(target);
174
+ }
175
+
176
+ function parseOverrides(text, file) {
177
+ let value;
178
+ try {
179
+ value = JSON.parse(stripBom(text));
180
+ } catch (error) {
181
+ throw new LocalOverridesError(file, `invalid JSON: ${error.message}`, error);
182
+ }
183
+ return normalizeOverrides(value, file);
184
+ }
185
+
186
+ function normalizeOverrides(value, file) {
187
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
188
+ throw new LocalOverridesError(file, 'root must be a JSON object');
189
+ }
190
+ const unknown = Object.keys(value).filter((key) => !['schema_version', 'allow', 'deny'].includes(key));
191
+ if (unknown.length) {
192
+ throw new LocalOverridesError(file, `unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
193
+ }
194
+ // Older local files had no schema_version. Reads keep that compatibility;
195
+ // saveOverrides always rewrites the canonical versioned form.
196
+ if (value.schema_version !== undefined && value.schema_version !== 1) {
197
+ throw new LocalOverridesError(file, 'schema_version must be 1');
198
+ }
199
+ return {
200
+ allow: overrideEntries(value.allow, 'allow', file),
201
+ deny: overrideEntries(value.deny, 'deny', file),
202
+ };
203
+ }
204
+
205
+ function overrideEntries(value, key, file) {
206
+ if (value === undefined) return [];
207
+ if (!Array.isArray(value)) {
208
+ throw new LocalOverridesError(file, `${key} must be an array`);
209
+ }
210
+ return value.map((entry, index) => {
211
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
212
+ throw new LocalOverridesError(file, `${key}[${index}] must be an object`);
213
+ }
214
+ const unknown = Object.keys(entry).filter((field) => !OVERRIDE_FIELDS.has(field));
215
+ if (unknown.length) {
216
+ throw new LocalOverridesError(
217
+ file,
218
+ `${key}[${index}] has unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`,
219
+ );
220
+ }
221
+ const target = normalizeOverrideTarget(entry.target);
222
+ if (!target) {
223
+ throw new LocalOverridesError(file, `${key}[${index}].target must be a non-empty string`);
224
+ }
225
+ const scope = entry.scope;
226
+ if (scope !== undefined && !OVERRIDE_SCOPES.has(scope)) {
227
+ throw new LocalOverridesError(file, `${key}[${index}].scope is unsupported`);
228
+ }
229
+ if (key === 'deny' && scope !== undefined && !['path', 'rule'].includes(scope)) {
230
+ throw new LocalOverridesError(file, `${key}[${index}].scope ${scope} is only valid for allow entries`);
231
+ }
232
+ for (const field of ['reason', 'actor', 'at', 'transition']) {
233
+ if (entry[field] !== undefined && typeof entry[field] !== 'string') {
234
+ throw new LocalOverridesError(file, `${key}[${index}].${field} must be a string`);
235
+ }
236
+ }
237
+ if (entry.at !== undefined && !isRfc3339DateTime(entry.at)) {
238
+ throw new LocalOverridesError(file, `${key}[${index}].at must be an RFC3339 date-time`);
239
+ }
240
+ for (const field of ['head', 'oldObjectId']) {
241
+ if (entry[field] !== undefined && !OBJECT_ID.test(entry[field])) {
242
+ throw new LocalOverridesError(file, `${key}[${index}].${field} must be a full Git object ID`);
243
+ }
244
+ }
245
+ if (entry.newObjectId !== undefined && entry.newObjectId !== null && !OBJECT_ID.test(entry.newObjectId)) {
246
+ throw new LocalOverridesError(file, `${key}[${index}].newObjectId must be a full Git object ID or null`);
247
+ }
248
+ if (entry.newMode !== undefined && entry.newMode !== null && !REVIEWED_MODE.test(entry.newMode)) {
249
+ throw new LocalOverridesError(file, `${key}[${index}].newMode must be a regular-file Git mode or null`);
250
+ }
251
+ validateOverrideBinding(entry, key, index, file);
252
+ // Entries written before scoped overrides did not carry a scope. Keep
253
+ // that distinction so the engine can infer rule IDs from its catalog;
254
+ // treating every legacy entry as a path changes existing policy.
255
+ const normalized = { ...entry, target };
256
+ for (const field of ['head', 'oldObjectId', 'newObjectId']) {
257
+ if (typeof normalized[field] === 'string') normalized[field] = normalized[field].toLowerCase();
258
+ }
259
+ if (normalized.transition !== 'staged' && typeof normalized.transition === 'string') {
260
+ normalized.transition = normalized.transition.toLowerCase();
261
+ }
262
+ return normalized;
263
+ });
264
+ }
265
+
266
+ function isRfc3339DateTime(value) {
267
+ if (!RFC3339_DATE_TIME.test(value) || !Number.isFinite(Date.parse(value))) return false;
268
+ const date = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
269
+ if (!date) return false;
270
+ const year = Number(date[1]);
271
+ const month = Number(date[2]);
272
+ const day = Number(date[3]);
273
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
274
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
275
+ return day <= daysInMonth[month - 1];
276
+ }
277
+
278
+ function validateOverrideBinding(entry, key, index, file) {
279
+ const label = `${key}[${index}]`;
280
+ const scope = entry.scope;
281
+ const has = (field) => Object.prototype.hasOwnProperty.call(entry, field);
282
+ const reviewFields = ['head', 'transition', 'oldObjectId', 'newObjectId', 'newMode'];
283
+
284
+ if (scope === 'reviewed-instruction' || scope === 'reviewed-policy-file') {
285
+ for (const field of ['head', 'transition', 'newObjectId', 'newMode']) {
286
+ if (!has(field)) {
287
+ throw new LocalOverridesError(file, `${label}.${field} is required for ${scope}`);
288
+ }
289
+ }
290
+ if (entry.transition !== 'staged' && !OBJECT_ID.test(entry.transition)) {
291
+ throw new LocalOverridesError(
292
+ file,
293
+ `${label}.transition must be "staged" or a full Git object ID for ${scope}`,
294
+ );
295
+ }
296
+ if (entry.newObjectId !== null && !OBJECT_ID.test(entry.newObjectId || '')) {
297
+ throw new LocalOverridesError(
298
+ file,
299
+ `${label}.newObjectId must be a full Git blob ID or null for ${scope}`,
300
+ );
301
+ }
302
+ if ((entry.newObjectId === null) !== (entry.newMode === null)) {
303
+ throw new LocalOverridesError(
304
+ file,
305
+ `${label}.newObjectId and .newMode must both describe a blob or both be null for ${scope}`,
306
+ );
307
+ }
308
+ for (const field of ['oldObjectId']) {
309
+ if (has(field)) {
310
+ throw new LocalOverridesError(file, `${label}.${field} is only valid for policy-migration`);
311
+ }
312
+ }
313
+ return;
314
+ }
315
+
316
+ if (scope === 'policy-migration') {
317
+ if (key !== 'allow') {
318
+ throw new LocalOverridesError(file, `${label}.scope policy-migration is only valid for allow entries`);
319
+ }
320
+ if (normalizeOverrideTarget(entry.target) !== '.aimhooman.json') {
321
+ throw new LocalOverridesError(file, `${label}.target must be .aimhooman.json for policy-migration`);
322
+ }
323
+ for (const field of ['head', 'transition', 'oldObjectId', 'newObjectId', 'newMode']) {
324
+ if (!has(field)) {
325
+ throw new LocalOverridesError(file, `${label}.${field} is required for policy-migration`);
326
+ }
327
+ }
328
+ if (entry.transition !== 'staged' && !OBJECT_ID.test(entry.transition)) {
329
+ throw new LocalOverridesError(
330
+ file,
331
+ `${label}.transition must be "staged" or a full Git object ID`,
332
+ );
333
+ }
334
+ if ((entry.newObjectId === null) !== (entry.newMode === null)) {
335
+ throw new LocalOverridesError(
336
+ file,
337
+ `${label}.newObjectId and .newMode must both describe a blob or both be null for policy-migration`,
338
+ );
339
+ }
340
+ return;
341
+ }
342
+
343
+ for (const field of reviewFields) {
344
+ if (has(field)) {
345
+ throw new LocalOverridesError(file, `${label}.${field} requires a review-bound scope`);
346
+ }
347
+ }
348
+ }
349
+
350
+ function atomicWriteJson(file, value) {
351
+ let text;
352
+ try {
353
+ text = JSON.stringify(value, null, 2) + '\n';
354
+ } catch (error) {
355
+ throw new TypeError(`cannot serialize "${file}": ${error.message}`, { cause: error });
356
+ }
357
+
358
+ mkdirSync(dirname(file), { recursive: true });
359
+ atomicWrite(file, text);
360
+ }