@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.
- package/.agents/rules/aimhooman.md +53 -0
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.clinerules/aimhooman.md +53 -0
- package/.codex-plugin/plugin.json +37 -0
- package/.cursor/rules/aimhooman.mdc +59 -0
- package/.gemini/settings.json +7 -0
- package/.github/copilot-instructions.md +53 -0
- package/.github/hooks/aimhooman.json +22 -0
- package/.kiro/steering/aimhooman.md +53 -0
- package/.windsurf/rules/aimhooman.md +57 -0
- package/AGENTS.md +53 -0
- package/CHANGELOG.md +153 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +144 -0
- package/GEMINI.md +53 -0
- package/LICENSE +21 -0
- package/README.md +472 -0
- package/SECURITY.md +74 -0
- package/bin/aimhooman.mjs +1589 -0
- package/docs/design/agent-portability.md +73 -0
- package/docs/design/frictionless-enforcement.md +135 -0
- package/docs/hosts.json +164 -0
- package/docs/logo/aimhooman-logo.png +0 -0
- package/docs/logo/aimhooman.png +0 -0
- package/hooks/hooks.json +29 -0
- package/package.json +77 -0
- package/rules/attribution.json +142 -0
- package/rules/markers.json +88 -0
- package/rules/paths.json +407 -0
- package/rules/secrets.json +90 -0
- package/schemas/overrides.schema.json +134 -0
- package/schemas/project-policy.schema.json +12 -0
- package/schemas/rule-pack.schema.json +126 -0
- package/schemas/scan-report.schema.json +165 -0
- package/skills/aimhooman/SKILL.md +65 -0
- package/src/args.mjs +72 -0
- package/src/atomic-write.mjs +285 -0
- package/src/codex-manifest.mjs +65 -0
- package/src/exclude.mjs +125 -0
- package/src/git-environment.mjs +5 -0
- package/src/git-path.mjs +5 -0
- package/src/githooks.mjs +813 -0
- package/src/gitx.mjs +721 -0
- package/src/history-scan.mjs +295 -0
- package/src/hook.mjs +2602 -0
- package/src/policy-resolver.mjs +200 -0
- package/src/report.mjs +63 -0
- package/src/rules.mjs +509 -0
- package/src/ruleset-text.mjs +29 -0
- package/src/scan-session.mjs +152 -0
- package/src/scan-target.mjs +697 -0
- package/src/scan.mjs +325 -0
- 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
|
+
}
|