@rmyndharis/aimhooman 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -11,7 +11,7 @@ import { normalizeGitPath } from './git-path.mjs';
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',
@@ -121,20 +121,29 @@ export function loadConfig(stateDir, root) {
121
121
  throw new LocalConfigError(file, `invalid JSON: ${error.message}`, error);
122
122
  }
123
123
  const normalized = normalizeLocalConfig(config, file);
124
- return { profile: normalized.profile, source: 'local', file };
124
+ return {
125
+ profile: normalized.profile,
126
+ source: 'local',
127
+ file,
128
+ ...(normalized.gitignore ? { gitignore: normalized.gitignore } : {}),
129
+ };
125
130
  }
126
131
 
127
132
  export function saveConfig(stateDir, config) {
128
133
  const file = join(stateDir, 'config.json');
129
134
  const normalized = normalizeLocalConfig(config, file);
130
- atomicWriteJson(file, { schema_version: 1, profile: normalized.profile });
135
+ atomicWriteJson(file, {
136
+ schema_version: 1,
137
+ profile: normalized.profile,
138
+ ...(normalized.gitignore ? { gitignore: normalized.gitignore } : {}),
139
+ });
131
140
  }
132
141
 
133
142
  function normalizeLocalConfig(config, file) {
134
143
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
135
144
  throw new LocalConfigError(file, 'root must be a JSON object');
136
145
  }
137
- const unknown = Object.keys(config).filter((key) => !['schema_version', 'profile'].includes(key));
146
+ const unknown = Object.keys(config).filter((key) => !['schema_version', 'profile', 'gitignore'].includes(key));
138
147
  if (unknown.length) {
139
148
  throw new LocalConfigError(file, `unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
140
149
  }
@@ -147,7 +156,32 @@ function normalizeLocalConfig(config, file) {
147
156
  if (!PROFILES.has(config.profile)) {
148
157
  throw new LocalConfigError(file, 'profile must be clean, strict, or compliance');
149
158
  }
150
- return { profile: config.profile };
159
+ const gitignore = normalizeGitignoreConfig(config.gitignore, file);
160
+ return { profile: config.profile, ...(gitignore ? { gitignore } : {}) };
161
+ }
162
+
163
+ // The gitignore field records an `init --gitignore` opt-in for this clone:
164
+ // enabled says the worktree .gitignore carries our managed block, created says
165
+ // the file did not exist before we wrote it (so uninstall may delete a file we
166
+ // introduced once the block leaves it empty). It stays out of the project
167
+ // policy on purpose — whether this clone created its .gitignore is per-clone
168
+ // state, never team policy. Absent means disabled, and a disabled record
169
+ // normalizes away so the two spellings cannot drift apart.
170
+ function normalizeGitignoreConfig(value, file) {
171
+ if (value === undefined) return undefined;
172
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
173
+ throw new LocalConfigError(file, 'gitignore must be an object');
174
+ }
175
+ const unknown = Object.keys(value).filter((key) => !['enabled', 'created'].includes(key));
176
+ if (unknown.length) {
177
+ throw new LocalConfigError(file, `gitignore has unsupported field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
178
+ }
179
+ for (const field of ['enabled', 'created']) {
180
+ if (typeof value[field] !== 'boolean') {
181
+ throw new LocalConfigError(file, `gitignore.${field} must be a boolean`);
182
+ }
183
+ }
184
+ return value.enabled ? { enabled: true, created: value.created } : undefined;
151
185
  }
152
186
 
153
187
  export function loadOverrides(stateDir) {
@@ -196,18 +230,33 @@ function normalizeOverrides(value, file) {
196
230
  if (value.schema_version !== undefined && value.schema_version !== 1) {
197
231
  throw new LocalOverridesError(file, 'schema_version must be 1');
198
232
  }
199
- return {
200
- allow: overrideEntries(value.allow, 'allow', file),
201
- deny: overrideEntries(value.deny, 'deny', file),
233
+ const dropped = { count: 0 };
234
+ const normalized = {
235
+ allow: overrideEntries(value.allow, 'allow', file, dropped),
236
+ deny: overrideEntries(value.deny, 'deny', file, dropped),
202
237
  };
238
+ if (dropped.count) {
239
+ process.stderr.write(
240
+ `aimhooman: warning: ${file}: dropped ${dropped.count} override(s) with retired scope "secret-path"; built-in secret scanning was removed in v0.3.0\n`
241
+ );
242
+ }
243
+ return normalized;
203
244
  }
204
245
 
205
- function overrideEntries(value, key, file) {
246
+ function overrideEntries(value, key, file, dropped = { count: 0 }) {
206
247
  if (value === undefined) return [];
207
248
  if (!Array.isArray(value)) {
208
249
  throw new LocalOverridesError(file, `${key} must be an array`);
209
250
  }
210
251
  return value.map((entry, index) => {
252
+ // Built-in secret scanning and its secret-path override scope were
253
+ // removed in v0.3.0. An overrides file written by an older version may
254
+ // still carry such entries; drop them (one warning per file, below)
255
+ // instead of failing the whole load.
256
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.scope === 'secret-path') {
257
+ dropped.count += 1;
258
+ return null;
259
+ }
211
260
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
212
261
  throw new LocalOverridesError(file, `${key}[${index}] must be an object`);
213
262
  }
@@ -260,7 +309,7 @@ function overrideEntries(value, key, file) {
260
309
  normalized.transition = normalized.transition.toLowerCase();
261
310
  }
262
311
  return normalized;
263
- });
312
+ }).filter((entry) => entry !== null);
264
313
  }
265
314
 
266
315
  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
- ]