@rmyndharis/aimhooman 0.2.0 → 0.3.1
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 +9 -6
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.clinerules/aimhooman.md +9 -6
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor/rules/aimhooman.mdc +10 -7
- package/.github/copilot-instructions.md +9 -6
- package/.kiro/steering/aimhooman.md +9 -6
- package/.windsurf/rules/aimhooman.md +9 -6
- package/AGENTS.md +9 -6
- package/CHANGELOG.md +111 -0
- package/CONTRIBUTING.md +2 -3
- package/GEMINI.md +9 -6
- package/README.md +57 -376
- package/SECURITY.md +6 -4
- package/bin/aimhooman.mjs +134 -143
- package/docs/ai-artifacts.gitignore +63 -0
- package/docs/catalog.md +41 -0
- package/docs/cli-reference.md +114 -0
- package/docs/design/frictionless-enforcement.md +27 -17
- package/docs/faq.md +60 -0
- package/docs/integrations.md +108 -0
- package/docs/policy.md +82 -0
- package/docs/secrets.md +83 -0
- package/hooks/hooks.json +1 -1
- package/package.json +10 -2
- package/rules/paths.json +0 -114
- package/schemas/overrides.schema.json +4 -1
- package/skills/aimhooman/SKILL.md +11 -8
- package/src/args.mjs +2 -7
- package/src/exclude.mjs +2 -2
- package/src/gitx.mjs +1 -13
- package/src/hook.mjs +44 -12
- package/src/report.mjs +17 -41
- package/src/scan-session.mjs +15 -22
- package/src/scan-target.mjs +10 -10
- package/src/scan.mjs +9 -24
- package/src/state.mjs +104 -15
- package/rules/secrets.json +0 -96
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { chmodSync, existsSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
@@ -26,7 +26,6 @@ import { applyExclude, inspectExclude, managedPatterns, patternsForRules, remove
|
|
|
26
26
|
import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
27
27
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
28
28
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
29
|
-
import { DEFAULT_SCAN_LIMITS } from '../src/scan-session.mjs';
|
|
30
29
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
31
30
|
import { atomicWrite, withLock } from '../src/atomic-write.mjs';
|
|
32
31
|
import { commitParents, resolveCommit } from '../src/history-scan.mjs';
|
|
@@ -113,7 +112,6 @@ function configuredEngine(profile, repo) {
|
|
|
113
112
|
entry.scope === undefined
|
|
114
113
|
|| entry.scope === 'path'
|
|
115
114
|
|| entry.scope === 'rule'
|
|
116
|
-
|| entry.scope === 'secret-path'
|
|
117
115
|
));
|
|
118
116
|
engine.setOverrides(ordinary(ov.allow), ordinary(ov.deny));
|
|
119
117
|
}
|
|
@@ -215,7 +213,7 @@ function emitDiagnostics(diagnostics = []) {
|
|
|
215
213
|
}
|
|
216
214
|
}
|
|
217
215
|
|
|
218
|
-
function incompleteMessage(scan) {
|
|
216
|
+
function incompleteMessage(scan, { blocking = true } = {}) {
|
|
219
217
|
const reasons = scan.stats?.skipped || {};
|
|
220
218
|
// Each count is how many items were skipped, not the limit that fired:
|
|
221
219
|
// "(size-limit=1)" read as a one-byte budget. Name the noun so the number
|
|
@@ -228,14 +226,17 @@ function incompleteMessage(scan) {
|
|
|
228
226
|
// the error, so point at that instead of misdirecting to the limits. When a
|
|
229
227
|
// byte budget is what stopped the scan, name the budget: the caller whose own
|
|
230
228
|
// tree outgrew it needs to raise one, and "reduce the limits" sends them the
|
|
231
|
-
// wrong way down a road they cannot leave.
|
|
229
|
+
// wrong way down a road they cannot leave. A blocking stop says "and retry";
|
|
230
|
+
// a warning names what to change so the next run covers what this one
|
|
231
|
+
// skipped — this run is already through.
|
|
232
|
+
const tail = blocking ? 'and retry' : 'so the next scan covers it';
|
|
232
233
|
const budgeted = reasons['size-limit'] || reasons['total-byte-limit'];
|
|
233
234
|
const hint = reasons['local-pack-error']
|
|
234
|
-
?
|
|
235
|
+
? `fix the reported rule pack ${tail}`
|
|
235
236
|
: budgeted
|
|
236
|
-
?
|
|
237
|
-
:
|
|
238
|
-
let message = `aimhooman: scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
|
|
237
|
+
? `reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, ${tail}`
|
|
238
|
+
: `reduce the target or limits ${tail}`;
|
|
239
|
+
let message = `aimhooman: ${blocking ? '' : 'warning: '}scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
|
|
239
240
|
const skippedPaths = scan.stats?.skippedPaths || {};
|
|
240
241
|
const pathLines = [];
|
|
241
242
|
for (const [reason, entries] of Object.entries(skippedPaths)) {
|
|
@@ -442,8 +443,12 @@ function cmdPrecommit(args) {
|
|
|
442
443
|
if (!blocks.length) {
|
|
443
444
|
if (reviews.length) process.stderr.write(human(reviews, tone()));
|
|
444
445
|
if (!scan.complete) {
|
|
445
|
-
|
|
446
|
-
|
|
446
|
+
// Strict stops on an unchecked remainder. Clean/compliance go ahead
|
|
447
|
+
// with a warning: the reference-transaction guard rescans the
|
|
448
|
+
// introduced commit before any ref moves. The clean marker stays
|
|
449
|
+
// unwritten so commit-msg does not skip its own tree scan.
|
|
450
|
+
process.stderr.write(incompleteMessage(scan, { blocking: profile === 'strict' }));
|
|
451
|
+
return profile === 'strict' ? 31 : 0;
|
|
447
452
|
}
|
|
448
453
|
// W5 marker dedup: record that this staged tree scanned clean so the
|
|
449
454
|
// upcoming commit-msg hook can skip its duplicate tree scan. The tree
|
|
@@ -515,22 +520,25 @@ function cmdPrecommit(args) {
|
|
|
515
520
|
);
|
|
516
521
|
return 10;
|
|
517
522
|
}
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
523
|
+
// The summary above cannot name a cause without guessing which rule fired,
|
|
524
|
+
// and it used to be the only thing printed for a block. Let the findings
|
|
525
|
+
// speak instead: human() already carries the rule id, the path, the reason
|
|
526
|
+
// and the remediation, and redacts secret-category text from local packs.
|
|
527
|
+
// It labels each one BLOCK, which is the decision that unstaged the path,
|
|
528
|
+
// not a stopped commit; the summary above says what actually happened to
|
|
529
|
+
// them.
|
|
525
530
|
process.stderr.write(human([...blocks, ...reviews], tone()));
|
|
526
|
-
|
|
531
|
+
// Only frictionless profiles reach the repair path (strict returned 10
|
|
532
|
+
// above), so an incomplete post-repair scan is a warning, not a stop: the
|
|
533
|
+
// reference-transaction guard rescans the introduced commit.
|
|
534
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: false }));
|
|
527
535
|
// Git refuses a commit with nothing staged; repair runs after git has already
|
|
528
536
|
// decided otherwise, so carrying on here mints the empty commit git would
|
|
529
537
|
// not. The request was to commit the artifact, not to stamp the history with
|
|
530
538
|
// its message and no content.
|
|
531
539
|
if (emptied) return 10;
|
|
532
540
|
if (scan.complete) noticeIgnoredArtifacts(repo);
|
|
533
|
-
return
|
|
541
|
+
return 0;
|
|
534
542
|
}
|
|
535
543
|
|
|
536
544
|
function cmdCommitmsg(args) {
|
|
@@ -603,9 +611,12 @@ function cmdCommitmsg(args) {
|
|
|
603
611
|
const treeCode = exitCode(treeScan.findings, treeScan.profile, treeScan.complete);
|
|
604
612
|
if (treeCode !== 0) {
|
|
605
613
|
if (treeScan.findings.length) process.stderr.write(human(treeScan.findings, tone()));
|
|
606
|
-
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan));
|
|
614
|
+
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan, { blocking: treeScan.profile === 'strict' }));
|
|
607
615
|
return treeCode;
|
|
608
616
|
}
|
|
617
|
+
// exitCode passes an incomplete tree scan on frictionless profiles; the
|
|
618
|
+
// skip still names itself so the commit does not sail through silently.
|
|
619
|
+
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan, { blocking: false }));
|
|
609
620
|
}
|
|
610
621
|
if (dispatchHooksChanged(repo, profile)) return 20;
|
|
611
622
|
// Strict must fail closed on an incomplete scan: a block still wins (10),
|
|
@@ -615,7 +626,7 @@ function cmdCommitmsg(args) {
|
|
|
615
626
|
process.stderr.write(incompleteMessage(scan));
|
|
616
627
|
return findings.some((finding) => finding.decision === 'block') ? 10 : 31;
|
|
617
628
|
}
|
|
618
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
629
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: false }));
|
|
619
630
|
if (!findings.length) return 0;
|
|
620
631
|
const blocks = findings.filter((finding) => finding.decision === 'block');
|
|
621
632
|
if (profile === 'strict') {
|
|
@@ -792,7 +803,11 @@ function cmdRefcheck(args) {
|
|
|
792
803
|
return expectedErrorCode(error);
|
|
793
804
|
}
|
|
794
805
|
emitDiagnostics(scan.diagnostics);
|
|
795
|
-
|
|
806
|
+
// The reference transaction is the final boundary --no-verify cannot
|
|
807
|
+
// skip, so an incomplete scan vetoes the update on every profile, even
|
|
808
|
+
// though earlier guards let frictionless profiles through with a
|
|
809
|
+
// warning.
|
|
810
|
+
const code = exitCode(scan.findings, scan.profile, scan.complete, { failClosedIncomplete: true });
|
|
796
811
|
if (code !== 0) {
|
|
797
812
|
process.stderr.write(human(scan.findings, tone()));
|
|
798
813
|
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
@@ -889,7 +904,7 @@ function cmdCheck(args) {
|
|
|
889
904
|
}
|
|
890
905
|
else {
|
|
891
906
|
process.stderr.write(human(findings, tone()));
|
|
892
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
907
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: scan.profile === 'strict' }));
|
|
893
908
|
}
|
|
894
909
|
return exitCode(findings, scan.profile, scan.complete);
|
|
895
910
|
}
|
|
@@ -900,9 +915,9 @@ function cmdInit(args) {
|
|
|
900
915
|
profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
|
|
901
916
|
global: { names: ['--global'], type: 'boolean' },
|
|
902
917
|
yes: { names: ['--yes'], type: 'boolean' },
|
|
903
|
-
|
|
918
|
+
gitignore: { names: ['--gitignore'], type: 'boolean' },
|
|
904
919
|
},
|
|
905
|
-
conflicts: [['profile', 'global'], ['
|
|
920
|
+
conflicts: [['profile', 'global'], ['gitignore', 'global']],
|
|
906
921
|
maxPositionals: 0,
|
|
907
922
|
});
|
|
908
923
|
if (rejectUnsupportedGit()) return 20;
|
|
@@ -1016,10 +1031,26 @@ function cmdInit(args) {
|
|
|
1016
1031
|
const hookFiles = hookState.some((hook) => hook.shared)
|
|
1017
1032
|
? []
|
|
1018
1033
|
: [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
|
|
1034
|
+
// --gitignore opts the clone into the committed variant of the managed
|
|
1035
|
+
// block. A plain re-init keeps a previously recorded choice instead of
|
|
1036
|
+
// silently dropping a block a teammate may already have committed. The
|
|
1037
|
+
// record lives in the local config: whether this clone created its
|
|
1038
|
+
// .gitignore is per-clone state, never team policy.
|
|
1039
|
+
let previousGitignore;
|
|
1040
|
+
try {
|
|
1041
|
+
previousGitignore = loadConfig(repo.stateDir).gitignore;
|
|
1042
|
+
} catch {
|
|
1043
|
+
// An unreadable local config is rewritten by saveConfig below, the
|
|
1044
|
+
// same recovery a plain init has always had.
|
|
1045
|
+
previousGitignore = undefined;
|
|
1046
|
+
}
|
|
1047
|
+
const gitignoreFile = join(repo.root, '.gitignore');
|
|
1048
|
+
const gitignoreWanted = Boolean(options.gitignore) || Boolean(previousGitignore?.enabled);
|
|
1019
1049
|
let snapshots = [];
|
|
1020
1050
|
let rep;
|
|
1021
1051
|
try {
|
|
1022
1052
|
snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
|
|
1053
|
+
.concat(gitignoreWanted ? [gitignoreFile] : [])
|
|
1023
1054
|
.map(snapshotFile);
|
|
1024
1055
|
rep = installHooks(repo, CLI_PATH);
|
|
1025
1056
|
const activeHooks = installedHooks(repo);
|
|
@@ -1040,11 +1071,21 @@ function cmdInit(args) {
|
|
|
1040
1071
|
: '';
|
|
1041
1072
|
throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
|
|
1042
1073
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1074
|
+
const patterns = patternsForRules(eng.rules);
|
|
1075
|
+
// created is sticky: once aimhooman introduced the file it stays
|
|
1076
|
+
// recorded as ours, so uninstall can remove an emptied husk it made.
|
|
1077
|
+
const gitignore = gitignoreWanted
|
|
1078
|
+
? { enabled: true, created: Boolean(previousGitignore?.created) || !existsSync(gitignoreFile) }
|
|
1079
|
+
: undefined;
|
|
1080
|
+
saveConfig(repo.stateDir, gitignore ? { profile, gitignore } : { profile });
|
|
1081
|
+
// The opt-in worktree file goes first, so every later failure —
|
|
1082
|
+
// including the core exclude write — rolls it back with the rest.
|
|
1083
|
+
if (gitignore) applyExclude(gitignoreFile, patterns);
|
|
1084
|
+
applyExclude(repo.excludeFile, patterns);
|
|
1045
1085
|
const saved = loadConfig(repo.stateDir);
|
|
1046
|
-
const excludes = inspectExclude(repo.excludeFile,
|
|
1047
|
-
if (saved.profile !== profile || !excludes.current
|
|
1086
|
+
const excludes = inspectExclude(repo.excludeFile, patterns);
|
|
1087
|
+
if (saved.profile !== profile || !excludes.current
|
|
1088
|
+
|| (gitignore && !inspectExclude(gitignoreFile, patterns).current)) {
|
|
1048
1089
|
throw new Error('post-install state verification failed');
|
|
1049
1090
|
}
|
|
1050
1091
|
} catch (error) {
|
|
@@ -1076,78 +1117,19 @@ function cmdInit(args) {
|
|
|
1076
1117
|
console.log(` state: ${repo.stateDir}`);
|
|
1077
1118
|
console.log(` excludes: ${repo.excludeFile}`);
|
|
1078
1119
|
console.log(` note: known AI artifacts are now ignored locally; see them with 'git status --ignored'`);
|
|
1120
|
+
if (options.gitignore) {
|
|
1121
|
+
console.log('aimhooman: wrote AI-artifact ignores to .gitignore — commit it to share them with every clone');
|
|
1122
|
+
console.log(' note: gitignore matching is case-sensitive; the commit hooks still catch case variants');
|
|
1123
|
+
}
|
|
1079
1124
|
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
1080
1125
|
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
1081
1126
|
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
1082
1127
|
console.log(' undo: aimhooman uninstall');
|
|
1083
1128
|
return 0;
|
|
1084
1129
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1085
|
-
if (code === 0 && options.grandfatherSecrets) grandfatherTrackedSecrets(repo);
|
|
1086
1130
|
return code;
|
|
1087
1131
|
}
|
|
1088
1132
|
|
|
1089
|
-
// --grandfather-secrets: a repository that already tracks secret-looking
|
|
1090
|
-
// fixtures (test certs, sample keys) would see every commit touching one
|
|
1091
|
-
// blocked, and allowing each path by hand does not scale. After a successful
|
|
1092
|
-
// init, scan the tracked tree once and write a --scope secret-path allow for
|
|
1093
|
-
// every path with a secret finding. The posture for NEW files is unchanged —
|
|
1094
|
-
// only paths found in this scan get an allow, so a secret added after init is
|
|
1095
|
-
// still blocked. A failed or incomplete scan never fails the init itself: the
|
|
1096
|
-
// guard is already active by then, so the gap is reported as a warning.
|
|
1097
|
-
function grandfatherTrackedSecrets(repo) {
|
|
1098
|
-
let scan;
|
|
1099
|
-
try {
|
|
1100
|
-
// Commit-time budgets exist for latency, but a grandfather scan that
|
|
1101
|
-
// stops at the default 64 MiB total or 1,000 findings silently misses
|
|
1102
|
-
// fixtures in exactly the large, fixture-heavy repositories it exists
|
|
1103
|
-
// for (OpenSSL tracks ~200 MiB and its 279 key files produce more
|
|
1104
|
-
// than a thousand PEM findings). This is a one-shot, operator-invoked
|
|
1105
|
-
// scan of the operator's own repository, so the defaults here are the
|
|
1106
|
-
// same cap an env raise could reach plus a generous finding budget;
|
|
1107
|
-
// the per-file budget and an explicit env override still apply.
|
|
1108
|
-
scan = scanGitTarget(repo, {
|
|
1109
|
-
kind: 'tracked',
|
|
1110
|
-
limits: { maxTotalBytes: MAX_SCAN_LIMIT_BYTES, maxFindings: 100_000, ...scanLimits() },
|
|
1111
|
-
});
|
|
1112
|
-
} catch (error) {
|
|
1113
|
-
console.error(`aimhooman: warning: grandfather scan failed (${error.message}); no pre-existing secret paths were allowed`);
|
|
1114
|
-
return;
|
|
1115
|
-
}
|
|
1116
|
-
if (!scan.complete) {
|
|
1117
|
-
console.error('aimhooman: warning: tracked scan was incomplete, so the grandfathered set may be partial; allow the remaining paths with --scope secret-path');
|
|
1118
|
-
}
|
|
1119
|
-
const paths = [...new Set(scan.findings
|
|
1120
|
-
.filter((finding) => finding.category === 'secret' && finding.path)
|
|
1121
|
-
.map((finding) => finding.path))].sort();
|
|
1122
|
-
if (!paths.length) {
|
|
1123
|
-
console.log('aimhooman: no tracked secret findings to grandfather');
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
|
|
1127
|
-
const added = withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
1128
|
-
const ov = loadOverrides(repo.stateDir);
|
|
1129
|
-
let count = 0;
|
|
1130
|
-
for (const path of paths) {
|
|
1131
|
-
const entry = {
|
|
1132
|
-
target: path,
|
|
1133
|
-
scope: 'secret-path',
|
|
1134
|
-
reason: 'pre-existing tracked fixture (grandfathered at init)',
|
|
1135
|
-
actor: gitConfig(repo.root, 'user.email'),
|
|
1136
|
-
at: new Date().toISOString(),
|
|
1137
|
-
};
|
|
1138
|
-
const sameAllow = (candidate) => (
|
|
1139
|
-
candidate.target === path && effectiveOverrideScope(candidate, engine) === 'secret-path'
|
|
1140
|
-
);
|
|
1141
|
-
if (ov.allow.some(sameAllow)) continue;
|
|
1142
|
-
ov.allow = upsert(ov.allow, entry, sameAllow);
|
|
1143
|
-
count += 1;
|
|
1144
|
-
}
|
|
1145
|
-
saveOverrides(repo.stateDir, ov);
|
|
1146
|
-
return count;
|
|
1147
|
-
});
|
|
1148
|
-
console.log(`aimhooman: grandfathered ${added} tracked secret path(s) with --scope secret-path allows; new files with secrets are still blocked`);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
1133
|
function cmdStatus(args) {
|
|
1152
1134
|
parseNoArguments(args);
|
|
1153
1135
|
const repo = tryRepo();
|
|
@@ -1189,6 +1171,24 @@ function cmdStatus(args) {
|
|
|
1189
1171
|
excludeError = e.message;
|
|
1190
1172
|
excludes = { current: false, installed: false };
|
|
1191
1173
|
}
|
|
1174
|
+
// The committed variant is reported only when the local config records the
|
|
1175
|
+
// opt-in; anything else in a worktree .gitignore is the user's own file.
|
|
1176
|
+
let gitignoreRecord;
|
|
1177
|
+
try {
|
|
1178
|
+
gitignoreRecord = loadConfig(repo.stateDir).gitignore;
|
|
1179
|
+
} catch {
|
|
1180
|
+
gitignoreRecord = undefined;
|
|
1181
|
+
}
|
|
1182
|
+
let gitignoreExcludes;
|
|
1183
|
+
let gitignoreError = null;
|
|
1184
|
+
if (gitignoreRecord?.enabled) {
|
|
1185
|
+
try {
|
|
1186
|
+
gitignoreExcludes = inspectExclude(join(repo.root, '.gitignore'), patternsForRules(engine.rules));
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
gitignoreError = e.message;
|
|
1189
|
+
gitignoreExcludes = { current: false, installed: false };
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
1192
|
// The pre-commit and reference-transaction guards resolve the policy from
|
|
1193
1193
|
// the index, so the staged profile is what is actually enforced. Report it
|
|
1194
1194
|
// first; the worktree file is shown alongside so an edit that has not been
|
|
@@ -1215,6 +1215,9 @@ function cmdStatus(args) {
|
|
|
1215
1215
|
console.log(`rules: ${builtin} built-in${local ? ` + ${local} local` : ''}`);
|
|
1216
1216
|
console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
|
|
1217
1217
|
console.log(`excludes: ${excludeError ? `unknown (malformed markers: ${excludeError}; run: aimhooman init)` : excludes.current ? 'current' : excludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init)'}`);
|
|
1218
|
+
if (gitignoreRecord?.enabled) {
|
|
1219
|
+
console.log(`gitignore: ${gitignoreError ? `unknown (malformed markers: ${gitignoreError}; run: aimhooman init)` : gitignoreExcludes.current ? 'current' : gitignoreExcludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init --gitignore)'}`);
|
|
1220
|
+
}
|
|
1218
1221
|
for (const error of errors) console.log(`warning: ${error.message}`);
|
|
1219
1222
|
const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
|
|
1220
1223
|
const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
|
|
@@ -1258,31 +1261,12 @@ function cmdExplain(args) {
|
|
|
1258
1261
|
return 0;
|
|
1259
1262
|
}
|
|
1260
1263
|
|
|
1261
|
-
// The content half of the secret-allow guard in cmdOverride: run the engine's
|
|
1262
|
-
// own secret-category content rules over the target's bytes. Files over the
|
|
1263
|
-
// scanner's per-file budget, unreadable paths, and non-regular files skip it —
|
|
1264
|
-
// this guard is a UX safety net against an allow that would report success yet
|
|
1265
|
-
// leave the block in place, and the commit-time scanner still fails closed on
|
|
1266
|
-
// whatever was not checked here.
|
|
1267
|
-
function fileContentMatchesSecret(engine, repo, target) {
|
|
1268
|
-
let content;
|
|
1269
|
-
try {
|
|
1270
|
-
const file = join(repo.root, target);
|
|
1271
|
-
const stat = lstatSync(file);
|
|
1272
|
-
if (!stat.isFile() || stat.size > DEFAULT_SCAN_LIMITS.maxFileBytes) return false;
|
|
1273
|
-
content = readFileSync(file, 'utf8');
|
|
1274
|
-
} catch {
|
|
1275
|
-
return false;
|
|
1276
|
-
}
|
|
1277
|
-
return engine.checkContent(target, content, { categories: ['secret'] }).length > 0;
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
1264
|
function cmdOverride(args, allow) {
|
|
1281
1265
|
const verb = allow ? 'allow' : 'deny';
|
|
1282
1266
|
const { options, positionals } = parseArguments(args, {
|
|
1283
1267
|
options: {
|
|
1284
1268
|
reason: { names: ['--reason'], type: 'string', nonEmpty: false },
|
|
1285
|
-
scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule'
|
|
1269
|
+
scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule'] },
|
|
1286
1270
|
},
|
|
1287
1271
|
minPositionals: 1,
|
|
1288
1272
|
maxPositionals: 1,
|
|
@@ -1299,31 +1283,6 @@ function cmdOverride(args, allow) {
|
|
|
1299
1283
|
if (scope === 'rule' && !engine.lookup(target)) {
|
|
1300
1284
|
throw new ArgumentError(`unknown rule ID "${target}"; use --scope path for a path with this spelling`);
|
|
1301
1285
|
}
|
|
1302
|
-
if (allow && scope === 'rule' && engine.lookup(target)?.category === 'secret') {
|
|
1303
|
-
throw new ArgumentError(
|
|
1304
|
-
`secret rules cannot be allowed at --scope rule (that would suppress every matching secret path under every profile); use --scope secret-path <path> to allow a specific secret path`,
|
|
1305
|
-
);
|
|
1306
|
-
}
|
|
1307
|
-
// A path that matches a secret rule cannot be silenced by a path (or rule)
|
|
1308
|
-
// allow: only --scope secret-path suppresses secret findings, deliberately,
|
|
1309
|
-
// so a local override cannot hide a possible leaked key. Without this check
|
|
1310
|
-
// a bare `allow .env.minimal` would report success but leave the block in
|
|
1311
|
-
// place, which reads as a broken allow. checkPaths only evaluates path
|
|
1312
|
-
// rules, so a content-shaped secret (a private key inside an
|
|
1313
|
-
// ordinary-looking filename) would slip past it; the content half of the
|
|
1314
|
-
// guard closes that hole.
|
|
1315
|
-
if (allow && scope !== 'secret-path'
|
|
1316
|
-
&& (engine.checkPaths([target]).some((finding) => finding.category === 'secret')
|
|
1317
|
-
|| fileContentMatchesSecret(engine, repo, target))) {
|
|
1318
|
-
throw new ArgumentError(
|
|
1319
|
-
`"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
|
|
1320
|
-
+ '(a local override must not hide a possible leaked key); '
|
|
1321
|
-
+ 'use --scope secret-path to explicitly allow this specific path',
|
|
1322
|
-
);
|
|
1323
|
-
}
|
|
1324
|
-
if (scope === 'secret-path' && !allow) {
|
|
1325
|
-
throw new ArgumentError('--scope secret-path is only valid with allow');
|
|
1326
|
-
}
|
|
1327
1286
|
const entry = {
|
|
1328
1287
|
target,
|
|
1329
1288
|
scope,
|
|
@@ -1646,7 +1605,7 @@ function cmdFix(args) {
|
|
|
1646
1605
|
return expectedErrorCode(e);
|
|
1647
1606
|
}
|
|
1648
1607
|
emitDiagnostics(scan.diagnostics);
|
|
1649
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
1608
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: scan.profile === 'strict' }));
|
|
1650
1609
|
if (scan.profile === 'compliance') {
|
|
1651
1610
|
if (options.apply) throw new ArgumentError('--apply is only valid when the active profile is strict');
|
|
1652
1611
|
if (scan.findings.length) process.stderr.write(human(scan.findings, tone()));
|
|
@@ -1686,9 +1645,13 @@ function cmdFix(args) {
|
|
|
1686
1645
|
? `aimhooman: strict policy would remove ${repair.removed.length} exact attribution line(s); rerun with --apply to write the repair`
|
|
1687
1646
|
: 'aimhooman: strict policy found no automatically repairable attribution lines'
|
|
1688
1647
|
);
|
|
1648
|
+
// 11 asks a human to review the lines a repair would remove, which is
|
|
1649
|
+
// only honest when the recheck saw the whole message. An incomplete
|
|
1650
|
+
// recheck on strict fails closed (31) — the same verdict --apply
|
|
1651
|
+
// reaches below for the same content.
|
|
1689
1652
|
return remaining.some((finding) => finding.decision === 'block')
|
|
1690
1653
|
? 10
|
|
1691
|
-
: repair.removed.length ? 11 : exitCode(remaining, scan.profile, remainingComplete);
|
|
1654
|
+
: repair.removed.length && remainingComplete ? 11 : exitCode(remaining, scan.profile, remainingComplete);
|
|
1692
1655
|
}
|
|
1693
1656
|
} else if (options.apply) {
|
|
1694
1657
|
throw new ArgumentError('--apply is only needed when the active profile is strict');
|
|
@@ -1928,6 +1891,32 @@ function cmdUninstall(args) {
|
|
|
1928
1891
|
} catch (error) {
|
|
1929
1892
|
excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
|
|
1930
1893
|
}
|
|
1894
|
+
// The committed variant of the block is only touched while the local
|
|
1895
|
+
// config says this clone opted in; without that record the worktree
|
|
1896
|
+
// .gitignore is treated as user-authored and left alone.
|
|
1897
|
+
let gitignoreRecord;
|
|
1898
|
+
try {
|
|
1899
|
+
gitignoreRecord = loadConfig(repo.stateDir).gitignore;
|
|
1900
|
+
} catch {
|
|
1901
|
+
gitignoreRecord = undefined;
|
|
1902
|
+
}
|
|
1903
|
+
if (gitignoreRecord?.enabled) {
|
|
1904
|
+
const gitignoreFile = join(repo.root, '.gitignore');
|
|
1905
|
+
try {
|
|
1906
|
+
removeExclude(gitignoreFile);
|
|
1907
|
+
// We introduced the file: once the block is gone and nothing
|
|
1908
|
+
// else remains, delete it rather than leave an empty husk. A
|
|
1909
|
+
// file with any other content is user-authored and stays.
|
|
1910
|
+
if (gitignoreRecord.created
|
|
1911
|
+
&& existsSync(gitignoreFile)
|
|
1912
|
+
&& readFileSync(gitignoreFile, 'utf8').trim() === '') {
|
|
1913
|
+
rmSync(gitignoreFile, { force: true });
|
|
1914
|
+
}
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
const gitignoreFailure = `gitignore block left in ${gitignoreFile}: ${error.message}`;
|
|
1917
|
+
excludeFailure = excludeFailure ? `${excludeFailure}; ${gitignoreFailure}` : gitignoreFailure;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1931
1920
|
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1932
1921
|
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1933
1922
|
const remaining = remainingDispatchers(repo);
|
|
@@ -1990,6 +1979,7 @@ function cmdUninstall(args) {
|
|
|
1990
1979
|
for (const queue of [
|
|
1991
1980
|
`${lifecycleLock}.queue`,
|
|
1992
1981
|
`${repo.excludeFile}.aimhooman.lock.queue`,
|
|
1982
|
+
`${join(repo.root, '.gitignore')}.aimhooman.lock.queue`,
|
|
1993
1983
|
join(effectiveHooksDir(repo), '.aimhooman-hooks.lock.queue'),
|
|
1994
1984
|
]) {
|
|
1995
1985
|
try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
|
|
@@ -2024,14 +2014,14 @@ function usage() {
|
|
|
2024
2014
|
process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
|
|
2025
2015
|
|
|
2026
2016
|
Usage:
|
|
2027
|
-
aimhooman init [--profile clean|strict|compliance] [--
|
|
2017
|
+
aimhooman init [--profile clean|strict|compliance] [--gitignore]
|
|
2028
2018
|
aimhooman init --global --yes
|
|
2029
2019
|
aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
|
|
2030
2020
|
aimhooman check --commit <rev> | --range <base>...<head> | --tracked
|
|
2031
2021
|
aimhooman audit|scan [--json] [--profile ...]
|
|
2032
2022
|
aimhooman status
|
|
2033
2023
|
aimhooman explain <rule-id>
|
|
2034
|
-
aimhooman allow <path|rule-id> [--scope path|rule
|
|
2024
|
+
aimhooman allow <path|rule-id> [--scope path|rule] [--reason "..."]
|
|
2035
2025
|
aimhooman deny <path|rule-id> [--scope path|rule] [--reason "..."]
|
|
2036
2026
|
aimhooman override list [--json]
|
|
2037
2027
|
aimhooman override remove <target>
|
|
@@ -2045,7 +2035,8 @@ Usage:
|
|
|
2045
2035
|
aimhooman version
|
|
2046
2036
|
|
|
2047
2037
|
Exit codes: 0 clean, 10 blocked, 11 review required, 20 invalid input or policy,
|
|
2048
|
-
30 Git or I/O failure, 31 incomplete scan
|
|
2038
|
+
30 Git or I/O failure, 31 incomplete scan on strict or at the final ref guard
|
|
2039
|
+
(clean/compliance warn and continue).
|
|
2049
2040
|
`);
|
|
2050
2041
|
}
|
|
2051
2042
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# AI tooling artifacts for .gitignore
|
|
2
|
+
#
|
|
3
|
+
# These patterns cover the session and state files that AI coding tools
|
|
4
|
+
# write into a working tree: transcripts, history, caches, logs, and local
|
|
5
|
+
# settings. aimhooman keeps them out of commits. Copy the lines below into
|
|
6
|
+
# your .gitignore as-is. Matching follows normal gitignore rules and is
|
|
7
|
+
# case-sensitive. The machine-enforced version of this list lives in the
|
|
8
|
+
# aimhooman CLI, which blocks these paths at commit time; this file is the
|
|
9
|
+
# standalone copy for anyone who wants the same coverage without the hooks.
|
|
10
|
+
#
|
|
11
|
+
# Generated from rules/*.json by `npm run sync:catalog`; do not edit by hand.
|
|
12
|
+
# `npm run check` fails when this file is stale.
|
|
13
|
+
|
|
14
|
+
# >>> aimhooman:catalog-start
|
|
15
|
+
**/.agent/**
|
|
16
|
+
**/.aider.*
|
|
17
|
+
**/.claude.json
|
|
18
|
+
**/.claude/history*
|
|
19
|
+
**/.claude/logs/**
|
|
20
|
+
**/.claude/projects/**
|
|
21
|
+
**/.claude/session*.json
|
|
22
|
+
**/.claude/settings.local.json
|
|
23
|
+
**/.claude/shell-snapshots/**
|
|
24
|
+
**/.claude/statsig/**
|
|
25
|
+
**/.claude/todos/**
|
|
26
|
+
**/.codex/history*
|
|
27
|
+
**/.codex/log/**
|
|
28
|
+
**/.codex/logs/**
|
|
29
|
+
**/.codex/sessions/**
|
|
30
|
+
**/.continue/sessions/**
|
|
31
|
+
**/.copilot/**
|
|
32
|
+
**/.cursor/chats/**
|
|
33
|
+
**/.cursor/logs/**
|
|
34
|
+
**/.cursor/session*
|
|
35
|
+
**/.playwright-mcp/**
|
|
36
|
+
**/.remember/**
|
|
37
|
+
**/.specstory/**
|
|
38
|
+
**/.superpowers/**
|
|
39
|
+
.agent/**
|
|
40
|
+
.aider.*
|
|
41
|
+
.claude.json
|
|
42
|
+
.claude/history*
|
|
43
|
+
.claude/logs/**
|
|
44
|
+
.claude/projects/**
|
|
45
|
+
.claude/session*.json
|
|
46
|
+
.claude/settings.local.json
|
|
47
|
+
.claude/shell-snapshots/**
|
|
48
|
+
.claude/statsig/**
|
|
49
|
+
.claude/todos/**
|
|
50
|
+
.codex/history*
|
|
51
|
+
.codex/log/**
|
|
52
|
+
.codex/logs/**
|
|
53
|
+
.codex/sessions/**
|
|
54
|
+
.continue/sessions/**
|
|
55
|
+
.copilot/**
|
|
56
|
+
.cursor/chats/**
|
|
57
|
+
.cursor/logs/**
|
|
58
|
+
.cursor/session*
|
|
59
|
+
.playwright-mcp/**
|
|
60
|
+
.remember/**
|
|
61
|
+
.specstory/**
|
|
62
|
+
.superpowers/**
|
|
63
|
+
# <<< aimhooman:catalog-end
|
package/docs/catalog.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# AI-artifact catalog
|
|
2
|
+
|
|
3
|
+
<!-- Generated from rules/*.json by `npm run sync:catalog`; do not edit by hand. -->
|
|
4
|
+
|
|
5
|
+
aimhooman watches commits for AI residue: tooling artifacts (session files,
|
|
6
|
+
local settings, and agent state that belong on your machine, not in history)
|
|
7
|
+
and AI attribution (co-author trailers, "generated with" lines, leftover
|
|
8
|
+
markers in code). The table lists every built-in rule and what each profile
|
|
9
|
+
does when it matches: `block` stops the commit, `review` asks a human to
|
|
10
|
+
confirm, `allow` lets it through.
|
|
11
|
+
|
|
12
|
+
Secret scanning is out of scope since v0.3.0. See
|
|
13
|
+
[docs/secrets.md](secrets.md) for the reasoning and the gitleaks setup we
|
|
14
|
+
recommend instead.
|
|
15
|
+
|
|
16
|
+
<!-- aimhooman:catalog-start -->
|
|
17
|
+
| Rule | Provider | Category | Kind | clean | strict | compliance | Reason |
|
|
18
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
19
|
+
| `attribution.claude-coauthor` | claude-code | ai-attribution | message | block | block | allow | Unwanted AI co-author attribution (Claude). |
|
|
20
|
+
| `attribution.copilot-coauthor` | copilot | ai-attribution | message | block | block | allow | Unwanted AI co-author attribution (Copilot). |
|
|
21
|
+
| `attribution.codex-coauthor` | codex | ai-attribution | message | block | block | allow | Unwanted AI co-author attribution (Codex). |
|
|
22
|
+
| `attribution.bot-coauthor` | generic | ai-attribution | message | review | review | allow | Unwanted bot co-author attribution. |
|
|
23
|
+
| `attribution.generated-with` | generic | ai-attribution | message | block | block | allow | Unwanted AI generation attribution in the message. |
|
|
24
|
+
| `attribution.ai-noreply` | generic | ai-attribution | message | review | review | allow | AI service noreply email in the message. |
|
|
25
|
+
| `marker.corner-cut` | generic | ai-marker | code | review | block | review | AI-tooling corner-cut marker left in committed code. |
|
|
26
|
+
| `marker.ai-authored` | generic | ai-marker | code | review | block | review | AI-authored code marker left in committed code. |
|
|
27
|
+
| `claude.local-settings` | claude-code | local-settings | path | block | block | block | Personal Claude Code settings are not intended for source control. |
|
|
28
|
+
| `claude.session-state` | claude-code | ephemeral-state | path | block | block | block | Claude Code session and state artifacts are local, not repository content. |
|
|
29
|
+
| `codex.session-state` | codex | ephemeral-state | path | block | block | block | Codex session, history, and log artifacts are local, not repository content. |
|
|
30
|
+
| `copilot.session-state` | copilot | ephemeral-state | path | block | block | block | Copilot local state is not repository content. |
|
|
31
|
+
| `cursor.session-state` | cursor | ephemeral-state | path | block | block | block | Cursor session artifacts are local, not repository content. |
|
|
32
|
+
| `aider.history` | aider | ephemeral-state | path | block | block | block | Aider dotfiles are local, not repository content. |
|
|
33
|
+
| `specstory.history` | specstory | ephemeral-state | path | block | block | block | SpecStory session history is local, not repository content. |
|
|
34
|
+
| `continue.sessions` | continue | ephemeral-state | path | block | block | block | Continue session artifacts are local, not repository content. |
|
|
35
|
+
| `generic.agent-instructions` | generic | ambiguous-instructions | path | review | block | review | Agent instruction files may be intentional team config. Review before committing. |
|
|
36
|
+
| `generic.project-policy` | aimhooman | policy-config | path | review | block | review | Versioned enforcement policy changes require explicit human review. |
|
|
37
|
+
| `playwright-mcp.state` | playwright-mcp | ephemeral-state | path | block | block | block | Playwright MCP session artifacts are local, not repository content. |
|
|
38
|
+
| `remember.state` | remember | ephemeral-state | path | block | block | block | Remember second-brain data is local, not repository content. |
|
|
39
|
+
| `superpowers.state` | superpowers | ephemeral-state | path | block | block | block | Superpowers plugin state is local, not repository content. |
|
|
40
|
+
| `agent.state` | generic | ephemeral-state | path | block | block | block | Generic agent state is local, not repository content. |
|
|
41
|
+
<!-- aimhooman:catalog-end -->
|