@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,285 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import {
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ renameSync,
12
+ rmSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { basename, dirname, join } from 'node:path';
18
+
19
+ export function atomicWrite(file, data, options = {}) {
20
+ const directory = dirname(file);
21
+ const temporary = join(directory, `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
22
+ const mode = options.mode ?? existingMode(file) ?? 0o600;
23
+ const operations = {
24
+ open: openSync,
25
+ write: writeFileSync,
26
+ sync: fsyncSync,
27
+ close: closeSync,
28
+ rename: renameSync,
29
+ remove: rmSync,
30
+ openDirectory: openSync,
31
+ syncDirectory: fsyncSync,
32
+ closeDirectory: closeSync,
33
+ ...options.operations,
34
+ };
35
+ let descriptor;
36
+ try {
37
+ descriptor = operations.open(temporary, 'wx', mode);
38
+ operations.write(descriptor, data);
39
+ operations.sync(descriptor);
40
+ operations.close(descriptor);
41
+ descriptor = undefined;
42
+ operations.rename(temporary, file);
43
+ try {
44
+ syncDirectory(directory, operations);
45
+ } catch (error) {
46
+ throw new Error(
47
+ `replaced "${file}" but could not sync its directory; durability is uncertain: ${error.message}`,
48
+ { cause: error },
49
+ );
50
+ }
51
+ } catch (error) {
52
+ if (descriptor !== undefined) {
53
+ try { operations.close(descriptor); } catch { /* keep the write error */ }
54
+ }
55
+ try { operations.remove(temporary, { force: true }); } catch { /* keep the write error */ }
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ function existingMode(file) {
61
+ try {
62
+ return statSync(file).mode & 0o777;
63
+ } catch (error) {
64
+ if (error?.code === 'ENOENT') return null;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ function syncDirectory(directory, operations) {
70
+ let descriptor;
71
+ let failure = null;
72
+ try {
73
+ descriptor = operations.openDirectory(directory, 'r');
74
+ operations.syncDirectory(descriptor);
75
+ } catch (error) {
76
+ failure = error;
77
+ }
78
+ let closeFailure = null;
79
+ if (descriptor !== undefined) {
80
+ try { operations.closeDirectory(descriptor); }
81
+ catch (error) { closeFailure = error; }
82
+ }
83
+ if (failure && !['EINVAL', 'ENOTSUP', 'EISDIR', 'EBADF', 'EPERM'].includes(failure.code)) {
84
+ throw failure;
85
+ }
86
+ if (closeFailure) throw closeFailure;
87
+ }
88
+
89
+ const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
90
+
91
+ function waitForLock(milliseconds) {
92
+ if (milliseconds > 0) Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, milliseconds);
93
+ }
94
+
95
+ function processIdentity(pid) {
96
+ try {
97
+ if (process.platform === 'linux') {
98
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
99
+ const afterName = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
100
+ const startTicks = afterName[19];
101
+ return startTicks ? `linux:${startTicks}` : null;
102
+ }
103
+ if (process.platform === 'win32') {
104
+ // Node has no direct Windows process-start identity API. Starting
105
+ // PowerShell for every lock contender can take longer than the lock
106
+ // retry window under load. Keep the PID-only fallback: a dead owner
107
+ // is removable, while a reused live PID conservatively retains the
108
+ // stale candidate instead of risking two writers in one lock.
109
+ return null;
110
+ }
111
+ if (process.platform !== 'win32') {
112
+ const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
113
+ encoding: 'utf8',
114
+ stdio: ['ignore', 'pipe', 'ignore'],
115
+ }).trim();
116
+ return started ? `ps:${started}` : null;
117
+ }
118
+ } catch {
119
+ // A missing process or an unavailable platform probe is handled by the
120
+ // kill(0) check below. Unknown identity fails safe as an active owner.
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function lockOwnerIsActive(owner) {
126
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 0 || typeof owner.token !== 'string') {
127
+ return false;
128
+ }
129
+ try {
130
+ process.kill(owner.pid, 0);
131
+ } catch (error) {
132
+ return error?.code !== 'ESRCH';
133
+ }
134
+ if (typeof owner.processIdentity === 'string') {
135
+ const activeIdentity = processIdentity(owner.pid);
136
+ // If the platform cannot prove identity, retain the lock. A mismatch is
137
+ // proof that the PID was reused after the recorded holder exited.
138
+ if (activeIdentity && activeIdentity !== owner.processIdentity) return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ const LOCK_CANDIDATE = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.json$/i;
144
+
145
+ function readCandidate(path, token) {
146
+ let candidate;
147
+ try {
148
+ candidate = JSON.parse(readFileSync(path, 'utf8'));
149
+ } catch (error) {
150
+ if (error?.code === 'ENOENT') return null;
151
+ throw new Error(`malformed state lock candidate "${path}"`, { cause: error });
152
+ }
153
+ const valid = candidate
154
+ && candidate.version === 1
155
+ && candidate.token === token
156
+ && Number.isSafeInteger(candidate.pid)
157
+ && candidate.pid > 0
158
+ && (candidate.processIdentity === null || typeof candidate.processIdentity === 'string')
159
+ && typeof candidate.choosing === 'boolean'
160
+ && (candidate.choosing
161
+ ? candidate.ticket === null
162
+ : Number.isSafeInteger(candidate.ticket) && candidate.ticket > 0);
163
+ if (!valid) throw new Error(`malformed state lock candidate "${path}"`);
164
+ return { ...candidate, path };
165
+ }
166
+
167
+ function removeDeadCandidate(candidate, staleMs) {
168
+ let age;
169
+ try {
170
+ age = Date.now() - statSync(candidate.path).mtimeMs;
171
+ } catch (error) {
172
+ if (error?.code === 'ENOENT') return true;
173
+ throw error;
174
+ }
175
+ if (age < staleMs || lockOwnerIsActive(candidate)) return false;
176
+ // Candidate names are random UUIDs and are never reused. Once its exact
177
+ // process identity is dead, no conforming writer can replace this path, so
178
+ // deleting it has none of the check-then-unlink race of a shared lockfile.
179
+ try {
180
+ unlinkSync(candidate.path);
181
+ } catch (error) {
182
+ if (error?.code !== 'ENOENT') throw error;
183
+ }
184
+ return true;
185
+ }
186
+
187
+ function queueCandidates(queueDir, staleMs) {
188
+ const candidates = [];
189
+ for (const entry of readdirSync(queueDir, { withFileTypes: true })) {
190
+ const match = entry.isFile() && LOCK_CANDIDATE.exec(entry.name);
191
+ if (!match) continue;
192
+ const candidate = readCandidate(join(queueDir, entry.name), match[1]);
193
+ if (!candidate || removeDeadCandidate(candidate, staleMs)) continue;
194
+ candidates.push(candidate);
195
+ }
196
+ return candidates;
197
+ }
198
+
199
+ function precedes(left, right) {
200
+ return left.ticket < right.ticket
201
+ || (left.ticket === right.ticket && left.token.localeCompare(right.token) < 0);
202
+ }
203
+
204
+ function publishCandidate(path, candidate, operations) {
205
+ atomicWrite(path, `${JSON.stringify(candidate)}\n`, { operations });
206
+ }
207
+
208
+ // withLock uses a Lamport bakery queue. Each contender owns one never-reused
209
+ // UUID path, so stale cleanup and release only unlink that contender's path;
210
+ // neither operation can remove a replacement lock at a shared pathname.
211
+ export function withLock(lockPath, fn, options = {}) {
212
+ const retries = options.retries ?? 50;
213
+ const staleMs = options.staleMs ?? 60000;
214
+ const retryDelayMs = options.retryDelayMs ?? 10;
215
+ mkdirSync(dirname(lockPath), { recursive: true });
216
+ const queueDir = `${lockPath}.queue`;
217
+ mkdirSync(queueDir, { recursive: true });
218
+ const token = randomUUID();
219
+ const candidatePath = join(queueDir, `${token}.json`);
220
+ const candidate = {
221
+ version: 1,
222
+ pid: process.pid,
223
+ token,
224
+ processIdentity: processIdentity(process.pid),
225
+ choosing: true,
226
+ ticket: null,
227
+ };
228
+ let held = false;
229
+ let published = false;
230
+ let primaryError = null;
231
+ try {
232
+ // Own the UUID pathname before publication begins. If the rename lands
233
+ // but its directory fsync fails, finally still removes the candidate;
234
+ // unlinking ENOENT is harmless when publication failed earlier.
235
+ published = true;
236
+ publishCandidate(candidatePath, candidate, options.candidateOperations);
237
+ const observed = queueCandidates(queueDir, staleMs);
238
+ candidate.ticket = observed.reduce((maximum, peer) => (
239
+ peer.ticket === null ? maximum : Math.max(maximum, peer.ticket)
240
+ ), 0) + 1;
241
+ candidate.choosing = false;
242
+ publishCandidate(candidatePath, candidate, options.candidateOperations);
243
+
244
+ for (let attempt = 0; attempt < retries; attempt += 1) {
245
+ // A file at the pre-queue lock path may belong to a concurrently
246
+ // running older aimhooman version. It cannot be removed safely, so
247
+ // an upgrade waits/fails closed until that legacy holder releases.
248
+ const legacyHolder = existsSync(lockPath);
249
+ const peers = queueCandidates(queueDir, staleMs);
250
+ const own = peers.find((peer) => peer.token === token);
251
+ if (!own) throw new Error(`state lock candidate "${candidatePath}" disappeared`);
252
+ const blocked = legacyHolder || peers.some((peer) => (
253
+ peer.token !== token && (peer.choosing || precedes(peer, own))
254
+ ));
255
+ if (!blocked) {
256
+ held = true;
257
+ break;
258
+ }
259
+ if (attempt + 1 < retries) waitForLock(retryDelayMs);
260
+ }
261
+ if (!held) {
262
+ throw new Error(`cannot acquire state lock "${lockPath}" after ${retries} attempts`);
263
+ }
264
+ return fn();
265
+ } catch (error) {
266
+ primaryError = error;
267
+ throw error;
268
+ } finally {
269
+ if (published) {
270
+ try { (options.unlinkCandidate || unlinkSync)(candidatePath); }
271
+ catch (error) {
272
+ if (error?.code !== 'ENOENT') {
273
+ if (primaryError) {
274
+ throw new AggregateError(
275
+ [primaryError, error],
276
+ `${primaryError.message}; also failed to release state lock: ${error.message}`,
277
+ { cause: primaryError },
278
+ );
279
+ }
280
+ throw error;
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+
4
+ const CODEX_PLUGIN_FIELDS = new Set([
5
+ 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license',
6
+ 'keywords', 'skills', 'mcpServers', 'apps', 'hooks', 'interface',
7
+ ]);
8
+
9
+ export function validateCodexManifest(manifest, pluginRoot) {
10
+ if (!plainObject(manifest)) throw new Error('Codex plugin manifest must be an object');
11
+ for (const field of Object.keys(manifest)) {
12
+ if (!CODEX_PLUGIN_FIELDS.has(field)) throw new Error(`unsupported Codex plugin field: ${field}`);
13
+ }
14
+ for (const field of ['name', 'version', 'description']) requireString(manifest[field], `plugin.${field}`);
15
+ if (manifest.author !== undefined) {
16
+ if (typeof manifest.author === 'string') requireString(manifest.author, 'plugin.author');
17
+ else requireString(manifest.author?.name, 'plugin.author.name');
18
+ }
19
+ for (const field of ['skills', 'apps', 'mcpServers']) {
20
+ if (manifest[field] !== undefined) validatePathValue(manifest[field], field, pluginRoot);
21
+ }
22
+ if (manifest.hooks !== undefined) validateHooks(manifest.hooks, pluginRoot);
23
+ return manifest;
24
+ }
25
+
26
+ function validateHooks(value, pluginRoot) {
27
+ if (typeof value === 'string') {
28
+ validatePath(value, 'hooks', pluginRoot);
29
+ return;
30
+ }
31
+ if (plainObject(value)) return;
32
+ if (!Array.isArray(value) || value.length === 0) {
33
+ throw new Error('plugin.hooks must be a path, inline object, or non-empty array');
34
+ }
35
+ const strings = value.every((entry) => typeof entry === 'string');
36
+ const objects = value.every(plainObject);
37
+ if (!strings && !objects) throw new Error('plugin.hooks array must contain only paths or only inline objects');
38
+ if (strings) for (const path of value) validatePath(path, 'hooks', pluginRoot);
39
+ }
40
+
41
+ function validatePathValue(value, field, pluginRoot) {
42
+ if (typeof value === 'string') return validatePath(value, field, pluginRoot);
43
+ throw new Error(`plugin.${field} must be a relative path`);
44
+ }
45
+
46
+ function validatePath(path, field, pluginRoot) {
47
+ requireString(path, `plugin.${field}`);
48
+ if (!path.startsWith('./') || isAbsolute(path)) {
49
+ throw new Error(`plugin.${field} path must start with ./: ${path}`);
50
+ }
51
+ const target = resolve(pluginRoot, path);
52
+ const outside = relative(pluginRoot, target);
53
+ if (outside === '..' || outside.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(outside)) {
54
+ throw new Error(`plugin.${field} path escapes the plugin root: ${path}`);
55
+ }
56
+ if (!existsSync(target)) throw new Error(`plugin.${field} path does not exist: ${path}`);
57
+ }
58
+
59
+ function plainObject(value) {
60
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
61
+ }
62
+
63
+ function requireString(value, field) {
64
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} must be a non-empty string`);
65
+ }
@@ -0,0 +1,125 @@
1
+ import { lstatSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { loadRules } from './rules.mjs';
4
+ import { atomicWrite, withLock } from './atomic-write.mjs';
5
+
6
+ const BEGIN = '# >>> aimhooman managed excludes (do not edit by hand)';
7
+ const END = '# <<< aimhooman managed excludes';
8
+
9
+ // Local AI residue kept out of `git status`. Deriving this list from the rule
10
+ // catalog prevents the prevention layer from drifting behind enforcement. We
11
+ // intentionally exclude only unambiguous tooling state/settings: secret and
12
+ // review-required rules must remain visible to users.
13
+ const AUTO_EXCLUDE_CATEGORIES = new Set(['ephemeral-state', 'local-settings']);
14
+
15
+ export function patternsForRules(rules) {
16
+ const patterns = new Set();
17
+ for (const rule of rules || []) {
18
+ if (rule.kind !== 'path' || !AUTO_EXCLUDE_CATEGORIES.has(rule.category)) continue;
19
+ if (rule.match?.except?.length) continue;
20
+ const actions = rule.actions || {};
21
+ if (['clean', 'strict', 'compliance'].some((p) => actions[p] !== 'block')) continue;
22
+ for (const pattern of rule.match?.paths || []) {
23
+ // .gitignore-style files are line based. Never let a local rule
24
+ // turn one catalog value into extra unmanaged exclude lines.
25
+ if (!/[\r\n]/.test(pattern)) patterns.add(pattern);
26
+ }
27
+ }
28
+ return [...patterns].sort();
29
+ }
30
+
31
+ // Default exclude patterns derived from the built-in catalog — a convenience
32
+ // for tests/inspection. Lazy + memoized so importing this module never triggers
33
+ // a rule-pack load (production callers derive patterns from their own engine
34
+ // rules via patternsForRules). Eager loading here would also make every command
35
+ // fail-closed on a corrupt rule pack, even commands that never exclude.
36
+ let cachedDefaultPatterns;
37
+ export function defaultPatterns() {
38
+ if (!cachedDefaultPatterns) cachedDefaultPatterns = patternsForRules(loadRules());
39
+ return cachedDefaultPatterns;
40
+ }
41
+
42
+ // applyExclude writes or refreshes the managed block. Idempotent.
43
+ export function applyExclude(file, patterns) {
44
+ validatePatterns(patterns);
45
+ return withLock(`${file}.aimhooman.lock`, () => {
46
+ mkdirSync(dirname(file), { recursive: true });
47
+ const existing = readExclude(file, '');
48
+ let body = stripBlock(existing);
49
+ if (body && !body.endsWith('\n')) body += '\n';
50
+ const block = BEGIN + '\n' + patterns.join('\n') + '\n' + END + '\n';
51
+ atomicWrite(file, body + block);
52
+ });
53
+ }
54
+
55
+ // removeExclude strips the managed block, keeping other excludes.
56
+ export function removeExclude(file) {
57
+ return withLock(`${file}.aimhooman.lock`, () => {
58
+ const existing = readExclude(file, null);
59
+ if (existing === null) return;
60
+ atomicWrite(file, stripBlock(existing));
61
+ });
62
+ }
63
+
64
+ export function inspectExclude(file, patterns) {
65
+ const existing = readExclude(file, null);
66
+ if (existing === null) {
67
+ return { installed: false, current: false, missing: [...patterns] };
68
+ }
69
+ const range = managedRange(existing);
70
+ if (!range) return { installed: false, current: false, missing: [...patterns] };
71
+ const { start, end } = range;
72
+ const actual = new Set(existing.slice(start + BEGIN.length, end).split('\n').map((s) => s.trim()).filter(Boolean));
73
+ const missing = patterns.filter((pattern) => !actual.has(pattern));
74
+ return { installed: true, current: missing.length === 0 && actual.size === patterns.length, missing };
75
+ }
76
+
77
+ function stripBlock(s) {
78
+ const range = managedRange(s);
79
+ if (!range) return s;
80
+ const { start, end } = range;
81
+ let before = s.slice(0, start).replace(/\n+$/, '');
82
+ if (before) before += '\n';
83
+ const rest = s.slice(end + END.length).replace(/^\n/, '');
84
+ return before + rest;
85
+ }
86
+
87
+ function readExclude(file, missing) {
88
+ try {
89
+ const stat = lstatSync(file);
90
+ if (stat.isSymbolicLink() || !stat.isFile()) {
91
+ throw new Error(`exclude path "${file}" must be a regular file`);
92
+ }
93
+ return readFileSync(file, 'utf8');
94
+ } catch (error) {
95
+ if (error?.code === 'ENOENT') return missing;
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ function managedRange(value) {
101
+ const start = value.indexOf(BEGIN);
102
+ const firstEnd = value.indexOf(END);
103
+ if (start < 0 && firstEnd < 0) return null;
104
+ if (start < 0 || firstEnd < start + BEGIN.length) {
105
+ throw new Error('managed exclude markers are malformed; repair the file by hand');
106
+ }
107
+ const nestedStart = value.indexOf(BEGIN, start + BEGIN.length);
108
+ const nextEnd = value.indexOf(END, firstEnd + END.length);
109
+ if (nestedStart >= 0 || nextEnd >= 0) {
110
+ throw new Error('managed exclude markers appear more than once; repair the file by hand');
111
+ }
112
+ return { start, end: firstEnd };
113
+ }
114
+
115
+ function validatePatterns(patterns) {
116
+ if (!Array.isArray(patterns) || patterns.some((pattern) => (
117
+ typeof pattern !== 'string'
118
+ || pattern.length === 0
119
+ || /[\r\n]/.test(pattern)
120
+ || pattern === BEGIN
121
+ || pattern === END
122
+ ))) {
123
+ throw new TypeError('exclude patterns must be non-empty single-line strings');
124
+ }
125
+ }
@@ -0,0 +1,5 @@
1
+ // Git replacement refs are local, mutable aliases. Policy and audit reads must
2
+ // address the repository's real object graph instead of a rewritten view.
3
+ export function gitEnvironment(environment = process.env) {
4
+ return { ...environment, GIT_NO_REPLACE_OBJECTS: '1' };
5
+ }
@@ -0,0 +1,5 @@
1
+ export function normalizeGitPath(value) {
2
+ let path = String(value || '');
3
+ if (process.platform === 'win32') path = path.replace(/\\/g, '/');
4
+ return path.replace(/^(?:\.\/)+/, '');
5
+ }