auxilo-mcp 0.7.0 → 0.8.2

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/lib/review.js ADDED
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/review.js — LW-15 `auxilo review` network layer
5
+ *
6
+ * Pure network functions for the contributor self-review queue. Mirrors the
7
+ * `recordConsent` shape in lib/installer.js: X-API-Key auth, injectable
8
+ * fetchImpl for hermetic tests, throws on non-2xx. No stdin, no home-directory
9
+ * access, no filesystem — the binding layer (bin/auxilo-cli.js) supplies the
10
+ * real home, fetch, and prompts.
11
+ *
12
+ * extract -> pending_review -> `auxilo review` (this) -> approve/reject
13
+ *
14
+ * That loop is what makes background extraction safe to re-enable: nothing the
15
+ * contributor extracted goes public until they personally approve it here.
16
+ */
17
+
18
+ const DEFAULT_BASE_URL = 'https://api.auxilo.io';
19
+
20
+ function normBase(baseUrl) {
21
+ return (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
22
+ }
23
+
24
+ /**
25
+ * GET /account/pending — fetch the caller's own pending_review learnings.
26
+ *
27
+ * @param {object} opts
28
+ * @param {string} opts.apiKey
29
+ * @param {string} [opts.baseUrl]
30
+ * @param {number} [opts.limit]
31
+ * @param {number} [opts.offset]
32
+ * @param {Function} [opts.fetchImpl]
33
+ * @returns {Promise<{account_id:string, pending_count:number, limit:number, offset:number, learnings:object[]}>}
34
+ */
35
+ async function fetchPending(opts = {}) {
36
+ const { apiKey } = opts;
37
+ if (!apiKey) throw new Error('fetchPending: apiKey is required');
38
+ const baseUrl = normBase(opts.baseUrl);
39
+ const fetchImpl = opts.fetchImpl || fetch;
40
+
41
+ const qs = new URLSearchParams();
42
+ if (opts.limit != null) qs.set('limit', String(opts.limit));
43
+ if (opts.offset != null) qs.set('offset', String(opts.offset));
44
+ const suffix = qs.toString() ? `?${qs}` : '';
45
+
46
+ const res = await fetchImpl(`${baseUrl}/account/pending${suffix}`, {
47
+ method: 'GET',
48
+ headers: { 'X-API-Key': apiKey },
49
+ });
50
+ let body = {};
51
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
52
+ if (!res.ok) {
53
+ throw new Error(`Fetch pending failed (HTTP ${res.status}): ${body.error || 'unknown error'}`);
54
+ }
55
+ return body;
56
+ }
57
+
58
+ /**
59
+ * POST /account/pending/:id/approve|reject — submit a self-review decision.
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} opts.apiKey
63
+ * @param {string} opts.id
64
+ * @param {'approve'|'reject'} opts.decision
65
+ * @param {string} [opts.reason] only sent on reject
66
+ * @param {string} [opts.baseUrl]
67
+ * @param {Function} [opts.fetchImpl]
68
+ * @returns {Promise<object>} server response body
69
+ */
70
+ async function submitDecision(opts = {}) {
71
+ const { apiKey, id, decision, reason } = opts;
72
+ if (!apiKey) throw new Error('submitDecision: apiKey is required');
73
+ if (!id) throw new Error('submitDecision: id is required');
74
+ if (decision !== 'approve' && decision !== 'reject') {
75
+ throw new Error('submitDecision: decision must be "approve" or "reject"');
76
+ }
77
+ const baseUrl = normBase(opts.baseUrl);
78
+ const fetchImpl = opts.fetchImpl || fetch;
79
+
80
+ const init = {
81
+ method: 'POST',
82
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
83
+ };
84
+ // reject accepts an optional reason; approve takes no body.
85
+ if (decision === 'reject') {
86
+ init.body = JSON.stringify(reason ? { reason } : {});
87
+ }
88
+
89
+ const res = await fetchImpl(`${baseUrl}/account/pending/${encodeURIComponent(id)}/${decision}`, init);
90
+ let body = {};
91
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
92
+ if (!res.ok) {
93
+ throw new Error(`${decision} failed for ${id} (HTTP ${res.status}): ${body.error || 'unknown error'}`);
94
+ }
95
+ return body;
96
+ }
97
+
98
+ /**
99
+ * Render a one-line safety-flag summary for a pending learning, or '' if clean.
100
+ * Shared by the CLI's interactive and --list views so the human always sees the
101
+ * injection / near-duplicate signals before deciding.
102
+ *
103
+ * @param {object} l a projected pending learning
104
+ * @returns {string}
105
+ */
106
+ function formatFlags(l) {
107
+ const parts = [];
108
+ if (Array.isArray(l.injection_flags) && l.injection_flags.length > 0) {
109
+ const ids = l.injection_flags.map((f) => f.pattern_id || f.pattern || 'flag').join(', ');
110
+ parts.push(`injection: ${ids}`);
111
+ }
112
+ if (l.possible_duplicate_of) {
113
+ const sim = l.possible_duplicate_similarity != null ? ` (${l.possible_duplicate_similarity})` : '';
114
+ parts.push(`possible duplicate of ${l.possible_duplicate_of}${sim}`);
115
+ }
116
+ return parts.join(' · ');
117
+ }
118
+
119
+ module.exports = {
120
+ DEFAULT_BASE_URL,
121
+ fetchPending,
122
+ submitDecision,
123
+ formatFlags,
124
+ };
@@ -0,0 +1,388 @@
1
+ /**
2
+ * lib/sensitivity-filter.js
3
+ *
4
+ * Sensitivity filter for agent learning submissions.
5
+ * Scans title, body, task_context, and tags for patterns that
6
+ * indicate private/secret data (keys, tokens, credentials, IPs).
7
+ *
8
+ * Two-mode operation:
9
+ * - Supervised mode: human reviews all learnings (filter is advisory)
10
+ * - Autonomous mode: filter gates submission — if triggered, learning
11
+ * is rejected with details so the agent can redact and resubmit
12
+ *
13
+ * Returns { clean: true } or { clean: false, matches: [...] }
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // ─── Version ─────────────────────────────────────────────────────────────────
19
+ // Bumped when patterns are added or behavior changes. Server rejects extractions
20
+ // from clients older than N-1 (§7.6).
21
+ const SENSITIVITY_FILTER_VERSION = '0.4.0';
22
+
23
+ // ─── Patterns ────────────────────────────────────────────────────────────────
24
+ // Each pattern has a name, regex, and description for the rejection message.
25
+
26
+ const PATTERNS = [
27
+ {
28
+ name: 'private_key',
29
+ regex: /0x[a-fA-F0-9]{64}/g,
30
+ description: 'Blockchain private key (64-char hex)',
31
+ },
32
+ {
33
+ name: 'api_token',
34
+ regex: /(Bearer\s+|sk-|cnwy_k|ghp_|gho_|AKIA)[A-Za-z0-9_\-]{8,}/g,
35
+ description: 'API token or credential (Bearer, sk-, ghp_, AKIA, etc.)',
36
+ },
37
+ {
38
+ name: 'jwt_token',
39
+ regex: /eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+/g,
40
+ description: 'JWT token',
41
+ },
42
+ {
43
+ name: 'internal_ip',
44
+ regex: /(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})/g,
45
+ description: 'Private/internal IP address (RFC 1918)',
46
+ },
47
+ {
48
+ // M-4: Negative lookahead to suppress common placeholder/null false positives.
49
+ // Skips: password=null, =undefined, =<anything>, =***, =xxx, ={...}
50
+ name: 'password_pair',
51
+ regex: /password[=:]\s*(?!null\b|undefined\b|<[^>]+>|\*+|xxx+|\{[^}]+\})\S+/gi,
52
+ description: 'Password value in plaintext',
53
+ },
54
+ {
55
+ name: 'connection_string',
56
+ regex: /(mongodb|postgres|mysql|redis):\/\/[^\s]+@/g,
57
+ description: 'Database connection string with credentials',
58
+ },
59
+ {
60
+ name: 'env_secret',
61
+ regex: /[A-Z_]+=(sk-|ghp_|Bearer |0x[a-fA-F0-9]{64})/g,
62
+ description: 'Environment variable containing secret value',
63
+ },
64
+ {
65
+ // M-3: Word-boundary assertions on aws_secret to prevent matching substrings
66
+ // of larger base64 strings. contextRequired keeps it quiet without a keyword.
67
+ name: 'aws_secret',
68
+ regex: /(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g,
69
+ description: 'Potential AWS secret access key (40-char base64)',
70
+ contextRequired: true,
71
+ },
72
+ // ── New patterns added in SPEC-SF (C-1, C-2) ──────────────────────────────
73
+ {
74
+ // C-1: SSH/RSA/EC/OPENSSH/DSA/ED25519 private key headers
75
+ name: 'ssh_private_key',
76
+ regex: /-----BEGIN\s+(RSA|EC|OPENSSH|DSA|ED25519)?\s*PRIVATE KEY-----/g,
77
+ description: 'SSH/TLS private key header (PEM format)',
78
+ },
79
+ {
80
+ name: 'slack_token',
81
+ regex: /(xoxb-|xoxp-|xoxs-|xoxa-|xoxo-)[A-Za-z0-9\-]{10,}/g,
82
+ description: 'Slack API token (bot, user, or workspace)',
83
+ },
84
+ {
85
+ // C-2: Dedicated Stripe pattern — catches sk_live_, pk_live_, rk_live_, sk_test_, pk_test_
86
+ // that the generic sk- prefix in api_token missed for live keys.
87
+ name: 'stripe_key',
88
+ regex: /(sk_live_|pk_live_|rk_live_|sk_test_|pk_test_)[A-Za-z0-9]{10,}/g,
89
+ description: 'Stripe API key (live or test)',
90
+ },
91
+ {
92
+ name: 'google_api_key',
93
+ regex: /AIza[A-Za-z0-9_\-]{35}/g,
94
+ description: 'Google API key (AIza... format)',
95
+ },
96
+ {
97
+ name: 'npm_token',
98
+ regex: /npm_[A-Za-z0-9]{36}/g,
99
+ description: 'npm access token',
100
+ },
101
+ {
102
+ // pem_block complements ssh_private_key — catches CERTIFICATE, PUBLIC KEY,
103
+ // PRIVATE KEY (generic), and ENCRYPTED PRIVATE KEY blocks.
104
+ name: 'pem_block',
105
+ regex: /-----BEGIN\s+(CERTIFICATE|PUBLIC KEY|PRIVATE KEY|ENCRYPTED PRIVATE KEY)-----/g,
106
+ description: 'PEM block header (certificate or key)',
107
+ },
108
+ // ── P2.1a new patterns (§7.6 — 10 additions) ─────────────────────────────
109
+ {
110
+ name: 'email_address',
111
+ // Linearly-bounded form (D-1 ReDoS fix). The previous
112
+ // /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/ backtracked O(n^2) on a long run
113
+ // of dash/digit chars with no valid TLD, pinning the single-process event
114
+ // loop for minutes. Each quantifier here is explicitly bounded and the
115
+ // domain labels do not overlap with the dot separators, so there is no
116
+ // catastrophic-backtracking path.
117
+ regex: /[a-z0-9._%+\-]{1,64}@[a-z0-9](?:[a-z0-9\-]{0,253}[a-z0-9])?(?:\.[a-z]{2,24})+/gi,
118
+ description: 'Email address',
119
+ },
120
+ {
121
+ // E.164 (+1234567890) + common US formats (123-456-7890, (123) 456-7890)
122
+ name: 'phone_number',
123
+ regex: /(\+\d{1,3}[\s.-]?)?(\(?\d{3}\)?[\s.-]?)?\d{3}[\s.-]?\d{4}(?!\d)/g,
124
+ description: 'Phone number (E.164 or US format)',
125
+ contextRequired: true, // suppress false positives on port numbers, timestamps
126
+ },
127
+ {
128
+ name: 'http_cookie',
129
+ regex: /(Cookie|Set-Cookie):\s*\S+/gi,
130
+ description: 'HTTP Cookie header',
131
+ },
132
+ {
133
+ name: 'authorization_header',
134
+ regex: /Authorization:\s*\S+/gi,
135
+ description: 'HTTP Authorization header',
136
+ },
137
+ {
138
+ name: 'gcp_service_account',
139
+ regex: /"type"\s*:\s*"service_account"/g,
140
+ description: 'GCP service account JSON key',
141
+ },
142
+ {
143
+ name: 'azure_sas_token',
144
+ regex: /sig=[A-Za-z0-9%]{20,}/g,
145
+ description: 'Azure Storage SAS token',
146
+ },
147
+ {
148
+ name: 'github_pat',
149
+ regex: /github_pat_[A-Za-z0-9_]{80,}/g,
150
+ description: 'GitHub fine-grained personal access token',
151
+ },
152
+ {
153
+ name: 'openai_project_key',
154
+ regex: /sk-proj-[A-Za-z0-9]{20,}/g,
155
+ description: 'OpenAI project API key',
156
+ },
157
+ {
158
+ // sk-ant-* — we host the marketplace; leaking these is a reputational nuke
159
+ name: 'anthropic_key',
160
+ regex: /sk-ant-[A-Za-z0-9_\-]{20,}/g,
161
+ description: 'Anthropic API key',
162
+ },
163
+ {
164
+ name: 'discord_bot_token',
165
+ regex: /[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g,
166
+ description: 'Discord bot token',
167
+ },
168
+ ];
169
+
170
+ // ─── M-2: /g flag invariant assertion at module load ────────────────────────
171
+ // Fail loudly at startup if any pattern is accidentally written without /g.
172
+ for (const p of PATTERNS) {
173
+ if (!p.regex.global) throw new Error(`[sensitivity-filter] Pattern "${p.name}" is missing /g flag`);
174
+ }
175
+
176
+ // ─── Context keywords that make ambiguous patterns more suspicious ───────────
177
+ const CONTEXT_KEYWORDS = [
178
+ 'secret', 'key', 'token', 'credential', 'password', 'auth',
179
+ 'aws_secret', 'access_key', 'private', 'apikey', 'api_key',
180
+ ];
181
+
182
+ /**
183
+ * Scan text fields of a learning for sensitive patterns.
184
+ *
185
+ * @param {Object} learning - The learning object to scan
186
+ * @param {string} learning.title
187
+ * @param {string} learning.body
188
+ * @param {string} learning.task_context
189
+ * @param {string[]} learning.tags
190
+ * @returns {{ clean: boolean, matches?: Array<{ pattern: string, field: string, match: string, description: string }> }}
191
+ */
192
+ function scanLearning(learning) {
193
+ const fields = {
194
+ title: learning.title || '',
195
+ body: learning.body || '',
196
+ task_context: learning.task_context || '',
197
+ tags: Array.isArray(learning.tags) ? learning.tags.join(' ') : '',
198
+ };
199
+
200
+ const allText = Object.values(fields).join(' ');
201
+ const hasContextKeyword = CONTEXT_KEYWORDS.some(kw =>
202
+ allText.toLowerCase().includes(kw)
203
+ );
204
+
205
+ const matches = [];
206
+
207
+ for (const pattern of PATTERNS) {
208
+ // Skip context-required patterns unless context keywords are present
209
+ if (pattern.contextRequired && !hasContextKeyword) continue;
210
+
211
+ for (const [fieldName, fieldValue] of Object.entries(fields)) {
212
+ // Reset regex lastIndex for global patterns
213
+ pattern.regex.lastIndex = 0;
214
+ let m;
215
+ while ((m = pattern.regex.exec(fieldValue)) !== null) {
216
+ // For private keys, skip if it's a well-known contract/wallet address (42 chars = address, not 64-char key)
217
+ if (pattern.name === 'private_key') {
218
+ // 0x + 64 hex = 66 chars total — this IS a key
219
+ // 0x + 40 hex = 42 chars total — this is an address, not a key
220
+ if (m[0].length <= 42) continue;
221
+ }
222
+
223
+ matches.push({
224
+ pattern: pattern.name,
225
+ field: fieldName,
226
+ match: redactMatch(m[0]),
227
+ description: pattern.description,
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ if (matches.length === 0) {
234
+ return { clean: true };
235
+ }
236
+
237
+ return { clean: false, matches };
238
+ }
239
+
240
+ /**
241
+ * Redact a matched value for safe display in error messages.
242
+ * H-2 fix: shows first 3 and last 2 characters (5 total visible),
243
+ * replacing the middle with *** to reduce information leakage.
244
+ * For very short values (<=8 chars), shows first 2 chars + ***.
245
+ */
246
+ function redactMatch(value) {
247
+ if (value.length <= 8) return value.substring(0, 2) + '***';
248
+ return value.substring(0, 3) + '***' + value.substring(value.length - 2);
249
+ }
250
+
251
+ /**
252
+ * Generate redaction suggestions for the agent.
253
+ * Maps pattern names to placeholder formats.
254
+ */
255
+ function getRedactionHint(patternName) {
256
+ const hints = {
257
+ private_key: '0x{PRIVATE_KEY}',
258
+ api_token: '{API_TOKEN}',
259
+ jwt_token: '{JWT_TOKEN}',
260
+ internal_ip: '{PRIVATE_IP}',
261
+ password_pair: 'password={REDACTED}',
262
+ connection_string: '{protocol}://{CREDENTIALS}@{host}',
263
+ env_secret: '{ENV_VAR}={REDACTED}',
264
+ aws_secret: '{AWS_SECRET}',
265
+ // New patterns (SPEC-SF)
266
+ ssh_private_key: '-----BEGIN {KEY_TYPE} PRIVATE KEY-----',
267
+ slack_token: '{SLACK_TOKEN}',
268
+ stripe_key: '{STRIPE_KEY}',
269
+ google_api_key: '{GOOGLE_API_KEY}',
270
+ npm_token: '{NPM_TOKEN}',
271
+ pem_block: '-----BEGIN {PEM_TYPE}-----',
272
+ // P2.1a patterns (§7.6)
273
+ email_address: '{EMAIL}',
274
+ phone_number: '{PHONE}',
275
+ http_cookie: 'Cookie: {REDACTED}',
276
+ authorization_header: 'Authorization: {REDACTED}',
277
+ gcp_service_account: '{GCP_SERVICE_ACCOUNT}',
278
+ azure_sas_token: 'sig={REDACTED}',
279
+ github_pat: '{GITHUB_PAT}',
280
+ openai_project_key: '{OPENAI_KEY}',
281
+ anthropic_key: '{ANTHROPIC_KEY}',
282
+ discord_bot_token: '{DISCORD_TOKEN}',
283
+ };
284
+ return hints[patternName] || '{REDACTED}';
285
+ }
286
+
287
+ // ─── Normalization (§7.6) ────────────────────────────────────────────────────
288
+ // URL-decode + strip whitespace inside likely-token windows before regex matching.
289
+ // Applied before pattern scan to defeat simple obfuscation.
290
+
291
+ /**
292
+ * Normalize text for sensitivity scanning.
293
+ * Decodes URL-encoded characters and strips whitespace within likely token sequences.
294
+ * @param {string} text
295
+ * @returns {string}
296
+ */
297
+ function normalizeText(text) {
298
+ if (!text) return '';
299
+ // URL-decode
300
+ let normalized = text;
301
+ try {
302
+ normalized = decodeURIComponent(normalized);
303
+ } catch {
304
+ // If decode fails (malformed %), continue with original
305
+ }
306
+ // Strip whitespace inside sequences that look like they're breaking up tokens:
307
+ // e.g. "sk - ant - api_12" → "sk-ant-api_12"
308
+ normalized = normalized.replace(/([A-Za-z0-9_])\s+([\-_])\s+([A-Za-z0-9])/g, '$1$2$3');
309
+ return normalized;
310
+ }
311
+
312
+ // ─── scanText (P2.1a §7.5) ──────────────────────────────────────────────────
313
+ // Raw text scanner for the runner. Cleaner seam than abusing scanLearning({body}).
314
+
315
+ /**
316
+ * Scan raw text for sensitive patterns.
317
+ * Used by the client-side runner (§7.5) to scrub transcripts before upload.
318
+ *
319
+ * Returns { clean, matches, redacted } where:
320
+ * - clean: true if no matches found
321
+ * - matches: array of { pattern, match, description, offset }
322
+ * - redacted: the text with matched values replaced by redaction hints
323
+ *
324
+ * @param {string} text - Raw text to scan
325
+ * @returns {{ clean: boolean, matches: Array<{pattern: string, match: string, description: string, offset: number}>, redacted: string }}
326
+ */
327
+ function scanText(text) {
328
+ if (!text || typeof text !== 'string') {
329
+ return { clean: true, matches: [], redacted: text || '' };
330
+ }
331
+
332
+ // Normalize before scanning
333
+ const normalized = normalizeText(text);
334
+ const allTextLower = normalized.toLowerCase();
335
+ const hasContextKeyword = CONTEXT_KEYWORDS.some(kw => allTextLower.includes(kw));
336
+
337
+ const matchList = [];
338
+
339
+ for (const pattern of PATTERNS) {
340
+ if (pattern.contextRequired && !hasContextKeyword) continue;
341
+
342
+ pattern.regex.lastIndex = 0;
343
+ let m;
344
+ while ((m = pattern.regex.exec(normalized)) !== null) {
345
+ // Private key length check (same as scanLearning)
346
+ if (pattern.name === 'private_key' && m[0].length <= 42) continue;
347
+
348
+ matchList.push({
349
+ pattern: pattern.name,
350
+ match: redactMatch(m[0]),
351
+ description: pattern.description,
352
+ offset: m.index,
353
+ _rawMatch: m[0], // internal, for redaction — never exposed to API
354
+ });
355
+ }
356
+ }
357
+
358
+ if (matchList.length === 0) {
359
+ return { clean: true, matches: [], redacted: text };
360
+ }
361
+
362
+ // Build redacted text by replacing each match with its redaction hint
363
+ let redacted = text;
364
+ // Sort matches by offset descending to replace from end to start (preserves offsets)
365
+ const sorted = [...matchList].sort((a, b) => b.offset - a.offset);
366
+ for (const m of sorted) {
367
+ const hint = getRedactionHint(m.pattern);
368
+ // Find the raw match in the original text (accounting for normalization drift)
369
+ const idx = redacted.indexOf(m._rawMatch);
370
+ if (idx >= 0) {
371
+ redacted = redacted.slice(0, idx) + hint + redacted.slice(idx + m._rawMatch.length);
372
+ }
373
+ }
374
+
375
+ // Strip internal _rawMatch before returning
376
+ const cleanMatches = matchList.map(({ _rawMatch, ...rest }) => rest);
377
+
378
+ return { clean: false, matches: cleanMatches, redacted };
379
+ }
380
+
381
+ module.exports = {
382
+ scanLearning,
383
+ scanText,
384
+ getRedactionHint,
385
+ normalizeText,
386
+ PATTERNS,
387
+ SENSITIVITY_FILTER_VERSION,
388
+ };