@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/src/scan.mjs CHANGED
@@ -19,7 +19,6 @@ export class Engine {
19
19
  this.rules = rules;
20
20
  this.allowPaths = new Set();
21
21
  this.allowRules = new Set();
22
- this.allowSecretPaths = new Set();
23
22
  this.denyPaths = new Set();
24
23
  this.denyRules = new Set();
25
24
  this.scopedAllow = [];
@@ -33,7 +32,6 @@ export class Engine {
33
32
  const denied = this.#splitOverrides(deny);
34
33
  this.allowPaths = allowed.paths;
35
34
  this.allowRules = allowed.rules;
36
- this.allowSecretPaths = allowed.secretPaths;
37
35
  this.denyPaths = denied.paths;
38
36
  this.denyRules = denied.rules;
39
37
  this.scopedAllow = scopedAllow.filter((entry) => (
@@ -59,8 +57,8 @@ export class Engine {
59
57
  // - clean/compliance: review is ADVISORY (a stderr message, exit 0),
60
58
  // so re-surfacing it on every edit of a reviewed agent-instruction
61
59
  // file (CLAUDE.md, AGENTS.md) is pure friction with no security
62
- // payoff — the security boundary is secret scanning, strict blocks,
63
- // and reference-transaction. A LIVE-FILE review (newObjectId set)
60
+ // payoff — the security boundary is strict blocks and the
61
+ // reference-transaction guard. A LIVE-FILE review (newObjectId set)
64
62
  // therefore persists per path + rule across edits. A TOMBSTONE
65
63
  // review (newObjectId null — a reviewed deletion) keeps the exact
66
64
  // match in every profile: a deletion review must not silently cover
@@ -80,20 +78,13 @@ export class Engine {
80
78
  && entry.newObjectId === context.objectId
81
79
  && entry.newMode === context.mode);
82
80
  })) return 'allow';
83
- // A rule-level allow cannot bypass the secret-category guard that the
84
- // path-level allow below enforces: secret rules require an explicit
85
- // --scope secret-path override on a specific path, never a blanket rule allow.
86
- if (this.allowRules.has(rule.id) && rule.category !== 'secret') return 'allow';
87
- // A --scope secret-path allow is the explicit override for secret-category
88
- // rules only; it must not also suppress a non-secret rule that happens to
89
- // match the same path (principle of least privilege).
90
- if (this.allowSecretPaths.has(target) && rule.category === 'secret') return 'allow';
91
- if (this.allowPaths.has(target) && rule.category !== 'secret') return 'allow';
81
+ if (this.allowRules.has(rule.id)) return 'allow';
82
+ if (this.allowPaths.has(target)) return 'allow';
92
83
  return base;
93
84
  }
94
85
 
95
86
  #splitOverrides(entries) {
96
- const result = { paths: new Set(), rules: new Set(), secretPaths: new Set() };
87
+ const result = { paths: new Set(), rules: new Set() };
97
88
  for (const entry of entries || []) {
98
89
  const target = typeof entry === 'string' ? entry : entry?.target;
99
90
  if (!target) continue;
@@ -101,7 +92,6 @@ export class Engine {
101
92
  ? (this.lookup(target) ? 'rule' : 'path')
102
93
  : (entry.scope ?? (this.lookup(target) ? 'rule' : 'path'));
103
94
  if (scope === 'rule') result.rules.add(target);
104
- else if (scope === 'secret-path') result.secretPaths.add(target);
105
95
  else if (scope === 'path') result.paths.add(target);
106
96
  }
107
97
  return result;
@@ -138,13 +128,10 @@ export class Engine {
138
128
  checkContent(path, content, options = {}) {
139
129
  const out = [];
140
130
  const p = normalize(path);
141
- const categories = Array.isArray(options.categories)
142
- ? new Set(options.categories)
143
- : null;
144
131
  // lineRanges narrows content scanning to changed hunks (W4, bug 12d-F1).
145
132
  // When provided (an array of inclusive 1-based {start,end} ranges), only
146
133
  // lines falling in a range are evaluated; this stops a file that contains
147
- // a secret-bearing line ELSEWHERE (a PEM header inside a test string on
134
+ // a flagged line ELSEWHERE (a marker phrase inside a test fixture on
148
135
  // line 200) from blocking a commit that only edited line 50. Findings
149
136
  // keep their real line numbers because lineRecords already anchors them.
150
137
  // Absent = scan every line (the pre-W4 behaviour).
@@ -153,8 +140,8 @@ export class Engine {
153
140
  : null;
154
141
  for (const record of lineRecords(String(content))) {
155
142
  if (ranges && !ranges.some((range) => record.line >= range.start && record.line <= range.end)) continue;
156
- this.#markLocalInputSkip('code', p, record.text, categories);
157
- const result = this.#evaluate('code', p, record.text, { categories });
143
+ this.#markLocalInputSkip('code', p, record.text);
144
+ const result = this.#evaluate('code', p, record.text);
158
145
  if (result) out.push(finding(result, { path: p, line: record.line, text: record.text }));
159
146
  }
160
147
  return out;
@@ -198,11 +185,10 @@ export class Engine {
198
185
  return skipped;
199
186
  }
200
187
 
201
- #markLocalInputSkip(kind, target, text, categories = null) {
188
+ #markLocalInputSkip(kind, target, text) {
202
189
  if (text.length <= MAX_LOCAL_MATCH_INPUT) return;
203
190
  const applicable = this.rules.some((rule) => {
204
191
  if (rule.source !== 'local' || rule.kind !== kind) return false;
205
- if (categories && !categories.has(rule.category)) return false;
206
192
  if (kind !== 'code') return true;
207
193
  const candidate = rule.pathCaseInsensitive ? target.toLowerCase() : target;
208
194
  if (rule.pathRes.length && !rule.pathRes.some((regexp) => regexp.test(candidate))) return false;
@@ -216,7 +202,6 @@ export class Engine {
216
202
  for (const rule of this.rules) {
217
203
  if (rule.kind !== kind) continue;
218
204
  if (context.excludedRuleIds?.has(rule.id)) continue;
219
- if (context.categories && !context.categories.has(rule.category)) continue;
220
205
  const matched = kind === 'path'
221
206
  ? matchesPath(rule, target)
222
207
  : matchesContent(rule, text, kind === 'code' ? target : undefined);
package/src/state.mjs CHANGED
@@ -4,14 +4,14 @@ import {
4
4
  readFileSync,
5
5
  } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
- import { atomicWrite } from './atomic-write.mjs';
7
+ import { atomicWrite, withLock } from './atomic-write.mjs';
8
8
  import { normalizeGitPath } from './git-path.mjs';
9
9
 
10
10
  // Per-repository state, stored in the common Git dir (never the worktree).
11
11
 
12
12
  const PROFILES = new Set(['clean', 'strict', 'compliance']);
13
13
  const OVERRIDE_SCOPES = new Set([
14
- 'path', 'rule', 'secret-path', 'reviewed-instruction', 'reviewed-policy-file', 'policy-migration',
14
+ 'path', 'rule', 'reviewed-instruction', 'reviewed-policy-file', 'policy-migration',
15
15
  ]);
16
16
  const OVERRIDE_FIELDS = new Set([
17
17
  'target', 'scope', 'reason', 'actor', 'at', 'head', 'transition', 'oldObjectId', 'newObjectId', 'newMode',
@@ -105,8 +105,20 @@ export function parseProjectPolicy(text, file = '.aimhooman.json') {
105
105
 
106
106
  export function loadConfig(stateDir, root) {
107
107
  const project = loadProjectPolicy(root);
108
- if (project) return { profile: project.profile, source: 'project', file: project.file };
109
108
  const file = join(stateDir, 'config.json');
109
+ if (project) {
110
+ // The project policy owns the profile, but the --gitignore opt-in stays
111
+ // per-clone state in config.json; a two-arg caller still needs it. The
112
+ // profile no longer depends on that file here, so a missing or corrupt
113
+ // one just omits the record instead of failing the load.
114
+ const gitignore = readGitignoreRecord(file);
115
+ return {
116
+ profile: project.profile,
117
+ source: 'project',
118
+ file: project.file,
119
+ ...(gitignore ? { gitignore } : {}),
120
+ };
121
+ }
110
122
  let text;
111
123
  try {
112
124
  text = readFileSync(file, 'utf8');
@@ -121,20 +133,40 @@ export function loadConfig(stateDir, root) {
121
133
  throw new LocalConfigError(file, `invalid JSON: ${error.message}`, error);
122
134
  }
123
135
  const normalized = normalizeLocalConfig(config, file);
124
- return { profile: normalized.profile, source: 'local', file };
136
+ return {
137
+ profile: normalized.profile,
138
+ source: 'local',
139
+ file,
140
+ ...(normalized.gitignore ? { gitignore: normalized.gitignore } : {}),
141
+ };
142
+ }
143
+
144
+ // Best-effort read of the per-clone gitignore record for the project-policy
145
+ // branch above: any failure (absent file, invalid JSON, invalid shape) means
146
+ // no record, never a failed load.
147
+ function readGitignoreRecord(file) {
148
+ try {
149
+ return normalizeLocalConfig(JSON.parse(stripBom(readFileSync(file, 'utf8'))), file).gitignore;
150
+ } catch {
151
+ return undefined;
152
+ }
125
153
  }
126
154
 
127
155
  export function saveConfig(stateDir, config) {
128
156
  const file = join(stateDir, 'config.json');
129
157
  const normalized = normalizeLocalConfig(config, file);
130
- atomicWriteJson(file, { schema_version: 1, profile: normalized.profile });
158
+ atomicWriteJson(file, {
159
+ schema_version: 1,
160
+ profile: normalized.profile,
161
+ ...(normalized.gitignore ? { gitignore: normalized.gitignore } : {}),
162
+ });
131
163
  }
132
164
 
133
165
  function normalizeLocalConfig(config, file) {
134
166
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
135
167
  throw new LocalConfigError(file, 'root must be a JSON object');
136
168
  }
137
- const unknown = Object.keys(config).filter((key) => !['schema_version', 'profile'].includes(key));
169
+ const unknown = Object.keys(config).filter((key) => !['schema_version', 'profile', 'gitignore'].includes(key));
138
170
  if (unknown.length) {
139
171
  throw new LocalConfigError(file, `unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
140
172
  }
@@ -147,7 +179,32 @@ function normalizeLocalConfig(config, file) {
147
179
  if (!PROFILES.has(config.profile)) {
148
180
  throw new LocalConfigError(file, 'profile must be clean, strict, or compliance');
149
181
  }
150
- return { profile: config.profile };
182
+ const gitignore = normalizeGitignoreConfig(config.gitignore, file);
183
+ return { profile: config.profile, ...(gitignore ? { gitignore } : {}) };
184
+ }
185
+
186
+ // The gitignore field records an `init --gitignore` opt-in for this clone:
187
+ // enabled says the worktree .gitignore carries our managed block, created says
188
+ // the file did not exist before we wrote it (so uninstall may delete a file we
189
+ // introduced once the block leaves it empty). It stays out of the project
190
+ // policy on purpose — whether this clone created its .gitignore is per-clone
191
+ // state, never team policy. Absent means disabled, and a disabled record
192
+ // normalizes away so the two spellings cannot drift apart.
193
+ function normalizeGitignoreConfig(value, file) {
194
+ if (value === undefined) return undefined;
195
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
196
+ throw new LocalConfigError(file, 'gitignore must be an object');
197
+ }
198
+ const unknown = Object.keys(value).filter((key) => !['enabled', 'created'].includes(key));
199
+ if (unknown.length) {
200
+ throw new LocalConfigError(file, `gitignore has unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
201
+ }
202
+ for (const field of ['enabled', 'created']) {
203
+ if (typeof value[field] !== 'boolean') {
204
+ throw new LocalConfigError(file, `gitignore.${field} must be a boolean`);
205
+ }
206
+ }
207
+ return value.enabled ? { enabled: true, created: value.created } : undefined;
151
208
  }
152
209
 
153
210
  export function loadOverrides(stateDir) {
@@ -159,7 +216,28 @@ export function loadOverrides(stateDir) {
159
216
  if (error?.code === 'ENOENT') return { allow: [], deny: [] };
160
217
  throw new LocalOverridesError(file, `cannot read file: ${error.message}`, error);
161
218
  }
162
- return parseOverrides(text, file);
219
+ const dropped = { count: 0 };
220
+ const overrides = parseOverrides(text, file, dropped);
221
+ if (dropped.count) {
222
+ process.stderr.write(
223
+ `aimhooman: warning: ${file}: dropped ${dropped.count} override(s) with retired scope "secret-path"; built-in secret scanning was removed in v0.3.0\n`
224
+ );
225
+ // Persist the cleaned file so the migration warns once, not on every
226
+ // command. Re-read under the lock so a concurrent allow/deny write is
227
+ // never clobbered; a failed rewrite only means the warning returns
228
+ // next run, so this stays best effort. One attempt, no queue wait:
229
+ // allow/deny/override/review/policy-review call loadOverrides while
230
+ // already holding this same lock, and the bakery queue would park the
231
+ // inner candidate behind the outer one for the full retry budget
232
+ // before the catch below swallowed the throw.
233
+ try {
234
+ withLock(`${file}.lock`, () => {
235
+ const fresh = parseOverrides(readFileSync(file, 'utf8'), file);
236
+ atomicWriteJson(file, { schema_version: 1, ...fresh });
237
+ }, { retries: 1 });
238
+ } catch { /* best effort */ }
239
+ }
240
+ return overrides;
163
241
  }
164
242
 
165
243
  export function saveOverrides(stateDir, overrides) {
@@ -173,17 +251,17 @@ export function normalizeOverrideTarget(target) {
173
251
  return normalizeGitPath(target);
174
252
  }
175
253
 
176
- function parseOverrides(text, file) {
254
+ function parseOverrides(text, file, dropped = { count: 0 }) {
177
255
  let value;
178
256
  try {
179
257
  value = JSON.parse(stripBom(text));
180
258
  } catch (error) {
181
259
  throw new LocalOverridesError(file, `invalid JSON: ${error.message}`, error);
182
260
  }
183
- return normalizeOverrides(value, file);
261
+ return normalizeOverrides(value, file, dropped);
184
262
  }
185
263
 
186
- function normalizeOverrides(value, file) {
264
+ function normalizeOverrides(value, file, dropped = { count: 0 }) {
187
265
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
188
266
  throw new LocalOverridesError(file, 'root must be a JSON object');
189
267
  }
@@ -196,18 +274,29 @@ function normalizeOverrides(value, file) {
196
274
  if (value.schema_version !== undefined && value.schema_version !== 1) {
197
275
  throw new LocalOverridesError(file, 'schema_version must be 1');
198
276
  }
277
+ // The caller owns the drop count: loadOverrides warns and persists the
278
+ // cleaned file, saveOverrides drops retired entries silently on the next
279
+ // write.
199
280
  return {
200
- allow: overrideEntries(value.allow, 'allow', file),
201
- deny: overrideEntries(value.deny, 'deny', file),
281
+ allow: overrideEntries(value.allow, 'allow', file, dropped),
282
+ deny: overrideEntries(value.deny, 'deny', file, dropped),
202
283
  };
203
284
  }
204
285
 
205
- function overrideEntries(value, key, file) {
286
+ function overrideEntries(value, key, file, dropped = { count: 0 }) {
206
287
  if (value === undefined) return [];
207
288
  if (!Array.isArray(value)) {
208
289
  throw new LocalOverridesError(file, `${key} must be an array`);
209
290
  }
210
291
  return value.map((entry, index) => {
292
+ // Built-in secret scanning and its secret-path override scope were
293
+ // removed in v0.3.0. An overrides file written by an older version may
294
+ // still carry such entries; drop them (loadOverrides warns once and
295
+ // persists the cleaned file) instead of failing the whole load.
296
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.scope === 'secret-path') {
297
+ dropped.count += 1;
298
+ return null;
299
+ }
211
300
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
212
301
  throw new LocalOverridesError(file, `${key}[${index}] must be an object`);
213
302
  }
@@ -260,7 +349,7 @@ function overrideEntries(value, key, file) {
260
349
  normalized.transition = normalized.transition.toLowerCase();
261
350
  }
262
351
  return normalized;
263
- });
352
+ }).filter((entry) => entry !== null);
264
353
  }
265
354
 
266
355
  function isRfc3339DateTime(value) {
@@ -1,96 +0,0 @@
1
- [
2
- {
3
- "id": "secret.private-key-content",
4
- "version": 1,
5
- "provider": "generic",
6
- "category": "secret",
7
- "confidence": "high",
8
- "kind": "code",
9
- "match": {
10
- "content": [
11
- "-----BEGIN (?:ENCRYPTED |RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----"
12
- ]
13
- },
14
- "actions": {
15
- "clean": "block",
16
- "strict": "block",
17
- "compliance": "block"
18
- },
19
- "reason": "Private-key material must not enter Git history.",
20
- "remediation": [
21
- "Remove the key from the index, rotate it if exposed, and store it outside the repository.",
22
- "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
23
- ]
24
- },
25
- {
26
- "id": "secret.service-account-key",
27
- "version": 1,
28
- "provider": "generic",
29
- "category": "secret",
30
- "confidence": "high",
31
- "kind": "code",
32
- "match": {
33
- "content": [
34
- "(?i)\\\"private_key\\\"\\s*:\\s*\\\"-----BEGIN (?:RSA )?PRIVATE KEY-----"
35
- ]
36
- },
37
- "actions": {
38
- "clean": "block",
39
- "strict": "block",
40
- "compliance": "block"
41
- },
42
- "reason": "A service-account credential must not enter Git history.",
43
- "remediation": [
44
- "Remove the credential from the index, rotate it if exposed, and use the service's secret store.",
45
- "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
46
- ]
47
- },
48
- {
49
- "id": "secret.aws-key-content",
50
- "version": 3,
51
- "provider": "aws",
52
- "category": "secret",
53
- "confidence": "high",
54
- "kind": "code",
55
- "match": {
56
- "content": [
57
- "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?<!EXAMPLEKEY)(?![A-Za-z0-9/+=])",
58
- "\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
59
- ]
60
- },
61
- "actions": {
62
- "clean": "block",
63
- "strict": "block",
64
- "compliance": "block"
65
- },
66
- "reason": "AWS credential material must not enter Git history.",
67
- "remediation": [
68
- "Remove the credential from the index, rotate it if exposed, and use an AWS credential provider.",
69
- "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
70
- ]
71
- },
72
- {
73
- "id": "secret.provider-token",
74
- "version": 1,
75
- "provider": "generic",
76
- "category": "secret",
77
- "confidence": "high",
78
- "kind": "code",
79
- "match": {
80
- "content": [
81
- "\\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",
82
- "\\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"
83
- ]
84
- },
85
- "actions": {
86
- "clean": "block",
87
- "strict": "block",
88
- "compliance": "block"
89
- },
90
- "reason": "A provider access token must not enter Git history.",
91
- "remediation": [
92
- "Remove the token from the index, revoke or rotate it, and use the provider's secret store.",
93
- "If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
94
- ]
95
- }
96
- ]