@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,200 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { hashBlob, readCommitPath, readStagedPath } from './gitx.mjs';
|
|
4
|
+
import { loadConfig, parseProjectPolicy, ProjectPolicyError } from './state.mjs';
|
|
5
|
+
|
|
6
|
+
const POLICY_PATH = '.aimhooman.json';
|
|
7
|
+
const PROFILES = new Set(['clean', 'strict', 'compliance']);
|
|
8
|
+
|
|
9
|
+
class PolicyTargetError extends Error {
|
|
10
|
+
constructor(detail) {
|
|
11
|
+
super(`policy target: ${detail}`);
|
|
12
|
+
this.name = 'PolicyTargetError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PolicyProfileError extends Error {
|
|
17
|
+
constructor(requested, required, invalid = false) {
|
|
18
|
+
super(invalid
|
|
19
|
+
? `explicit profile "${requested}" must be clean, strict, or compliance`
|
|
20
|
+
: (
|
|
21
|
+
`explicit profile "${requested}" cannot lower target profile "${required}"; ` +
|
|
22
|
+
'it may only match the target or escalate it to strict'
|
|
23
|
+
));
|
|
24
|
+
this.name = 'PolicyProfileError';
|
|
25
|
+
this.requested = requested;
|
|
26
|
+
this.required = required;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolvePolicy(repo, options = {}) {
|
|
31
|
+
if (!repo?.root || !repo?.stateDir) {
|
|
32
|
+
throw new TypeError('repo must be an open repository');
|
|
33
|
+
}
|
|
34
|
+
const target = normalizeTarget(
|
|
35
|
+
targetOption(options),
|
|
36
|
+
optionValue(options, 'revision') ?? optionValue(options, 'commit'),
|
|
37
|
+
);
|
|
38
|
+
let resolved;
|
|
39
|
+
if (target.kind === 'worktree') {
|
|
40
|
+
resolved = resolveWorktreePolicy(repo);
|
|
41
|
+
} else if (target.kind === 'staged') {
|
|
42
|
+
resolved = resolveGitPolicy(repo, readStagedPath(repo, POLICY_PATH), 'staged-policy');
|
|
43
|
+
} else {
|
|
44
|
+
resolved = resolveGitPolicy(
|
|
45
|
+
repo,
|
|
46
|
+
readCommitPath(repo, target.revision, POLICY_PATH),
|
|
47
|
+
'commit-policy',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const strictFloor = strictFloorOption(options);
|
|
52
|
+
if (strictFloor) {
|
|
53
|
+
const floorSource = strictFloor.source;
|
|
54
|
+
resolved = applyStrictFloor(resolved, floorSource);
|
|
55
|
+
}
|
|
56
|
+
return applyExplicitProfile(
|
|
57
|
+
resolved,
|
|
58
|
+
optionValue(options, 'explicitProfile') ?? optionValue(options, 'profile'),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function applyExplicitProfile(resolved, explicitProfile) {
|
|
63
|
+
if (explicitProfile === undefined || explicitProfile === null || explicitProfile === '') {
|
|
64
|
+
return resolved;
|
|
65
|
+
}
|
|
66
|
+
if (!PROFILES.has(explicitProfile)) {
|
|
67
|
+
throw new PolicyProfileError(explicitProfile, resolved.profile, true);
|
|
68
|
+
}
|
|
69
|
+
if (explicitProfile === resolved.profile) return resolved;
|
|
70
|
+
if (explicitProfile !== 'strict') {
|
|
71
|
+
throw new PolicyProfileError(explicitProfile, resolved.profile);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...resolved,
|
|
75
|
+
profile: 'strict',
|
|
76
|
+
source: `${resolved.source}+explicit-strict`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function applyStrictFloor(resolved, source = 'strict-floor') {
|
|
81
|
+
if (resolved.profile === 'strict') return resolved;
|
|
82
|
+
if (typeof source !== 'string' || !source) {
|
|
83
|
+
throw new TypeError('strict floor source must be a non-empty string');
|
|
84
|
+
}
|
|
85
|
+
return { ...resolved, profile: 'strict', source };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveWorktreePolicy(repo) {
|
|
89
|
+
const file = join(repo.root, POLICY_PATH);
|
|
90
|
+
let stat;
|
|
91
|
+
try {
|
|
92
|
+
stat = lstatSync(file);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error?.code === 'ENOENT') return localFallback(repo, 'worktree');
|
|
95
|
+
throw new ProjectPolicyError(file, `cannot inspect file: ${error.message}`, error);
|
|
96
|
+
}
|
|
97
|
+
if (!stat.isFile()) {
|
|
98
|
+
throw new ProjectPolicyError(file, 'must be a regular file, not a symlink or special file');
|
|
99
|
+
}
|
|
100
|
+
let content;
|
|
101
|
+
try {
|
|
102
|
+
content = readFileSync(file);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new ProjectPolicyError(file, `cannot read file: ${error.message}`, error);
|
|
105
|
+
}
|
|
106
|
+
const policy = parseProjectPolicy(content.toString('utf8'), file);
|
|
107
|
+
return {
|
|
108
|
+
profile: policy.profile,
|
|
109
|
+
source: 'worktree-policy',
|
|
110
|
+
target: 'worktree',
|
|
111
|
+
policy_object_id: hashBlob(repo, content),
|
|
112
|
+
policy_mode: '100644',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveGitPolicy(repo, result, source) {
|
|
117
|
+
if (result.status === 'missing') return localFallback(repo, result.target);
|
|
118
|
+
if (!['100644', '100755'].includes(result.mode)) {
|
|
119
|
+
throw new ProjectPolicyError(
|
|
120
|
+
`${result.target}:${POLICY_PATH}`,
|
|
121
|
+
`must be a regular Git file, not mode ${result.mode || 'unknown'}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const policy = parseProjectPolicy(
|
|
125
|
+
result.content.toString('utf8'),
|
|
126
|
+
`${result.target}:${POLICY_PATH}`,
|
|
127
|
+
);
|
|
128
|
+
return {
|
|
129
|
+
profile: policy.profile,
|
|
130
|
+
source,
|
|
131
|
+
target: result.target,
|
|
132
|
+
policy_object_id: result.oid,
|
|
133
|
+
policy_mode: result.mode,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function localFallback(repo, target) {
|
|
138
|
+
const config = loadConfig(repo.stateDir);
|
|
139
|
+
return {
|
|
140
|
+
profile: config.profile,
|
|
141
|
+
source: config.source,
|
|
142
|
+
target,
|
|
143
|
+
policy_object_id: null,
|
|
144
|
+
policy_mode: null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function targetOption(options) {
|
|
149
|
+
if (typeof options === 'string') return options;
|
|
150
|
+
if (!options || typeof options !== 'object') return 'worktree';
|
|
151
|
+
if (options.target !== undefined) return options.target;
|
|
152
|
+
if (options.kind !== undefined || options.type !== undefined) return options;
|
|
153
|
+
if (options.commit !== undefined || options.revision !== undefined) return 'commit';
|
|
154
|
+
return 'worktree';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function optionValue(options, key) {
|
|
158
|
+
return options && typeof options === 'object' ? options[key] : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function strictFloorOption(options) {
|
|
162
|
+
const value = optionValue(options, 'strictFloor');
|
|
163
|
+
if (!value) return null;
|
|
164
|
+
if (typeof value === 'object' && value.enabled === false) return null;
|
|
165
|
+
if (typeof value === 'object' && value.profile && value.profile !== 'strict') {
|
|
166
|
+
throw new TypeError('strict floor profile must be strict');
|
|
167
|
+
}
|
|
168
|
+
const source = typeof value === 'string'
|
|
169
|
+
? value
|
|
170
|
+
: (typeof value === 'object' ? value.source : optionValue(options, 'strictFloorSource'));
|
|
171
|
+
return { source: source ?? 'strict-floor' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeTarget(target, revisionOption) {
|
|
175
|
+
if (target === undefined || target === 'worktree') return { kind: 'worktree' };
|
|
176
|
+
if (target === 'staged') return { kind: 'staged' };
|
|
177
|
+
if (target === 'commit') {
|
|
178
|
+
if (typeof revisionOption !== 'string' || !revisionOption) {
|
|
179
|
+
throw new PolicyTargetError('commit target needs a revision');
|
|
180
|
+
}
|
|
181
|
+
return { kind: 'commit', revision: revisionOption };
|
|
182
|
+
}
|
|
183
|
+
if (typeof target === 'string' && target.startsWith('commit:')) {
|
|
184
|
+
const revision = target.slice('commit:'.length);
|
|
185
|
+
if (!revision) throw new PolicyTargetError('commit target needs a revision');
|
|
186
|
+
return { kind: 'commit', revision };
|
|
187
|
+
}
|
|
188
|
+
if (target && typeof target === 'object') {
|
|
189
|
+
const kind = target.kind ?? target.type;
|
|
190
|
+
if (kind === 'worktree' || kind === 'staged') return { kind };
|
|
191
|
+
if (kind === 'commit') {
|
|
192
|
+
const revision = target.revision ?? target.commit ?? target.ref ?? revisionOption;
|
|
193
|
+
if (typeof revision !== 'string' || !revision) {
|
|
194
|
+
throw new PolicyTargetError('commit target needs a revision');
|
|
195
|
+
}
|
|
196
|
+
return { kind, revision };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw new PolicyTargetError('must be worktree, staged, or a commit revision');
|
|
200
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Human-facing and JSON reporting for aimhooman findings.
|
|
2
|
+
|
|
3
|
+
export function human(findings, tone) {
|
|
4
|
+
if (!findings.length) return '';
|
|
5
|
+
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
6
|
+
let block = 0;
|
|
7
|
+
let review = 0;
|
|
8
|
+
for (const f of findings) {
|
|
9
|
+
if (f.decision === 'block') block += 1;
|
|
10
|
+
else if (f.decision === 'review') review += 1;
|
|
11
|
+
const loc = f.path
|
|
12
|
+
? `${visible(f.path)}${f.line ? `:${f.line}` : ''}`
|
|
13
|
+
: (f.line ? `commit message line ${f.line}` : '');
|
|
14
|
+
const related = (f.matchedRuleIds || []).filter((id) => id !== f.ruleId);
|
|
15
|
+
const identity = related.length ? `${f.ruleId} (+ ${related.join(', ')})` : f.ruleId;
|
|
16
|
+
const commit = f.commit ? ` [commit ${String(f.commit).slice(0, 12)}]` : '';
|
|
17
|
+
out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${f.reason}\n`;
|
|
18
|
+
if (f.text && f.text.trim()) {
|
|
19
|
+
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
20
|
+
}
|
|
21
|
+
if (f.remediation?.length) out += ` fix: ${f.remediation[0]}\n`;
|
|
22
|
+
out += '\n';
|
|
23
|
+
}
|
|
24
|
+
out += `${findings.length} findings: ${block} block, ${review} review\n`;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function visible(value) {
|
|
29
|
+
return String(value).replace(/[\u0000-\u001f\u007f]/g, (character) => {
|
|
30
|
+
if (character === '\n') return '\\n';
|
|
31
|
+
if (character === '\r') return '\\r';
|
|
32
|
+
if (character === '\t') return '\\t';
|
|
33
|
+
return `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function jsonReport(findings, metadata = {}) {
|
|
38
|
+
const safe = findings.map((finding) => (
|
|
39
|
+
isSensitive(finding) && finding.text
|
|
40
|
+
? { ...finding, text: '[redacted]' }
|
|
41
|
+
: finding
|
|
42
|
+
));
|
|
43
|
+
return JSON.stringify({ schema_version: 1, ...metadata, findings: safe }, null, 2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSensitive(finding) {
|
|
47
|
+
return finding?.category === 'secret'
|
|
48
|
+
|| finding?.matchedRules?.some((match) => match.category === 'secret') === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Exit codes: 0 clean, 10 blocked, 11 review required, 31 incomplete scan.
|
|
52
|
+
export function exitCode(findings, profile, complete = true) {
|
|
53
|
+
let block = false;
|
|
54
|
+
let review = false;
|
|
55
|
+
for (const f of findings) {
|
|
56
|
+
if (f.decision === 'block') block = true;
|
|
57
|
+
else if (f.decision === 'review') review = true;
|
|
58
|
+
}
|
|
59
|
+
if (block) return 10;
|
|
60
|
+
if (!complete) return 31;
|
|
61
|
+
if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
|
|
62
|
+
return 0;
|
|
63
|
+
}
|