atris 3.27.0 → 3.29.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.
@@ -0,0 +1,440 @@
1
+ 'use strict';
2
+
3
+ // atris security scan — deterministic secrets / PII / privacy detectors (no LLM).
4
+ //
5
+ // A finding is a fact (file:line + rule + severity), so it drops straight into a
6
+ // loop / mission / CI gate and doubles as a SOC 2 evidence artifact (machine
7
+ // JSON). Precision over recall: a noisy gate gets muted, and a muted gate is dead.
8
+ // Suppress a single line with a trailing `atris-allow-secret` comment.
9
+ //
10
+ // Zero external deps (Node built-ins only) — repo contract.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+ const { execFileSync } = require('child_process');
16
+
17
+ const SCAN_EXTS = new Set([
18
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.md', '.mdx', '.txt',
19
+ '.yml', '.yaml', '.env', '.sh', '.bash', '.zsh', '.py', '.rb', '.go', '.java',
20
+ '.php', '.toml', '.ini', '.cfg', '.conf', '.html', '.vue', '.svelte', '.patch',
21
+ ]);
22
+ const SKIP_DIRS = new Set([
23
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'out', 'vendor',
24
+ '.cache', '__pycache__', '_archive', 'fixtures', 'fixture', 'snapshots',
25
+ 'snapshot', '__snapshots__', 'testdata',
26
+ ]);
27
+
28
+ const DEFAULT_BASELINE = '.security-review.baseline.json';
29
+
30
+ // Lines that opt out, and placeholder noise we never flag.
31
+ const ALLOW_MARKER = /atris-allow-secret|atris-security-ignore|#\s*nosec/i;
32
+ const PLACEHOLDER = /\b(?:example|placeholder|your[_-]?|my[_-]?key|xxx+|redacted|dummy|sample|changeme|test[_-]?key|fake|fixture|benchmark|mock|not[-_ ]?real|should[-_ ]?not[-_ ]?leak|should[-_ ]?redact|no\s+matches|process\.env|getenv|os\.environ|import\.meta\.env)\b|<[a-z0-9_-]+>/i;
33
+ const PLACEHOLDER_VALUE_FRAGMENTS = [
34
+ 'should-not-leak', 'should_redact', 'fixture-token', 'xoxb-secret',
35
+ 'xoxb-should-not-leak', 'xoxb-fixture-token', 'your_api_key',
36
+ 'your-api-key', 'your_key_here', 'your-token', 'secret_yyy', 'secret-yyy',
37
+ 'paid-audit-api-key', 'prod-api-key', 'free-plan-key', 'test1234567890',
38
+ 'abc123def456', 'abcdefghijklmnopqrstuvwxyz', 'abcdef1234567890',
39
+ 'sk-test', 'sk-abc', 'dev-secret', 'test-secret', 'my-super-secret',
40
+ 'same-secret', 'correct_secret', 'oauth_access_token', 'some_secret',
41
+ 'private-key-pem', 'shared-secret', 'oauth-access', 'ext-oauth',
42
+ 'environment-secret', 'immutable-secret', 'member-secret', 'atris_sk_test',
43
+ ];
44
+ const COMMON_PLACEHOLDER_VALUES = new Set([
45
+ 'password', 'passw0rd', 'hunter2', 'hunter2hunter2', 'secret', 'secret123',
46
+ 'test', 'testpass123', 'test_token_123', 'dev-user', 'dev-secret-do-not-use-in-prod',
47
+ 'sovereign-local', 'my-hmac-key', 'env-token', 'audit-key',
48
+ ]);
49
+
50
+ // High-precision secret patterns. Known-provider keys become critical only when
51
+ // their value quality check says the variable part looks real.
52
+ const SECRET_RULES = [
53
+ { id: 'private-key', sev: 'critical', cat: 'secret', re: /(-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA |ENCRYPTED )?PRIVATE KEY-----)/, known: true, noEntropy: true, why: 'private key committed in source' },
54
+ { id: 'aws-access-key-id', sev: 'critical', cat: 'secret', re: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/, known: true, why: 'real-looking AWS access key id' },
55
+ { id: 'github-token', sev: 'critical', cat: 'secret', re: /\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/, known: true, why: 'real-looking GitHub token' },
56
+ { id: 'slack-token', sev: 'critical', cat: 'secret', re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/, known: true, why: 'real-looking Slack token' },
57
+ { id: 'slack-webhook', sev: 'critical', cat: 'secret', re: /(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{20,})/, known: true, why: 'real-looking Slack incoming webhook url' },
58
+ { id: 'openai-key', sev: 'critical', cat: 'secret', re: /\b(sk-proj-[A-Za-z0-9_-]{32,}|sk-[A-Za-z0-9]{32,})\b/, known: true, why: 'real-looking OpenAI-style API key' },
59
+ { id: 'anthropic-key', sev: 'critical', cat: 'secret', re: /\b(sk-ant-[A-Za-z0-9_-]{20,})\b/, known: true, why: 'real-looking Anthropic API key' },
60
+ { id: 'google-api-key', sev: 'critical', cat: 'secret', re: /\b(AIza[0-9A-Za-z_-]{35})\b/, known: true, why: 'real-looking Google API key' },
61
+ { id: 'stripe-key', sev: 'critical', cat: 'secret', re: /\b([rs]k_live_[A-Za-z0-9]{20,})\b/, known: true, why: 'real-looking Stripe live key' },
62
+ { id: 'npm-token', sev: 'critical', cat: 'secret', re: /\b(npm_[A-Za-z0-9]{36})\b/, known: true, why: 'real-looking npm access token' },
63
+ { id: 'sendgrid-key', sev: 'critical', cat: 'secret', re: /\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b/, known: true, why: 'real-looking SendGrid API key' },
64
+ { id: 'jwt', sev: 'medium', cat: 'secret', re: /\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})\b/, why: 'JWT-like token in source' },
65
+ { id: 'bearer-token', sev: 'medium', cat: 'secret', re: /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/, why: 'hardcoded Bearer token' },
66
+ { id: 'assigned-secret', sev: 'high', cat: 'secret', re: /\b(?:password|passwd|pwd|secret|api[_-]?key|apikey|apiKey|access[_-]?token|accessToken|auth[_-]?token|authToken|client[_-]?secret|clientSecret|private[_-]?key|privateKey)\b\s*[:=]\s*(['"`])([^'"`\s]{8,})\1/i, valueGroup: 2, why: 'real-looking hardcoded credential value' },
67
+ ];
68
+
69
+ // Personal data. Home-path is the recurring leak (a username + local layout).
70
+ const PII_RULES = [
71
+ { id: 'home-path', sev: 'medium', cat: 'pii', re: /(?:\/Users\/|\/home\/)(?!runner\/|runner\b|root\/|root\b|ubuntu\/|ubuntu\b|user\/|user\b|node\/)[a-z][a-z0-9_.-]+/i, why: 'personal home path leaks a username + local layout (use os.homedir()/relative)' },
72
+ { id: 'windows-home-path', sev: 'medium', cat: 'pii', re: /[A-Za-z]:\\Users\\(?!Public\b)[^\\\s'"]+/, why: 'personal Windows path leaks a username' },
73
+ { id: 'email', sev: 'low', cat: 'pii', re: /\b[A-Za-z0-9._%+-]+@(?!example\.|sentry\.io|test\b|localhost|[\w.-]*\.local\b|sub\.)[A-Za-z0-9.-]+\.(?:com|net|org|ai|io|dev|co)\b/, why: 'email address (possible PII)' },
74
+ { id: 'phone', sev: 'low', cat: 'pii', re: /(?<![\d.\w])(?:\+?1[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}(?![\d.\w])/, why: 'phone-number-shaped string' },
75
+ ];
76
+
77
+ // Code-execution risks. For an AI CLI that runs autonomous loops and shells out,
78
+ // these are the "are we actually safe" checks beyond data exposure. eval/Function
79
+ // are almost never legitimate (HIGH); shelling out with interpolated input is the
80
+ // command-injection class (MEDIUM — common and sometimes safe, so it asks for a
81
+ // human look rather than hard-failing the gate).
82
+ const CODE_RULES = [
83
+ { id: 'eval-call', sev: 'medium', cat: 'code', re: /(?<![.\w])eval\((?!\s*\))/, why: 'eval() can run untrusted code' },
84
+ { id: 'new-function', sev: 'medium', cat: 'code', re: /\bnew\s+Function\(/, why: 'new Function() can run untrusted code' },
85
+ { id: 'shell-exec-interpolation', sev: 'medium', cat: 'code', re: /\b(?:exec|execSync)\s*\(\s*[`'"][^`'"]*(?:\$\{|"\s*\+|'\s*\+)/, why: 'shell exec with interpolated input (command-injection risk; prefer execFile/spawn with an args array)' },
86
+ { id: 'child-process-shell-true', sev: 'medium', cat: 'code', re: /\bshell\s*:\s*true\b/, why: 'child_process shell:true enables shell interpretation of args' },
87
+ ];
88
+
89
+ // extra per-line excludes that keep email/path rules from firing on safe noise
90
+ const EMAIL_SAFE = /\b(?:noreply|no-reply|support|hello|info|contact|team|hi|admin|press)@/i;
91
+
92
+ const RULES = [...SECRET_RULES, ...PII_RULES, ...CODE_RULES];
93
+ const SEVERITIES = ['critical', 'high', 'medium', 'low'];
94
+ const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
95
+
96
+ // Filenames that should never be committed (checked against the tracked file list).
97
+ const SENSITIVE_FILE_RE = /(?:^|\/)(?:\.env(?:\.[\w.-]+)?|id_rsa|id_dsa|id_ed25519|.*\.pem|.*\.pfx|.*\.p12|.*\.keystore|credentials\.json|\.npmrc|\.pypirc|\.netrc|secrets?\.(?:json|ya?ml|env))$/i;
98
+ const SENSITIVE_FILE_ALLOW = /\.env\.example$|\.env\.sample$|\.env\.template$/i;
99
+
100
+ function normalizeSnippet(snippet) {
101
+ return String(snippet || '').replace(/\s+/g, ' ').trim();
102
+ }
103
+
104
+ function sha256(value) {
105
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
106
+ }
107
+
108
+ function shannonEntropy(value) {
109
+ const s = String(value || '');
110
+ if (!s) return 0;
111
+ const freq = new Map();
112
+ for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
113
+ let entropy = 0;
114
+ for (const count of freq.values()) {
115
+ const p = count / s.length;
116
+ entropy -= p * Math.log2(p);
117
+ }
118
+ return entropy;
119
+ }
120
+
121
+ function charClassCount(value) {
122
+ const s = String(value || '');
123
+ let count = 0;
124
+ if (/[a-z]/.test(s)) count++;
125
+ if (/[A-Z]/.test(s)) count++;
126
+ if (/[0-9]/.test(s)) count++;
127
+ if (/[^A-Za-z0-9]/.test(s)) count++;
128
+ return count;
129
+ }
130
+
131
+ function uniqueChars(value) {
132
+ return new Set(String(value || '').split('')).size;
133
+ }
134
+
135
+ function hasRepeatedChunk(value) {
136
+ const s = String(value || '');
137
+ if (/^(.)\1{7,}$/.test(s)) return true;
138
+ for (let size = 2; size <= Math.min(24, Math.floor(s.length / 2)); size++) {
139
+ if (s.length % size !== 0) continue;
140
+ const chunk = s.slice(0, size);
141
+ if (chunk.repeat(s.length / size) === s) return true;
142
+ }
143
+ return /(.{8,})\1/.test(s);
144
+ }
145
+
146
+ function hasSequentialRun(value) {
147
+ const s = String(value || '').toLowerCase();
148
+ const sequences = [
149
+ 'abcdefghijklmnopqrstuvwxyz',
150
+ 'zyxwvutsrqponmlkjihgfedcba',
151
+ '0123456789',
152
+ '9876543210',
153
+ 'qwertyuiopasdfghjklzxcvbnm',
154
+ ];
155
+ for (const seq of sequences) {
156
+ for (let i = 0; i <= seq.length - 8; i++) {
157
+ if (s.includes(seq.slice(i, i + 8))) return true;
158
+ }
159
+ }
160
+ return false;
161
+ }
162
+
163
+ function stripKnownPrefix(value) {
164
+ return String(value || '')
165
+ .replace(/^(?:AKIA|ASIA)/, '')
166
+ .replace(/^github_pat_/, '')
167
+ .replace(/^gh[pousr]_/, '')
168
+ .replace(/^xox[baprs]-/, '')
169
+ .replace(/^https:\/\/hooks\.slack\.com\/services\//, '')
170
+ .replace(/^sk-proj-/, '')
171
+ .replace(/^sk-ant-/, '')
172
+ .replace(/^sk-/, '')
173
+ .replace(/^AIza/, '')
174
+ .replace(/^[rs]k_live_/, '')
175
+ .replace(/^npm_/, '')
176
+ .replace(/^SG\./, '');
177
+ }
178
+
179
+ function isPlaceholderSecretValue(value, line = '') {
180
+ const raw = String(value || '');
181
+ const lowValue = raw.toLowerCase();
182
+ const lowLine = String(line || '').toLowerCase();
183
+ if (!raw) return true;
184
+ if (PLACEHOLDER.test(lowLine) || PLACEHOLDER.test(lowValue)) return true;
185
+ if (COMMON_PLACEHOLDER_VALUES.has(lowValue)) return true;
186
+ if (/^<[^>]+>$/.test(raw) || /^\$\{?[A-Z0-9_]+\}?$/.test(raw)) return true;
187
+ if (/^(?:x+|\*+|\.+|-+|_+)$/.test(raw)) return true;
188
+ if (PLACEHOLDER_VALUE_FRAGMENTS.some((fragment) => lowValue.includes(fragment) || lowLine.includes(fragment))) return true;
189
+ return false;
190
+ }
191
+
192
+ function secretQuality(value, { known = false, noEntropy = false } = {}, line = '') {
193
+ const raw = String(value || '').trim();
194
+ if (isPlaceholderSecretValue(raw, line)) return { ok: false, reason: 'placeholder' };
195
+ if (noEntropy) return { ok: true, entropy: null, reason: 'private-key-marker' };
196
+
197
+ const core = stripKnownPrefix(raw).replace(/[-_.:/]/g, '');
198
+ const entropy = shannonEntropy(core);
199
+ const minLength = known ? 16 : 18;
200
+ const minEntropy = known ? 3.0 : 3.35;
201
+ const minUnique = known ? 8 : 10;
202
+
203
+ if (core.length < minLength) return { ok: false, entropy, reason: 'too-short' };
204
+ if (uniqueChars(core) < minUnique) return { ok: false, entropy, reason: 'low-variety' };
205
+ if (charClassCount(core) < 2) return { ok: false, entropy, reason: 'single-charset' };
206
+ if (entropy < minEntropy) return { ok: false, entropy, reason: 'low-entropy' };
207
+ if (hasRepeatedChunk(core)) return { ok: false, entropy, reason: 'repeating' };
208
+ if (hasSequentialRun(core)) return { ok: false, entropy, reason: 'sequential' };
209
+ return { ok: true, entropy, reason: 'real-shaped' };
210
+ }
211
+
212
+ function extractSecretValue(rule, match) {
213
+ if (!match) return '';
214
+ if (rule.valueGroup != null) return match[rule.valueGroup] || '';
215
+ return match[1] || match[0] || '';
216
+ }
217
+
218
+ function secretSnippet(rule, value) {
219
+ const safeValue = String(value || '');
220
+ if (rule.id === 'private-key') return 'private key header';
221
+ const prefix = safeValue.slice(0, Math.min(12, safeValue.length));
222
+ return `${prefix}${safeValue.length > prefix.length ? '...' : ''} sha256:${sha256(safeValue).slice(0, 12)}`;
223
+ }
224
+
225
+ function findingFingerprint(finding) {
226
+ const raw = [
227
+ finding.rule,
228
+ finding.cat,
229
+ finding.file || '',
230
+ normalizeSnippet(finding.snippet),
231
+ ].join('|');
232
+ return sha256(raw);
233
+ }
234
+
235
+ function withFingerprint(finding) {
236
+ return { ...finding, fingerprint: findingFingerprint(finding), suppressed: false };
237
+ }
238
+
239
+ function scanLine(line, rules = RULES) {
240
+ if (ALLOW_MARKER.test(line)) return [];
241
+ const findings = [];
242
+ for (const rule of rules) {
243
+ const m = rule.re.exec(line);
244
+ if (!m) continue;
245
+ if (rule.cat === 'secret') {
246
+ const value = extractSecretValue(rule, m);
247
+ const quality = secretQuality(value, rule, line);
248
+ if (!quality.ok) continue;
249
+ findings.push({
250
+ rule: rule.id,
251
+ sev: rule.sev,
252
+ cat: rule.cat,
253
+ why: rule.why,
254
+ snippet: secretSnippet(rule, value),
255
+ entropy: quality.entropy == null ? null : Number(quality.entropy.toFixed(2)),
256
+ });
257
+ continue;
258
+ }
259
+ if (rule.id === 'email' && EMAIL_SAFE.test(line)) continue;
260
+ findings.push({ rule: rule.id, sev: rule.sev, cat: rule.cat, why: rule.why, snippet: m[0].trim().slice(0, 60) });
261
+ }
262
+ return findings;
263
+ }
264
+
265
+ function scanText(text, rules = RULES) {
266
+ const out = [];
267
+ const lines = String(text || '').split('\n');
268
+ for (let i = 0; i < lines.length; i++) {
269
+ for (const f of scanLine(lines[i], rules)) out.push({ ...f, line: i + 1 });
270
+ }
271
+ return out;
272
+ }
273
+
274
+ // Code-execution rules only make sense in actual code files — `eval(` written in
275
+ // a markdown doc is prose, not a vuln.
276
+ const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.java', '.php', '.sh', '.bash', '.zsh']);
277
+ const DOC_EXTS = new Set(['.md', '.mdx', '.txt', '.patch']);
278
+
279
+ function rulesForFile(file) {
280
+ return CODE_EXTS.has(path.extname(file)) ? RULES : RULES.filter((r) => r.cat !== 'code');
281
+ }
282
+
283
+ function scanFile(file, rules) {
284
+ let text;
285
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
286
+ const ext = path.extname(file);
287
+ return scanText(text, rules || rulesForFile(file)).map((f) => {
288
+ if (DOC_EXTS.has(ext) && f.rule === 'assigned-secret' && f.sev === 'high') {
289
+ return { ...f, sev: 'medium', why: 'credential-looking value in documentation; verify before publishing', file };
290
+ }
291
+ return { ...f, file };
292
+ });
293
+ }
294
+
295
+ // Repo-level hygiene: a sensitive file being tracked at all is a finding,
296
+ // independent of its contents.
297
+ function sensitiveFileFindings(relPaths) {
298
+ const out = [];
299
+ for (const p of relPaths) {
300
+ if (shouldSkipRelPath(p)) continue;
301
+ if (SENSITIVE_FILE_RE.test(p) && !SENSITIVE_FILE_ALLOW.test(p)) {
302
+ out.push({ file: p, line: 0, rule: 'tracked-sensitive-file', sev: 'high', cat: 'privacy', why: 'sensitive file is tracked in git (gitignore + remove it)', snippet: path.basename(p) });
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ function gitTrackedFiles(root, { staged = false } = {}) {
309
+ try {
310
+ const args = staged ? ['diff', '--cached', '--name-only', '--diff-filter=ACM'] : ['ls-files'];
311
+ const out = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
312
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
313
+ } catch {
314
+ return null; // not a git repo / git unavailable
315
+ }
316
+ }
317
+
318
+ function walk(target, out) {
319
+ let stat;
320
+ try { stat = fs.statSync(target); } catch { return out; }
321
+ if (stat.isFile()) { out.push(target); return out; }
322
+ if (stat.isDirectory()) {
323
+ if (SKIP_DIRS.has(path.basename(target))) return out;
324
+ for (const name of fs.readdirSync(target)) {
325
+ if (name === '.git' || SKIP_DIRS.has(name)) continue;
326
+ walk(path.join(target, name), out);
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+
332
+ function scannable(file) {
333
+ const ext = path.extname(file);
334
+ return SCAN_EXTS.has(ext) || path.basename(file).startsWith('.env');
335
+ }
336
+
337
+ function shouldSkipRelPath(relPath) {
338
+ const parts = String(relPath || '').split(/[\\/]+/).filter(Boolean);
339
+ return parts.some((part) => SKIP_DIRS.has(part));
340
+ }
341
+
342
+ // Resolve the set of files to scan + the relative-path list for hygiene checks.
343
+ function resolveTargets({ root = process.cwd(), paths = [], staged = false } = {}) {
344
+ if (paths.length) {
345
+ const files = paths.flatMap((p) => walk(path.resolve(root, p), []))
346
+ .filter(scannable)
347
+ .filter((f) => !shouldSkipRelPath(path.relative(root, f)));
348
+ return { files, relPaths: files.map((f) => path.relative(root, f)) };
349
+ }
350
+ const tracked = gitTrackedFiles(root, { staged });
351
+ if (tracked) {
352
+ const relPaths = tracked.filter((p) => !shouldSkipRelPath(p));
353
+ const files = relPaths.filter(scannable).map((p) => path.join(root, p)).filter((f) => fs.existsSync(f));
354
+ return { files, relPaths };
355
+ }
356
+ const files = walk(root, []).filter(scannable).filter((f) => !shouldSkipRelPath(path.relative(root, f)));
357
+ return { files, relPaths: files.map((f) => path.relative(root, f)) };
358
+ }
359
+
360
+ // Full scan: line-level findings across files + repo hygiene findings.
361
+ // The scanner's own pattern database and its fixtures necessarily contain the
362
+ // very strings it detects — never scan them (a tool that flags itself is noise).
363
+ const SELF_FILES = new Set(['lib/security-scan.js', 'commands/security-review.js', 'test/security-scan.test.js']);
364
+
365
+ function runScan({ root = process.cwd(), paths = [], staged = false } = {}) {
366
+ const { files, relPaths } = resolveTargets({ root, paths, staged });
367
+ const findings = [];
368
+ for (const f of files) {
369
+ if (SELF_FILES.has(path.relative(root, f))) continue;
370
+ for (const finding of scanFile(f)) findings.push({ ...finding, file: path.relative(root, finding.file) });
371
+ }
372
+ findings.push(...sensitiveFileFindings(relPaths.filter((p) => !SELF_FILES.has(p))));
373
+ const withIds = findings.map(withFingerprint);
374
+ const counts = summarizeFindings(withIds);
375
+ return { findings: withIds, counts, scanned: files.length };
376
+ }
377
+
378
+ function summarizeFindings(findings) {
379
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
380
+ for (const f of findings) counts[f.sev] = (counts[f.sev] || 0) + 1;
381
+ return counts;
382
+ }
383
+
384
+ function loadBaseline(root = process.cwd(), baselineFile = DEFAULT_BASELINE) {
385
+ const file = path.isAbsolute(baselineFile) ? baselineFile : path.join(root, baselineFile);
386
+ if (!fs.existsSync(file)) return { file, fingerprints: [] };
387
+ let raw;
388
+ try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); }
389
+ catch (e) { throw new Error(`invalid baseline json: ${path.relative(root, file) || file} (${e.message})`); }
390
+ const values = Array.isArray(raw) ? raw : raw && raw.fingerprints;
391
+ if (!Array.isArray(values)) throw new Error('invalid baseline json: expected {"fingerprints":[...]}');
392
+ return { file, fingerprints: values.filter((v) => typeof v === 'string') };
393
+ }
394
+
395
+ function writeBaseline(root = process.cwd(), findings = [], baselineFile = DEFAULT_BASELINE) {
396
+ const file = path.isAbsolute(baselineFile) ? baselineFile : path.join(root, baselineFile);
397
+ const payload = {
398
+ generated_at: new Date().toISOString(),
399
+ fingerprints: [...new Set(findings.map((f) => f.fingerprint || findingFingerprint(f)))].sort(),
400
+ };
401
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2) + '\n');
402
+ return { file, fingerprints: payload.fingerprints };
403
+ }
404
+
405
+ function applyBaseline(findings, fingerprints = []) {
406
+ const accepted = new Set(fingerprints);
407
+ let suppressed = 0;
408
+ const all = findings.map((finding) => {
409
+ const fp = finding.fingerprint || findingFingerprint(finding);
410
+ const isSuppressed = accepted.has(fp);
411
+ if (isSuppressed) suppressed++;
412
+ return { ...finding, fingerprint: fp, suppressed: isSuppressed };
413
+ });
414
+ const active = all.filter((f) => !f.suppressed);
415
+ return {
416
+ findings: active,
417
+ allFindings: all,
418
+ suppressed,
419
+ counts: summarizeFindings(active),
420
+ countsAll: summarizeFindings(all),
421
+ };
422
+ }
423
+
424
+ function shouldFail(findings, failOn = 'high') {
425
+ const threshold = SEVERITY_RANK[failOn] == null ? SEVERITY_RANK.high : SEVERITY_RANK[failOn];
426
+ return findings.some((f) => SEVERITY_RANK[f.sev] >= threshold);
427
+ }
428
+
429
+ function scoreFindings(findings) {
430
+ const score = { low: 1, medium: 10, high: 50, critical: 100 };
431
+ return findings.reduce((sum, f) => sum + (score[f.sev] || 0), 0);
432
+ }
433
+
434
+ module.exports = {
435
+ scanLine, scanText, scanFile, sensitiveFileFindings, gitTrackedFiles,
436
+ resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES, CODE_RULES,
437
+ DEFAULT_BASELINE, SEVERITIES, SEVERITY_RANK, normalizeSnippet,
438
+ shannonEntropy, secretQuality, findingFingerprint, summarizeFindings,
439
+ loadBaseline, writeBaseline, applyBaseline, shouldFail, scoreFindings,
440
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.27.0",
3
+ "version": "3.29.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",
@@ -48,13 +48,6 @@
48
48
  "atris/features/_templates/",
49
49
  "atris/features/company-brain-sync/",
50
50
  "templates/",
51
- "atris/wiki/index.md",
52
- "atris/wiki/concepts/agent-activation-contract.md",
53
- "atris/wiki/concepts/workspace-initialization-contract.md",
54
- "atris/wiki/sources/atris-labs-2026-05-10.txt",
55
- "atris/wiki/sources/atris-labs-goals-2026-05-10.txt",
56
- "atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt",
57
- "atris/wiki/sources/jack-dorsey-2026-05-10.txt",
58
51
  "atris/policies/",
59
52
  "atris/skills/"
60
53
  ],
@@ -1,81 +0,0 @@
1
- ---
2
- type: concept
3
- slug: agent-activation-contract
4
- title: Agent Activation Contract
5
- sources:
6
- - atris/CLAUDE.md
7
- - commands/activate.js
8
- last_compiled: 2026-06-23
9
- last_verified: 2026-06-23
10
- confidence: 0.9
11
- dependencies:
12
- - atris/wiki/concepts/plan-do-review-loop.md
13
- - atris/wiki/concepts/wiki-as-memory-substrate.md
14
- actionability: "Use this before changing agent boot instructions, `atris activate`, MAP-first behavior, first-message requirements, or durable-memory routing."
15
- created: 2026-05-10
16
- updated: 2026-06-23
17
- tags:
18
- - agent-activation
19
- - protocol
20
- - mapfirst
21
- ---
22
- # Agent Activation Contract
23
-
24
- `atris/CLAUDE.md` is the editor-facing boot contract for agents entering an Atris-managed project. `commands/activate.js` is the runtime context panel agents use after that boot. Together they define what "activation" means before responding or editing.
25
-
26
- ## Boot Sequence
27
-
28
- ```text
29
- first message -> atris atris.md
30
- setup -> PERSONA + activate + wiki STATUS
31
- navigation -> MAP first, then one grep if needed
32
- work -> plan -> do -> review
33
- memory -> wiki or ingest durable knowledge
34
- ```
35
-
36
- The first response path is explicit: run `atris atris.md`, show the welcome visualization, then answer. After that, agents load `atris/PERSONA.md`, run `atris activate`, and treat `atris/wiki/STATUS.md` as the current memory snapshot when it exists.
37
-
38
- ## Runtime Activation
39
-
40
- `atris activate` refuses to run without an `atris/` folder and tells the operator to run `atris init` first. In a valid workspace it creates today's journal if missing, detects workspace state, loads current task context, reads `atris/wiki/STATUS.md`, and prints a narrow activation card.
41
-
42
- The activation card can include:
43
-
44
- - a handoff block from today's journal when `## Handoff` has structured context,
45
- - the last three deduped completions from journal history,
46
- - the current state summary from detected in-progress, backlog, and inbox items,
47
- - a learning count from `atris/learnings.jsonl`,
48
- - wiki health from `atris/wiki/STATUS.md`,
49
- - core file paths for persona, MAP, task view, journal, and wiki status.
50
-
51
- The command ends by pointing the operator back to `atris plan -> do -> review` or `atris log`. It is a read/load surface with one deliberate local side effect: ensuring the current journal file exists so the session is writable.
52
-
53
- ## MAP-First Rule
54
-
55
- Before any file search, read `atris/MAP.md` and search the map for the target keyword. If the map has the location, go directly to that file and line. If not, grep once and update `MAP.md` so the next agent does not repeat the same scan.
56
-
57
- ## Core Truth Surfaces
58
-
59
- - `atris/MAP.md` is navigation.
60
- - `atris/TODO.md` is the visible work queue; current task ownership lives in `atris task`.
61
- - `atris/logs/YYYY/YYYY-MM-DD.md` is the operating journal.
62
- - `atris/wiki/STATUS.md` is the current memory snapshot.
63
- - `atris/wiki/index.md` is the durable knowledge index.
64
- - `atris/atris.md` is the full protocol.
65
-
66
- ## Execution Contract
67
-
68
- Work follows `atris plan -> atris do -> atris review`. Planning requires an ASCII visualization and an approval gate. Execution is step-by-step, with verification as reality changes. Completed tasks should be removed from the active queue; the target state is zero stale tasks.
69
-
70
- Durable project knowledge belongs in `atris/wiki/` or through the local wiki flow. Ephemeral progress belongs in task state and the daily journal, not in ad hoc context.
71
-
72
- `atris/CLAUDE.md` now also carries an explicit agent contract: before edits, claim or create one small task with `atris task` and write the goal/files/done/check contract into task dialogue; after edits, move proof-backed work to Review with `atris task ready <id> --proof "..."`. Proof-ready and human accept are separate gates: an agent's native goal can complete once proof is in Review, but only a human-run `atris task accept <id>` marks the task Done and awards AgentXP. Work that should outlive the chat runs through `atris mission` (start with a verifier, bounded step, `mission tick --verify`, then complete or continue).
73
-
74
- ## Limits
75
-
76
- This page summarizes the activation contract only. Use `atris/atris.md` for the complete protocol, `atris/wiki/concepts/plan-do-review-loop.md` for stage ownership and proof, and `atris/wiki/concepts/wiki-as-memory-substrate.md` for wiki page shape and upkeep behavior. `atris activate` reports state; it does not claim tasks, finish work, or repair broken wiki pages.
77
-
78
- ## Cross-References
79
-
80
- - [[atris/wiki/concepts/plan-do-review-loop.md]] - stage ownership, proof, and review closure
81
- - [[atris/wiki/concepts/wiki-as-memory-substrate.md]] - durable memory routing and wiki verification
@@ -1,73 +0,0 @@
1
- ---
2
- type: concept
3
- slug: workspace-initialization-contract
4
- title: Workspace Initialization Contract
5
- sources:
6
- - commands/init.js
7
- last_compiled: 2026-06-23
8
- last_verified: 2026-06-23
9
- confidence: 0.86
10
- dependencies:
11
- - atris/wiki/systems/atris-cli.md
12
- - atris/wiki/concepts/agent-activation-contract.md
13
- - atris/wiki/concepts/wiki-as-memory-substrate.md
14
- actionability: "Use this before changing `atris init`, workspace scaffolds, generated agent instructions, project detection, or boot hook behavior."
15
- created: 2026-05-10
16
- updated: 2026-06-23
17
- tags:
18
- - initialization
19
- - workspace
20
- - scaffold
21
- ---
22
- # Workspace Initialization Contract
23
-
24
- `commands/init.js` defines the local contract for turning an arbitrary folder into an Atris-managed workspace. It is the repo-level bootstrap path, not the business workspace onboarding path.
25
-
26
- ## Shape
27
-
28
- ```text
29
- guard workspace -> create atris/ -> scaffold memory + teams + features
30
- -> detect project -> inject team context
31
- -> generate agent entry files + hooks
32
- -> copy atris protocol
33
- ```
34
-
35
- The command handles `atris init [--force]`. Help flags print usage without filesystem side effects. Normal execution refuses to run inside an existing `atris/` directory, inside a parent business workspace, or in a folder with `.atris/business.json` unless `--force` is present.
36
-
37
- ## What Init Creates
38
-
39
- - Core workspace: `atris/`, `atris/atris.md`, `atris/PERSONA.md`, `atris/GETTING_STARTED.md`, `atris/MAP.md`, `atris/TODO.md`, `atris/now.md`, `atris/lessons.md`, and dated logs.
40
- - Memory surfaces: wiki scaffold via `ensureWikiScaffold`, feature templates, experiments harness, and `INTUITION.md`.
41
- - Team surfaces: `atris/team/<member>/MEMBER.md` plus `skills/`, `tools/`, and `context/` folders for default members.
42
- - Project profile: `.project-profile.json` from package files, framework hints, directory shape, and default test command.
43
- - Agent entry files: `AGENTS.md`, `.cursorrules`, `.cursor/rules/atris.mdc`, `.claude/commands/atris.md`, `.claude/commands/atris-autopilot.md`, `atris/CLAUDE.md`, `.claude/settings.json`, `.devin/config.local.json`, and root `CLAUDE.md` Atris markers.
44
- - Skills: package `atris/skills/` copied into the workspace and linked into `.claude/skills/` when possible.
45
-
46
- ## Project Detection
47
-
48
- `detectProjectContext()` scans package files first, then framework-specific dependencies, then common structure directories. It detects Node, Python, Ruby, Go, Rust, Java, PHP, Elixir, D, iOS, and markdown-only knowledge bases. The resulting test command is a default hint (`npm test`, `pytest`, `go test ./...`, etc.), not a guarantee that validation is sufficient.
49
-
50
- `injectProjectPatterns()` writes that profile into navigator, executor, and validator specs so the first generated team has a local project shape before any agent starts work.
51
-
52
- ## Generated Agent Contract
53
-
54
- The generated instruction files all point agents back to the same small operating loop:
55
-
56
- - run the Atris boot sequence before first response,
57
- - keep replies short,
58
- - use ASCII visuals for planning,
59
- - check `MAP.md` before code search,
60
- - use `atris task` for ownership and proof,
61
- - treat `TODO.md` as rendered state, not the task database.
62
-
63
- Claude-specific setup also adds a `SessionStart` hook that runs `atris atris.md` when `atris/` exists and a `Stop` hook that points to the autopilot stop hook.
64
-
65
- ## Limits
66
-
67
- `atris init` bootstraps local files. It does not push to cloud, create a shared business owner, or reconcile template updates after custom edits. Use business commands for shared-owner workspaces and `atris update` / sync flows for canonical file refreshes.
68
-
69
- ## Cross-References
70
-
71
- - [[atris/wiki/concepts/agent-activation-contract.md]] - the boot behavior generated agent files point to
72
- - [[atris/wiki/concepts/wiki-as-memory-substrate.md]] - the wiki scaffold and memory contract initialized by this command
73
- - [[atris/wiki/systems/atris-cli.md]] - repo-level command surface that includes `atris init`
@@ -1,31 +0,0 @@
1
- # Atris Wiki Index
2
-
3
- ## Entities
4
-
5
- - [[atris/wiki/people/jack-dorsey.md]] — Block / Twitter founder; AI-native company thesis
6
- - [[atris/wiki/systems/atris-cli.md]] — this project; dev-tool layer of the Atris stack
7
- - [[atris/wiki/systems/atris-business.md]] — sibling product; shared owners with persistent AI computers
8
- - [[atris/wiki/systems/atris-labs.md]] — reference business owner in the Atris fleet; default computer dogfood
9
-
10
- ## Concepts
11
-
12
- - [[atris/wiki/concepts/intent-capability-composition.md]] — the operating loop; roadmap from gaps
13
- - [[atris/wiki/concepts/wiki-as-memory-substrate.md]] — what `atris/wiki/` is and isn't
14
- - [[atris/wiki/concepts/plan-do-review-loop.md]] — core Atris workflow and how local memory fits into it
15
- - [[atris/wiki/concepts/rebased-pack-co-first-loop.md]] — local-only business workspace first loop and proof guardrails
16
- - [[atris/wiki/concepts/atris-labs-goals.md]] — atris-labs north star, 2026 Q2 targets, standing constraints
17
- - [[atris/wiki/concepts/horizon-types.md]] — horizon slug prefix convention; type categories and inference rules
18
- - [[atris/wiki/concepts/verifiable-reward-loop.md]] — reward, scorecards, and why the repo now acts like an RL-style environment
19
- - [[atris/wiki/concepts/recursive-self-improvement-position.md]] — honest capability assessment: the loop is the RSI skeleton on the leverage axis, intelligence axis fixed by the model
20
- - [[atris/wiki/concepts/owner-computer-model.md]] — Owner = User | Business; constrained entity modes, typed computers, and groups as the social/access layer
21
- - [[atris/wiki/concepts/agent-activation-contract.md]] — editor-facing boot contract: first message, MAP-first, setup, and durable-memory routing
22
- - [[atris/wiki/concepts/workspace-initialization-contract.md]] — `atris init` bootstrap contract: guards, scaffolds, team context, generated agent files, and hooks
23
- - [[atris/wiki/concepts/glass-interface-principle.md]] — AI tool design doctrine: make reasoning visible and inspectable, not black-boxed
24
-
25
- ## Briefs
26
-
27
- - [[atris/wiki/briefs/rebased-pack-co-starter-brief.md]] — starter brief for the local-only Rebased Pack Co smoke workspace
28
- - [[atris/wiki/briefs/atris-cli-overview.md]] — summary of CLI, owner/computer model, workspace layers, and why `atris/wiki/` exists
29
- - [[atris/wiki/briefs/atris-labs-workspace-protocol.md]] — atris-labs workspace protocol: on-load sequence, layout, surfaces, north star
30
- - [[atris/wiki/briefs/atrisos-generative-ui-product-surface.md]] — historical AtrisOS generative UI / block surface design note
31
- - [[atris/wiki/briefs/launch-post.md]] — historical launch drafts for the local-first wiki feature
@@ -1,14 +0,0 @@
1
- Source receipt for atris/wiki/systems/atris-labs.md
2
- Compiled: 2026-05-10
3
-
4
- Local evidence checked:
5
- - historical Atris Labs customer workspace docs
6
- - historical Atris Labs context status and pipeline notes
7
- - historical Atris Labs member profile notes
8
- - historical Atris Labs app workspace inventory
9
- - historical Atris Labs cloud workspace binding metadata
10
-
11
- Notes:
12
- - The previous wiki source is missing in this workspace.
13
- - Available status/pipeline files are historical March 2026 artifacts.
14
- - Treat this page as orientation, not a live company operating report.