@rmyndharis/aimhooman 0.1.1 → 0.1.3
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 +5 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/aimhooman.md +5 -3
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +5 -3
- package/.github/copilot-instructions.md +5 -3
- package/.kiro/steering/aimhooman.md +5 -3
- package/.windsurf/rules/aimhooman.md +5 -3
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +177 -1
- package/CONTRIBUTING.md +36 -3
- package/GEMINI.md +5 -3
- package/README.md +18 -8
- package/bin/aimhooman.mjs +163 -49
- package/docs/design/frictionless-enforcement.md +28 -0
- package/package.json +1 -1
- package/rules/attribution.json +8 -8
- package/rules/markers.json +31 -5
- package/rules/paths.json +60 -33
- package/rules/secrets.json +2 -1
- package/skills/aimhooman/SKILL.md +5 -3
- package/src/atomic-write.mjs +18 -1
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +113 -19
- package/src/gitx.mjs +65 -270
- package/src/history-scan.mjs +8 -1
- package/src/hook.mjs +424 -47
- package/src/scan-session.mjs +11 -4
- package/src/scan-target.mjs +29 -3
package/rules/paths.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "claude.local-settings",
|
|
4
|
-
"version":
|
|
4
|
+
"version": 2,
|
|
5
5
|
"provider": "claude-code",
|
|
6
6
|
"category": "local-settings",
|
|
7
7
|
"confidence": "high",
|
|
8
8
|
"kind": "path",
|
|
9
9
|
"match": {
|
|
10
|
+
"path_case": "insensitive",
|
|
10
11
|
"paths": [
|
|
11
12
|
".claude/settings.local.json",
|
|
12
13
|
"**/.claude/settings.local.json"
|
|
@@ -24,17 +25,16 @@
|
|
|
24
25
|
},
|
|
25
26
|
{
|
|
26
27
|
"id": "claude.session-state",
|
|
27
|
-
"version":
|
|
28
|
+
"version": 2,
|
|
28
29
|
"provider": "claude-code",
|
|
29
30
|
"category": "ephemeral-state",
|
|
30
31
|
"confidence": "high",
|
|
31
32
|
"kind": "path",
|
|
32
33
|
"match": {
|
|
34
|
+
"path_case": "insensitive",
|
|
33
35
|
"paths": [
|
|
34
36
|
".claude.json",
|
|
35
37
|
"**/.claude.json",
|
|
36
|
-
".claude/.credentials.json",
|
|
37
|
-
"**/.claude/.credentials.json",
|
|
38
38
|
".claude/session*.json",
|
|
39
39
|
"**/.claude/session*.json",
|
|
40
40
|
".claude/history*",
|
|
@@ -64,12 +64,13 @@
|
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"id": "codex.session-state",
|
|
67
|
-
"version":
|
|
67
|
+
"version": 2,
|
|
68
68
|
"provider": "codex",
|
|
69
69
|
"category": "ephemeral-state",
|
|
70
70
|
"confidence": "high",
|
|
71
71
|
"kind": "path",
|
|
72
72
|
"match": {
|
|
73
|
+
"path_case": "insensitive",
|
|
73
74
|
"paths": [
|
|
74
75
|
".codex/sessions/**",
|
|
75
76
|
"**/.codex/sessions/**",
|
|
@@ -93,12 +94,13 @@
|
|
|
93
94
|
},
|
|
94
95
|
{
|
|
95
96
|
"id": "copilot.session-state",
|
|
96
|
-
"version":
|
|
97
|
+
"version": 3,
|
|
97
98
|
"provider": "copilot",
|
|
98
99
|
"category": "ephemeral-state",
|
|
99
100
|
"confidence": "high",
|
|
100
101
|
"kind": "path",
|
|
101
102
|
"match": {
|
|
103
|
+
"path_case": "insensitive",
|
|
102
104
|
"paths": [
|
|
103
105
|
".copilot/**",
|
|
104
106
|
"**/.copilot/**"
|
|
@@ -116,12 +118,13 @@
|
|
|
116
118
|
},
|
|
117
119
|
{
|
|
118
120
|
"id": "cursor.session-state",
|
|
119
|
-
"version":
|
|
121
|
+
"version": 2,
|
|
120
122
|
"provider": "cursor",
|
|
121
123
|
"category": "ephemeral-state",
|
|
122
124
|
"confidence": "medium",
|
|
123
125
|
"kind": "path",
|
|
124
126
|
"match": {
|
|
127
|
+
"path_case": "insensitive",
|
|
125
128
|
"paths": [
|
|
126
129
|
".cursor/session*",
|
|
127
130
|
"**/.cursor/session*",
|
|
@@ -143,12 +146,13 @@
|
|
|
143
146
|
},
|
|
144
147
|
{
|
|
145
148
|
"id": "aider.history",
|
|
146
|
-
"version":
|
|
149
|
+
"version": 3,
|
|
147
150
|
"provider": "aider",
|
|
148
151
|
"category": "ephemeral-state",
|
|
149
152
|
"confidence": "high",
|
|
150
153
|
"kind": "path",
|
|
151
154
|
"match": {
|
|
155
|
+
"path_case": "insensitive",
|
|
152
156
|
"paths": [
|
|
153
157
|
".aider.*",
|
|
154
158
|
"**/.aider.*"
|
|
@@ -166,12 +170,13 @@
|
|
|
166
170
|
},
|
|
167
171
|
{
|
|
168
172
|
"id": "specstory.history",
|
|
169
|
-
"version":
|
|
173
|
+
"version": 2,
|
|
170
174
|
"provider": "specstory",
|
|
171
175
|
"category": "ephemeral-state",
|
|
172
176
|
"confidence": "medium",
|
|
173
177
|
"kind": "path",
|
|
174
178
|
"match": {
|
|
179
|
+
"path_case": "insensitive",
|
|
175
180
|
"paths": [
|
|
176
181
|
".specstory/**",
|
|
177
182
|
"**/.specstory/**"
|
|
@@ -189,12 +194,13 @@
|
|
|
189
194
|
},
|
|
190
195
|
{
|
|
191
196
|
"id": "continue.sessions",
|
|
192
|
-
"version":
|
|
197
|
+
"version": 2,
|
|
193
198
|
"provider": "continue",
|
|
194
199
|
"category": "ephemeral-state",
|
|
195
200
|
"confidence": "medium",
|
|
196
201
|
"kind": "path",
|
|
197
202
|
"match": {
|
|
203
|
+
"path_case": "insensitive",
|
|
198
204
|
"paths": [
|
|
199
205
|
".continue/sessions/**",
|
|
200
206
|
"**/.continue/sessions/**"
|
|
@@ -212,7 +218,7 @@
|
|
|
212
218
|
},
|
|
213
219
|
{
|
|
214
220
|
"id": "secret.dotenv",
|
|
215
|
-
"version":
|
|
221
|
+
"version": 3,
|
|
216
222
|
"provider": "generic",
|
|
217
223
|
"category": "secret",
|
|
218
224
|
"confidence": "high",
|
|
@@ -226,16 +232,11 @@
|
|
|
226
232
|
"**/.env.*"
|
|
227
233
|
],
|
|
228
234
|
"except": [
|
|
229
|
-
"
|
|
230
|
-
"
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
-
"
|
|
234
|
-
"**/.env.template",
|
|
235
|
-
".env.dist",
|
|
236
|
-
"**/.env.dist",
|
|
237
|
-
".env.defaults",
|
|
238
|
-
"**/.env.defaults"
|
|
235
|
+
"**/*.example",
|
|
236
|
+
"**/*.sample",
|
|
237
|
+
"**/*.template",
|
|
238
|
+
"**/*.dist",
|
|
239
|
+
"**/*.defaults"
|
|
239
240
|
]
|
|
240
241
|
},
|
|
241
242
|
"actions": {
|
|
@@ -303,8 +304,33 @@
|
|
|
303
304
|
]
|
|
304
305
|
},
|
|
305
306
|
{
|
|
306
|
-
"id": "
|
|
307
|
+
"id": "secret.claude-credentials",
|
|
307
308
|
"version": 1,
|
|
309
|
+
"provider": "claude-code",
|
|
310
|
+
"category": "secret",
|
|
311
|
+
"confidence": "high",
|
|
312
|
+
"kind": "path",
|
|
313
|
+
"match": {
|
|
314
|
+
"path_case": "insensitive",
|
|
315
|
+
"paths": [
|
|
316
|
+
".claude/.credentials.json",
|
|
317
|
+
"**/.claude/.credentials.json"
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
"actions": {
|
|
321
|
+
"clean": "block",
|
|
322
|
+
"strict": "block",
|
|
323
|
+
"compliance": "block"
|
|
324
|
+
},
|
|
325
|
+
"reason": "This file holds an OAuth access token and must never be committed.",
|
|
326
|
+
"remediation": [
|
|
327
|
+
"git restore --staged <path>",
|
|
328
|
+
"Revoke the token if it already reached a commit."
|
|
329
|
+
]
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"id": "generic.agent-instructions",
|
|
333
|
+
"version": 2,
|
|
308
334
|
"provider": "generic",
|
|
309
335
|
"category": "ambiguous-instructions",
|
|
310
336
|
"confidence": "medium",
|
|
@@ -328,18 +354,19 @@
|
|
|
328
354
|
},
|
|
329
355
|
"reason": "Agent instruction files may be intentional team config. Review before committing.",
|
|
330
356
|
"remediation": [
|
|
331
|
-
"aimhooman
|
|
357
|
+
"aimhooman review <path> --head <commit> --reason \"shared team config\"",
|
|
332
358
|
"or unstage if it is personal"
|
|
333
359
|
]
|
|
334
360
|
},
|
|
335
361
|
{
|
|
336
362
|
"id": "generic.project-policy",
|
|
337
|
-
"version":
|
|
363
|
+
"version": 2,
|
|
338
364
|
"provider": "aimhooman",
|
|
339
365
|
"category": "policy-config",
|
|
340
366
|
"confidence": "high",
|
|
341
367
|
"kind": "path",
|
|
342
368
|
"match": {
|
|
369
|
+
"path_case": "insensitive",
|
|
343
370
|
"paths": [
|
|
344
371
|
".aimhooman.json",
|
|
345
372
|
"**/.aimhooman.json"
|
|
@@ -352,54 +379,54 @@
|
|
|
352
379
|
},
|
|
353
380
|
"reason": "Versioned enforcement policy changes require explicit human review.",
|
|
354
381
|
"remediation": [
|
|
355
|
-
"aimhooman
|
|
382
|
+
"aimhooman review .aimhooman.json --head <commit> --reason \"reviewed team policy change\"",
|
|
356
383
|
"or unstage the policy change"
|
|
357
384
|
]
|
|
358
385
|
},
|
|
359
386
|
{
|
|
360
387
|
"id": "playwright-mcp.state",
|
|
361
|
-
"version":
|
|
388
|
+
"version": 2,
|
|
362
389
|
"provider": "playwright-mcp",
|
|
363
390
|
"category": "ephemeral-state",
|
|
364
391
|
"confidence": "high",
|
|
365
392
|
"kind": "path",
|
|
366
|
-
"match": { "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
|
|
393
|
+
"match": { "path_case": "insensitive", "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
|
|
367
394
|
"actions": { "clean": "block", "strict": "block", "compliance": "block" },
|
|
368
395
|
"reason": "Playwright MCP session artifacts are local, not repository content.",
|
|
369
396
|
"remediation": ["git restore --staged <path>"]
|
|
370
397
|
},
|
|
371
398
|
{
|
|
372
399
|
"id": "remember.state",
|
|
373
|
-
"version":
|
|
400
|
+
"version": 2,
|
|
374
401
|
"provider": "remember",
|
|
375
402
|
"category": "ephemeral-state",
|
|
376
403
|
"confidence": "high",
|
|
377
404
|
"kind": "path",
|
|
378
|
-
"match": { "paths": [".remember/**", "**/.remember/**"] },
|
|
405
|
+
"match": { "path_case": "insensitive", "paths": [".remember/**", "**/.remember/**"] },
|
|
379
406
|
"actions": { "clean": "block", "strict": "block", "compliance": "block" },
|
|
380
407
|
"reason": "Remember second-brain data is local, not repository content.",
|
|
381
408
|
"remediation": ["git restore --staged <path>"]
|
|
382
409
|
},
|
|
383
410
|
{
|
|
384
411
|
"id": "superpowers.state",
|
|
385
|
-
"version":
|
|
412
|
+
"version": 2,
|
|
386
413
|
"provider": "superpowers",
|
|
387
414
|
"category": "ephemeral-state",
|
|
388
415
|
"confidence": "high",
|
|
389
416
|
"kind": "path",
|
|
390
|
-
"match": { "paths": [".superpowers/**", "**/.superpowers/**"] },
|
|
417
|
+
"match": { "path_case": "insensitive", "paths": [".superpowers/**", "**/.superpowers/**"] },
|
|
391
418
|
"actions": { "clean": "block", "strict": "block", "compliance": "block" },
|
|
392
419
|
"reason": "Superpowers plugin state is local, not repository content.",
|
|
393
420
|
"remediation": ["git restore --staged <path>"]
|
|
394
421
|
},
|
|
395
422
|
{
|
|
396
423
|
"id": "agent.state",
|
|
397
|
-
"version":
|
|
424
|
+
"version": 3,
|
|
398
425
|
"provider": "generic",
|
|
399
426
|
"category": "ephemeral-state",
|
|
400
427
|
"confidence": "high",
|
|
401
428
|
"kind": "path",
|
|
402
|
-
"match": { "paths": [".agent/**", "**/.agent/**"] },
|
|
429
|
+
"match": { "path_case": "insensitive", "paths": [".agent/**", "**/.agent/**"] },
|
|
403
430
|
"actions": { "clean": "block", "strict": "block", "compliance": "block" },
|
|
404
431
|
"reason": "Generic agent state is local, not repository content.",
|
|
405
432
|
"remediation": ["git restore --staged <path>"]
|
package/rules/secrets.json
CHANGED
|
@@ -74,7 +74,8 @@
|
|
|
74
74
|
"kind": "code",
|
|
75
75
|
"match": {
|
|
76
76
|
"content": [
|
|
77
|
-
"\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,255}|glpat-[A-Za-z0-9_-]{20,255}|npm_[A-Za-z0-9]{36,255}|xox[baprs]-[A-Za-z0-9-]{20,255})\\b"
|
|
77
|
+
"\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,255}|glpat-[A-Za-z0-9_-]{20,255}|npm_[A-Za-z0-9]{36,255}|xox[baprs]-[A-Za-z0-9-]{20,255})\\b",
|
|
78
|
+
"\\b(?:sk-ant-api[0-9]{2}-[A-Za-z0-9_-]{80,255}|sk-proj-[A-Za-z0-9_-]{40,255}|AIza[A-Za-z0-9_-]{35}|(?:sk|rk)_live_[A-Za-z0-9]{20,255}|hf_[A-Za-z0-9]{34}|SG\\.[A-Za-z0-9_-]{20,255}\\.[A-Za-z0-9_-]{40,255})\\b"
|
|
78
79
|
]
|
|
79
80
|
},
|
|
80
81
|
"actions": {
|
|
@@ -17,7 +17,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
|
|
|
17
17
|
Its policy:
|
|
18
18
|
|
|
19
19
|
- Never stage or commit local AI session/state files. Examples include
|
|
20
|
-
`.claude.json`, `.claude
|
|
20
|
+
`.claude.json`, `.claude/session*.json`,
|
|
21
21
|
`.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
|
|
22
22
|
`.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
|
|
23
23
|
`.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
|
|
@@ -27,8 +27,10 @@ Its policy:
|
|
|
27
27
|
exhaustive; the packaged `rules/paths.json` is the detection source of truth.
|
|
28
28
|
If one is staged, unstage it and keep it out of Git instead.
|
|
29
29
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
30
|
-
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
31
|
-
service-account keys
|
|
30
|
+
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
31
|
+
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
32
|
+
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
33
|
+
SendGrid). Public certificates are allowed.
|
|
32
34
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
33
35
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
34
36
|
noreply emails. A commit message reads as if a human wrote it.
|
package/src/atomic-write.mjs
CHANGED
|
@@ -111,6 +111,7 @@ function syncDirectory(directory, operations) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
114
|
+
const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
|
|
114
115
|
|
|
115
116
|
function waitForLock(milliseconds) {
|
|
116
117
|
if (milliseconds > 0) Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, milliseconds);
|
|
@@ -136,6 +137,12 @@ function processIdentity(pid) {
|
|
|
136
137
|
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
137
138
|
encoding: 'utf8',
|
|
138
139
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
140
|
+
// This is the only spawn on the lock path, and it runs on macOS
|
|
141
|
+
// and BSD alone: Linux reads /proc and Windows returns early.
|
|
142
|
+
// The retry loop below budgets milliseconds per attempt, so a
|
|
143
|
+
// probe that stalls must lose rather than stretch the loop. A
|
|
144
|
+
// timeout throws into the catch below, which fails safe.
|
|
145
|
+
timeout: IDENTITY_PROBE_TIMEOUT_MS,
|
|
139
146
|
}).trim();
|
|
140
147
|
return started ? `ps:${started}` : null;
|
|
141
148
|
}
|
|
@@ -283,7 +290,17 @@ export function withLock(lockPath, fn, options = {}) {
|
|
|
283
290
|
if (attempt + 1 < retries) waitForLock(retryDelayMs);
|
|
284
291
|
}
|
|
285
292
|
if (!held) {
|
|
286
|
-
|
|
293
|
+
// Name the queue, not just the lock. This scheme writes no file at lockPath;
|
|
294
|
+
// only the legacy holder checked above would, so a reader of this error
|
|
295
|
+
// usually goes looking for a path that is not there. What blocks is a
|
|
296
|
+
// candidate in the queue directory, retained because its owner cannot be
|
|
297
|
+
// disproved, and a holder killed outright never reaches the finally below
|
|
298
|
+
// that would have removed its own.
|
|
299
|
+
throw new Error(
|
|
300
|
+
`cannot acquire state lock "${lockPath}" after ${retries} attempts. Contenders `
|
|
301
|
+
+ `queue in "${queueDir}"; if no other aimhooman command is running, a candidate `
|
|
302
|
+
+ 'there outlived the process that published it, and removing that directory clears it',
|
|
303
|
+
);
|
|
287
304
|
}
|
|
288
305
|
return fn();
|
|
289
306
|
} catch (error) {
|
package/src/git-environment.mjs
CHANGED
|
@@ -3,3 +3,13 @@
|
|
|
3
3
|
export function gitEnvironment(environment = process.env) {
|
|
4
4
|
return { ...environment, GIT_NO_REPLACE_OBJECTS: '1' };
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
// execFileSync has no default timeout, so a git child that never exits blocks
|
|
8
|
+
// the caller forever. One did: it held a CI runner until the platform's 6-hour
|
|
9
|
+
// ceiling killed the job, with no error to read afterwards.
|
|
10
|
+
//
|
|
11
|
+
// Real calls here finish in milliseconds. This bound exists to end a stuck one,
|
|
12
|
+
// not to pace a slow one, so it sits far above any legitimate call: a huge
|
|
13
|
+
// repository on a slow disk stays well inside it, and a hang now raises an
|
|
14
|
+
// error naming the command instead of going quiet.
|
|
15
|
+
export const GIT_TIMEOUT_MS = 120_000;
|
package/src/githooks.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
3
4
|
import {
|
|
4
5
|
accessSync,
|
|
5
6
|
chmodSync,
|
|
@@ -38,13 +39,19 @@ function hooksDir(repo) {
|
|
|
38
39
|
|
|
39
40
|
const scope = configScope(repo, 'core.hooksPath');
|
|
40
41
|
const localScope = ['local', 'worktree'].includes(scope);
|
|
41
|
-
const
|
|
42
|
+
const inside = localScope && repositoryOwnsPath(repo, dir);
|
|
43
|
+
// Inside the worktree is not the same as ours. A hooks directory Git tracks
|
|
44
|
+
// (husky, the vanilla .githooks pattern) is repository content: a dispatcher
|
|
45
|
+
// written there stages this machine's absolute CLI, Node, and PATH for
|
|
46
|
+
// everyone who clones.
|
|
47
|
+
const tracked = inside && trackedPath(repo, dir);
|
|
48
|
+
const repositoryOwned = inside && !tracked;
|
|
42
49
|
const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
|
|
43
50
|
if (shared) {
|
|
44
51
|
const where = scope ? `${scope} scope` : 'a non-local scope';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
let reason = where;
|
|
53
|
+
if (tracked) reason = `${where}, tracked by this repository`;
|
|
54
|
+
else if (localScope && !inside) reason = `${where}, outside this repository`;
|
|
48
55
|
return {
|
|
49
56
|
dir,
|
|
50
57
|
shared: true,
|
|
@@ -60,16 +67,43 @@ function hooksDir(repo) {
|
|
|
60
67
|
};
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
function pathContains(root, candidate) {
|
|
71
|
+
const rel = relative(root, candidate);
|
|
72
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function repositoryOwnsPath(repo, path) {
|
|
64
76
|
const candidate = canonicalPath(path);
|
|
65
77
|
return [repo.root, repo.commonDir, repo.gitDir]
|
|
66
78
|
.filter(Boolean)
|
|
67
79
|
.map(canonicalPath)
|
|
68
|
-
.some((root) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
.some((root) => pathContains(root, candidate));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// insideGitDir is the part of repositoryOwnsPath that cannot be shared. A .git
|
|
84
|
+
// belongs to exactly one repository, while a directory in the worktree can be
|
|
85
|
+
// the core.hooksPath of a second one.
|
|
86
|
+
function insideGitDir(repo, path) {
|
|
87
|
+
const candidate = canonicalPath(path);
|
|
88
|
+
return [repo.commonDir, repo.gitDir]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(canonicalPath)
|
|
91
|
+
.some((root) => pathContains(root, candidate));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// trackedPath reports whether Git tracks anything under path. A Git that cannot
|
|
95
|
+
// answer counts as tracked: refusing to install costs a warning, while guessing
|
|
96
|
+
// wrong commits this machine's absolute paths into a shared repository.
|
|
97
|
+
function trackedPath(repo, path) {
|
|
98
|
+
try {
|
|
99
|
+
return execFileSync(
|
|
100
|
+
'git',
|
|
101
|
+
['--literal-pathspecs', 'ls-files', '-z', '--', String(path)],
|
|
102
|
+
{ cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
|
|
103
|
+
).length > 0;
|
|
104
|
+
} catch {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
function canonicalPath(path) {
|
|
@@ -95,13 +129,14 @@ function effectiveHooksDir(repo) {
|
|
|
95
129
|
return execFileSync(
|
|
96
130
|
'git',
|
|
97
131
|
['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
|
|
98
|
-
{ cwd: repo.root, encoding: 'utf8' }
|
|
132
|
+
{ cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
|
|
99
133
|
).trim();
|
|
100
134
|
} catch {
|
|
101
135
|
try {
|
|
102
136
|
const path = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
103
137
|
cwd: repo.root,
|
|
104
138
|
encoding: 'utf8',
|
|
139
|
+
timeout: GIT_TIMEOUT_MS,
|
|
105
140
|
}).trim();
|
|
106
141
|
return isAbsolute(path) ? path : resolve(repo.root, path);
|
|
107
142
|
} catch {
|
|
@@ -115,6 +150,7 @@ function configScope(repo, key) {
|
|
|
115
150
|
const line = execFileSync('git', ['config', '--show-scope', '--get', key], {
|
|
116
151
|
cwd: repo.root,
|
|
117
152
|
encoding: 'utf8',
|
|
153
|
+
timeout: GIT_TIMEOUT_MS,
|
|
118
154
|
}).trim();
|
|
119
155
|
return line.split(/\s/, 1)[0];
|
|
120
156
|
} catch {
|
|
@@ -125,6 +161,7 @@ function configScope(repo, key) {
|
|
|
125
161
|
execFileSync('git', ['config', '--local', '--get', key], {
|
|
126
162
|
cwd: repo.root,
|
|
127
163
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
164
|
+
timeout: GIT_TIMEOUT_MS,
|
|
128
165
|
});
|
|
129
166
|
return 'local';
|
|
130
167
|
} catch {
|
|
@@ -307,7 +344,7 @@ function installHooksLocked(repo, cliPath, options, dir, warnings) {
|
|
|
307
344
|
continue;
|
|
308
345
|
}
|
|
309
346
|
if (current && ownedHook(readFileSync(dest, 'utf8'), name)
|
|
310
|
-
&& !
|
|
347
|
+
&& !ownedByRepo(repo, dir, readFileSync(dest, 'utf8'), name, chainedPath)) {
|
|
311
348
|
warnings.push(`${name} is managed for another repository; refusing to overwrite it`);
|
|
312
349
|
unsafe = true;
|
|
313
350
|
}
|
|
@@ -381,13 +418,18 @@ function uninstallHooksLocked(repo, dir, warnings) {
|
|
|
381
418
|
const chained = join(chainedDir, name);
|
|
382
419
|
const content = readFileSync(dest, 'utf8');
|
|
383
420
|
if (!ownedHook(content, name)) continue;
|
|
384
|
-
if (!
|
|
385
|
-
|
|
421
|
+
if (!ownedByRepo(repo, dir, content, name, chained)) {
|
|
422
|
+
failures.push(`${name}: managed for another repository; left in place at ${dest}`);
|
|
386
423
|
continue;
|
|
387
424
|
}
|
|
388
425
|
const predecessor = entry(chained);
|
|
389
426
|
if (predecessor?.isSymbolicLink()) {
|
|
390
|
-
|
|
427
|
+
// Never read or copy through the symlink, but do let go of the
|
|
428
|
+
// dispatcher: holding the repository hostage over a backup we
|
|
429
|
+
// refuse to touch helps nobody.
|
|
430
|
+
warnings.push(`${name} chained backup is a symlink; ${chained} left in place and your original hook was not restored`);
|
|
431
|
+
unlinkSync(dest);
|
|
432
|
+
removed.push(name);
|
|
391
433
|
continue;
|
|
392
434
|
}
|
|
393
435
|
if (predecessor) {
|
|
@@ -407,6 +449,34 @@ function uninstallHooksLocked(repo, dir, warnings) {
|
|
|
407
449
|
return { removed: removed.sort(), restored: restored.sort(), warnings, failures };
|
|
408
450
|
}
|
|
409
451
|
|
|
452
|
+
// remainingDispatchers lists aimhooman dispatchers a local uninstall was meant
|
|
453
|
+
// to remove and did not. uninstall checks the directory rather than trusting its
|
|
454
|
+
// own report: a refusal that only produced a warning would otherwise be printed
|
|
455
|
+
// under a success headline, and "uninstalled" while four dispatchers still block
|
|
456
|
+
// every commit is the one lie a guard must never tell.
|
|
457
|
+
//
|
|
458
|
+
// A shared hooks directory is skipped because uninstallHooks does not touch one
|
|
459
|
+
// either — the global directory has `uninstall --global`, and a foreign or
|
|
460
|
+
// tracked one is reported by its own warning. This asks hooksDir rather than
|
|
461
|
+
// comparing paths: two spellings of one directory differ on Windows, and
|
|
462
|
+
// deciding ownership by string is the bug this change exists to remove.
|
|
463
|
+
export function remainingDispatchers(repo) {
|
|
464
|
+
const { dir, shared } = hooksDir(repo);
|
|
465
|
+
if (shared) return [];
|
|
466
|
+
return Object.keys(MANAGED).sort().flatMap((name) => {
|
|
467
|
+
const path = join(dir, name);
|
|
468
|
+
try {
|
|
469
|
+
const stat = entry(path);
|
|
470
|
+
if (!stat || stat.isSymbolicLink()) return [];
|
|
471
|
+
return ownedHook(readFileSync(path, 'utf8'), name) ? [path] : [];
|
|
472
|
+
} catch {
|
|
473
|
+
// Unreadable: cannot prove it is gone, so report it rather than
|
|
474
|
+
// claim a clean removal.
|
|
475
|
+
return [path];
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
410
480
|
// hookDiagnostics reports both dispatcher integrity and whether its configured
|
|
411
481
|
// command can be reached from the current environment.
|
|
412
482
|
export function hookDiagnostics(repo) {
|
|
@@ -414,8 +484,12 @@ export function hookDiagnostics(repo) {
|
|
|
414
484
|
// Global hooks (installGlobalHooks) keep chained backups in the hooks dir
|
|
415
485
|
// itself; local hooks keep them in repo state via chainDir. Diagnose the
|
|
416
486
|
// path the dispatcher actually embeds, not the repo-relative default.
|
|
487
|
+
// canonicalPath, not resolve: a HOME behind a symlink (Fedora Silverblue
|
|
488
|
+
// ships /home -> /var/home; NFS and autofs homes are everywhere) makes the
|
|
489
|
+
// two spellings differ, and every global dispatcher would then be diagnosed
|
|
490
|
+
// as belonging to another repository.
|
|
417
491
|
const globalDir = globalHooksDir();
|
|
418
|
-
const chained =
|
|
492
|
+
const chained = canonicalPath(dir) === canonicalPath(globalDir)
|
|
419
493
|
? join(globalDir, 'chained')
|
|
420
494
|
: chainDir(repo);
|
|
421
495
|
return Object.keys(MANAGED).sort().map((name) => {
|
|
@@ -458,7 +532,7 @@ export function hookDiagnostics(repo) {
|
|
|
458
532
|
reason: inspected.reason,
|
|
459
533
|
};
|
|
460
534
|
}
|
|
461
|
-
if (!
|
|
535
|
+
if (!ownedByRepo(repo, dir, content, name, base.chainedPath)) {
|
|
462
536
|
return {
|
|
463
537
|
...base,
|
|
464
538
|
managed: false,
|
|
@@ -580,8 +654,20 @@ AIMHOOMAN_CLI=${shq(resolvedCliPath)}
|
|
|
580
654
|
AIMHOOMAN_NODE=${shq(nodePath)}
|
|
581
655
|
AIMHOOMAN_PATH=${shq(shellPathValue)}
|
|
582
656
|
${captureTree}${captureTransaction}run_aimhooman() {
|
|
583
|
-
if [ ! -f "$AIMHOOMAN_CLI" ]
|
|
584
|
-
echo "aimhooman: guard unavailable (aimhooman CLI
|
|
657
|
+
if [ ! -f "$AIMHOOMAN_CLI" ]; then
|
|
658
|
+
echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
|
|
659
|
+
return 0
|
|
660
|
+
fi
|
|
661
|
+
if [ ! -f "$AIMHOOMAN_NODE" ]; then
|
|
662
|
+
# Both remedies below are Node programs, so stopping is only fair when a Node
|
|
663
|
+
# exists to run them with. Without one the CLI file is inert: 'init' and
|
|
664
|
+
# 'uninstall' cannot run, and refusing would leave the repository unusable
|
|
665
|
+
# with no way to remove these hooks except deleting them by hand.
|
|
666
|
+
if command -v node >/dev/null 2>&1; then
|
|
667
|
+
echo "aimhooman: the pinned Node interpreter is missing (\$AIMHOOMAN_NODE); the operation was stopped. Run 'aimhooman init' to re-pin it, or 'aimhooman uninstall' to remove the guard." >&2
|
|
668
|
+
return 20
|
|
669
|
+
fi
|
|
670
|
+
echo "aimhooman: guard unavailable (no Node interpreter found); allowing this operation without protection. Install Node and run 'aimhooman init' to restore the guard." >&2
|
|
585
671
|
return 0
|
|
586
672
|
fi
|
|
587
673
|
(
|
|
@@ -698,8 +784,16 @@ function ownedHook(content, name) {
|
|
|
698
784
|
return inspected.valid || legacyHook(content, name);
|
|
699
785
|
}
|
|
700
786
|
|
|
701
|
-
|
|
787
|
+
// ownedByRepo decides whether this repository may rewrite or remove a dispatcher.
|
|
788
|
+
// Inside our own .git the fingerprint settles it: we wrote the file, and no
|
|
789
|
+
// second repository can own anything there. The baked chained path only earns a
|
|
790
|
+
// vote where the hooks directory can genuinely be shared — two repositories may
|
|
791
|
+
// point core.hooksPath at one directory, and then it is the only way to tell the
|
|
792
|
+
// installs apart. Comparing paths everywhere means a renamed repository stops
|
|
793
|
+
// recognising the guard it installed itself, with no way to re-pin or remove it.
|
|
794
|
+
function ownedByRepo(repo, dir, content, name, chainedPath) {
|
|
702
795
|
if (!ownedHook(content, name)) return false;
|
|
796
|
+
if (insideGitDir(repo, dir)) return true;
|
|
703
797
|
return assignmentValue(content, 'CHAINED') === String(chainedPath);
|
|
704
798
|
}
|
|
705
799
|
|