@theronap/cortex-mcp 0.9.13 → 0.9.15

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.
@@ -96,6 +96,12 @@ if (cmd === 'setup') {
96
96
  await runResolve()
97
97
  const { closeFetch } = await import('../lib/diagnose.mjs')
98
98
  await closeFetch()
99
+ } else if (cmd === 'relationships') {
100
+ // Tier-3 relationship inference: judge server-flagged nodes' prose-implied relationships via `claude -p`.
101
+ const { runRelationships } = await import('../lib/relationships.mjs')
102
+ await runRelationships()
103
+ const { closeFetch } = await import('../lib/diagnose.mjs')
104
+ await closeFetch()
99
105
  } else if (cmd === 'ingest-folder') {
100
106
  const { runIngestFolder } = await import('../lib/ingest_folder.mjs')
101
107
  await runIngestFolder(rest)
package/lib/capture.mjs CHANGED
@@ -5,6 +5,7 @@ import { createHash } from 'crypto'
5
5
  import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
6
6
  import { extractSession } from './edge_extract.mjs'
7
7
  import { extractTyped } from './extract_typed.mjs'
8
+ import { redactSecrets } from './redact.mjs'
8
9
 
9
10
  // Fetch the node-type registry (global + this org's custom types) — the catalog the typed extractor
10
11
  // produces against. Best-effort: null on any failure (typed extraction is then skipped, never blocks capture).
@@ -92,7 +93,11 @@ export async function runCapture() {
92
93
  let hook = {}
93
94
  try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
94
95
  const repo = projectFrom(hook.cwd)
95
- const transcript = hook.transcript_path ? transcriptTail(hook.transcript_path) : ''
96
+ // Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
97
+ // feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
98
+ // transcript (pasted key, a tool that read a config file, a token inlined in a hook cmd)
99
+ // must never be transmitted or stored. See redact.mjs.
100
+ const transcript = hook.transcript_path ? redactSecrets(transcriptTail(hook.transcript_path)) : ''
96
101
 
97
102
  if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
98
103
 
@@ -6,7 +6,7 @@ import { spawnSync } from 'child_process'
6
6
  // both session people AND the entity catalogue down; extracting here keeps the engine on `claude -p`
7
7
  // (the standing preference) and removes that dependency. Node-native (no bun) to match capture.mjs.
8
8
  //
9
- // edgeSafeEnv() strips EVERY ANTHROPIC_* var + CLAUDE_CODE_OAUTH_TOKEN before spawning, so the call
9
+ // edgeSafeEnv() strips EVERY ANTHROPIC_* var (but KEEPS CLAUDE_CODE_OAUTH_TOKEN the headless
10
10
  // runs on the file-based subscription login and a stray ANTHROPIC_BASE_URL (e.g. a keytunnel proxy)
11
11
  // can't reroute "free, local" inference through a billed proxy or ship raw text off-machine. The
12
12
  // spawn also sets CORTEX_SUMMARIZING=1 so the headless session's own Stop hook no-ops (runCapture
@@ -21,7 +21,12 @@ export function edgeSafeEnv(base = process.env, extra = {}) {
21
21
  const env = {}
22
22
  for (const [k, v] of Object.entries(base)) {
23
23
  if (v === undefined) continue
24
- if (k.startsWith('ANTHROPIC_') || k === 'CLAUDE_CODE_OAUTH_TOKEN') continue
24
+ // Strip billed/proxy routing only (ANTHROPIC_API_KEY metered, ANTHROPIC_BASE_URL keytunnel,
25
+ // ANTHROPIC_AUTH_TOKEN/CUSTOM_HEADERS proxy) so edge inference runs on the SUBSCRIPTION, never a
26
+ // metered/proxy path. KEEP CLAUDE_CODE_OAUTH_TOKEN — it IS the headless subscription credential
27
+ // (from `claude setup-token`); stripping it leaves `claude --print` with no usable auth → 401.
28
+ // (The earlier "subscription is file-based" assumption was wrong on macOS — headless needs this token.)
29
+ if (k.startsWith('ANTHROPIC_')) continue
25
30
  env[k] = v
26
31
  }
27
32
  return { ...env, ...extra }
package/lib/redact.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // redact.mjs — strip high-confidence credential patterns from text BEFORE it leaves
2
+ // the machine (capture POSTs a transcript tail to the org). A leaked secret in a
3
+ // transcript — a user pastes an API key, a tool reads a config file holding one, or
4
+ // (the 2026-06-22 finding) a login token sits inlined in a hook command — must never
5
+ // be transmitted or stored. Conservative by design: targets known secret SHAPES so it
6
+ // won't mangle ordinary prose or record/UUID ids. Mirrored server-side in
7
+ // web/lib/engine/redact.ts — keep the two pattern lists in sync.
8
+
9
+ // [pattern, replacement]. Order matters: most specific Anthropic forms first so a
10
+ // login token reads as [REDACTED:anthropic-oauth], not the generic key bucket.
11
+ const PATTERNS = [
12
+ [/sk-ant-oat\d{2}-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-oauth]'],
13
+ [/sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-key]'],
14
+ [/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic]'],
15
+ [/sk-proj-[A-Za-z0-9_-]{20,}/g, '[REDACTED:openai]'],
16
+ [/sk-[A-Za-z0-9]{32,}/g, '[REDACTED:openai]'],
17
+ [/gh[pousr]_[A-Za-z0-9]{36,}/g, '[REDACTED:github]'],
18
+ [/github_pat_[A-Za-z0-9_]{22,}/g, '[REDACTED:github-pat]'],
19
+ [/AKIA[0-9A-Z]{16}/g, '[REDACTED:aws-akid]'],
20
+ [/AIza[0-9A-Za-z_-]{35}/g, '[REDACTED:google]'],
21
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack]'],
22
+ [/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED:jwt]'],
23
+ [/(Bearer\s+)[A-Za-z0-9._-]{20,}/g, '$1[REDACTED]'],
24
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED:private-key]'],
25
+ ]
26
+
27
+ export function redactSecrets(text) {
28
+ if (!text || typeof text !== 'string') return text
29
+ let out = text
30
+ for (const [re, repl] of PATTERNS) out = out.replace(re, repl)
31
+ return out
32
+ }
@@ -0,0 +1,55 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { redactSecrets } from './redact.mjs'
3
+
4
+ // The 2026-06-22 finding: a Claude Code login token (sk-ant-oat01-…) was sitting inlined
5
+ // in a Stop-hook command and could ride a transcript tail to the org. Capture must never
6
+ // transmit a credential. These assert known secret shapes are stripped — and that ordinary
7
+ // prose / record UUIDs are left intact (no false positives that would mangle real content).
8
+
9
+ test('redacts an Anthropic OAuth (Claude Code login) token', () => {
10
+ const s = 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-oyo53qZ8m_WSrYZU8rrpWrxmgtbO1T8hSOhhpicMqeZ4Ixtwzbj0qwb in the hook'
11
+ const out = redactSecrets(s)
12
+ expect(out).not.toContain('sk-ant-oat01-oyo53qZ8m')
13
+ expect(out).toContain('[REDACTED:anthropic-oauth]')
14
+ })
15
+
16
+ test('redacts a range of credential shapes', () => {
17
+ const cases = [
18
+ ['sk-ant-api03-' + 'a'.repeat(40), 'anthropic-key'],
19
+ ['sk-proj-' + 'b'.repeat(40), 'openai'],
20
+ ['sk-' + 'c'.repeat(40), 'openai'],
21
+ ['ghp_' + 'd'.repeat(36), 'github'],
22
+ ['github_pat_' + 'e'.repeat(30), 'github-pat'],
23
+ ['AKIA' + 'ABCDEFGHIJKLMNOP', 'aws-akid'],
24
+ ['AIza' + 'f'.repeat(35), 'google'],
25
+ ['xoxb-' + '1234567890-abcdef', 'slack'],
26
+ ['eyJ' + 'a'.repeat(20) + '.' + 'b'.repeat(20) + '.' + 'c'.repeat(20), 'jwt'],
27
+ ]
28
+ for (const [secret, tag] of cases) {
29
+ const out = redactSecrets(`token: ${secret} end`)
30
+ expect(out).toContain(`[REDACTED:${tag}]`)
31
+ expect(out).not.toContain(secret)
32
+ }
33
+ })
34
+
35
+ test('redacts a Bearer token but keeps the scheme', () => {
36
+ const out = redactSecrets('Authorization: Bearer abcdef0123456789ABCDEFxyz')
37
+ expect(out).toBe('Authorization: Bearer [REDACTED]')
38
+ })
39
+
40
+ test('redacts a PEM private key block', () => {
41
+ const out = redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----')
42
+ expect(out).toContain('[REDACTED:private-key]')
43
+ expect(out).not.toContain('MIIE')
44
+ })
45
+
46
+ test('leaves ordinary prose and record UUIDs untouched', () => {
47
+ const prose = 'Fixed the ingest latency; record 5203eacd-321b-43f7-bae2-4d6e7cae96ab landed in ~0.3s.'
48
+ expect(redactSecrets(prose)).toBe(prose)
49
+ })
50
+
51
+ test('handles empty / non-string input safely', () => {
52
+ expect(redactSecrets('')).toBe('')
53
+ expect(redactSecrets(null)).toBe(null)
54
+ expect(redactSecrets(undefined)).toBe(undefined)
55
+ })
@@ -0,0 +1,133 @@
1
+ import { spawnSync } from 'child_process'
2
+ import { fetchCortex, resolveBase, classify } from './diagnose.mjs'
3
+ import { edgeSafeEnv } from './edge_extract.mjs'
4
+
5
+ // `cortex-mcp relationships` — the JUDGE half of Tier-3 relationship inference (relationship layer slice 3,
6
+ // sibling of `resolve`). The server FLAGS nodes worth judging (GET /api/relationship-candidates) with their
7
+ // accessible evidence + already-known connections; this command reads that evidence LOCALLY via `claude -p`
8
+ // (the engine preference — the server never calls the metered API) and infers prose-implied relationships,
9
+ // then pushes them back (POST /api/relationship-apply) where high/medium-confidence ones become [[links]] on
10
+ // the node's page. Conservative by construction: structural/known links are excluded from the prompt, only
11
+ // relationships actually stated in the evidence are returned, and low confidence is dropped server-side.
12
+
13
+ // Relationship taxonomy (spec §2) — the judge must pick from this enum (business only; no romantic/family).
14
+ const REL_TYPES = [
15
+ 'reports_to', 'manages', 'member_of', 'employed_by', 'works_on', 'works_with', 'owns', 'depends_on',
16
+ 'produces', 'customer_of', 'vendor_to', 'partner_of', 'invested_in', 'governs', 'about', 'cites', 'supersedes',
17
+ ]
18
+
19
+ export async function runRelationships() {
20
+ // recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
21
+ if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
22
+ const token = process.env.CORTEX_TOKEN
23
+ if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
24
+ const base = resolveBase(process.env.CORTEX_URL)
25
+
26
+ // 1. pull the flagged candidate nodes (each with evidence + known connections)
27
+ let candidates = []
28
+ try {
29
+ const res = await fetchCortex(`${base}/api/relationship-candidates`, { headers: { Authorization: `Bearer ${token}` } })
30
+ if (!res.ok) {
31
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
32
+ process.stderr.write(`cortex: relationship-candidates failed — ${d.message}\n`); return
33
+ }
34
+ const j = await res.json().catch(() => ({}))
35
+ candidates = Array.isArray(j.candidates) ? j.candidates : []
36
+ } catch (e) { process.stderr.write(`cortex: relationships fetch failed — ${e.message}\n`); return }
37
+
38
+ if (!candidates.length) { process.stderr.write('cortex: no nodes to judge for relationships\n'); return }
39
+ process.stderr.write(`cortex: inferring relationships for ${candidates.length} node(s) locally…\n`)
40
+
41
+ // 2. judge locally on the subscription — ONE call per node (evidence is per-node, bounded)
42
+ const relationships = []
43
+ let judged = 0
44
+ for (const c of candidates) {
45
+ const inferred = judgeNode(c)
46
+ if (inferred === null) { process.stderr.write('cortex: judge unavailable (is `claude` on PATH?) — skipping\n'); break }
47
+ judged++
48
+ for (const r of inferred) relationships.push(r)
49
+ }
50
+ if (!judged) return
51
+ if (!relationships.length) { process.stderr.write(`cortex: judged ${judged} node(s), no new relationships inferred\n`); return }
52
+
53
+ // 3. apply the judged relationships
54
+ try {
55
+ const res = await fetchCortex(`${base}/api/relationship-apply`, {
56
+ method: 'POST',
57
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
58
+ body: JSON.stringify({ relationships }),
59
+ })
60
+ if (res.ok) {
61
+ const j = await res.json().catch(() => ({}))
62
+ process.stderr.write(`cortex: relationships — wrote ${j.relationships ?? 0} across ${j.subjects ?? 0} node(s), skipped ${j.skipped ?? 0}\n`)
63
+ } else {
64
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
65
+ process.stderr.write(`cortex: relationship-apply failed — ${d.message}\n`)
66
+ }
67
+ } catch (e) { process.stderr.write(`cortex: relationship-apply failed — ${e.message}\n`) }
68
+ }
69
+
70
+ // ONE `claude --print` call infers a node's prose-implied relationships from its evidence. Returns
71
+ // InferredRelationship[] (subjectId/subjectName carried from the candidate), or null if the CLI is
72
+ // unavailable. Conservative: already-known connections are listed as "do NOT re-assert"; only relationships
73
+ // the evidence actually states are returned; the model self-scores confidence and the server drops < 0.5.
74
+ function judgeNode(c) {
75
+ if (!c || typeof c.nodeId !== 'string' || typeof c.nodeName !== 'string') return []
76
+ const evidence = (Array.isArray(c.evidence) ? c.evidence : [])
77
+ .slice(0, 30)
78
+ .map((e, i) => `[${i}] ${e?.title ?? ''}${e?.summary ? ` — ${String(e.summary).slice(0, 280)}` : ''}`)
79
+ .join('\n')
80
+ if (!evidence.trim()) return []
81
+ const known = (Array.isArray(c.knownConnections) ? c.knownConnections : []).filter(Boolean).join(', ') || '(none)'
82
+ const prompt =
83
+ `You are mapping business relationships for ONE person in an organization knowledge graph: "${c.nodeName}".\n` +
84
+ `Below is evidence (record titles + summaries) that mentions them. Identify relationships of "${c.nodeName}" ` +
85
+ `that are EXPLICITLY stated or strongly implied by THIS evidence — to other people, teams, projects, ` +
86
+ `systems, or organizations.\n\n` +
87
+ `Rules:\n` +
88
+ `- Use ONLY these relationship types: ${REL_TYPES.join(', ')}.\n` +
89
+ `- Be CONSERVATIVE: only assert a relationship the evidence actually supports. When unsure, omit it.\n` +
90
+ `- Do NOT re-assert these already-known connections: ${known}.\n` +
91
+ `- "target" is the OTHER endpoint's name exactly as written. Never invent names, numbers, or titles.\n` +
92
+ `- Business relationships only (no romantic/family).\n` +
93
+ `Return ONLY a minified JSON array — one object per relationship: ` +
94
+ `{"rel_type":"<one of the enum>","target":"<name>","confidence":<0..1>,"evidence":"<short quote>"}.\n` +
95
+ `If the evidence supports no clear relationship, return [].\n\n` +
96
+ `EVIDENCE:\n${evidence}`
97
+ try {
98
+ const r = spawnSync(
99
+ 'claude',
100
+ ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
101
+ { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
102
+ )
103
+ if (r.status !== 0 || !r.stdout) return null
104
+ return parseInferred(r.stdout, c)
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ function parseInferred(out, c) {
111
+ const start = out.indexOf('[')
112
+ const end = out.lastIndexOf(']')
113
+ if (start < 0 || end <= start) return []
114
+ let arr
115
+ try { arr = JSON.parse(out.slice(start, end + 1)) } catch { return [] }
116
+ if (!Array.isArray(arr)) return []
117
+ const valid = new Set(REL_TYPES)
118
+ const out2 = []
119
+ for (const d of arr) {
120
+ const relType = typeof d?.rel_type === 'string' ? d.rel_type.trim() : ''
121
+ const target = typeof d?.target === 'string' ? d.target.trim() : ''
122
+ if (!valid.has(relType) || !target) continue
123
+ out2.push({
124
+ subjectId: c.nodeId,
125
+ subjectName: c.nodeName,
126
+ relType,
127
+ target,
128
+ confidence: Number(d.confidence) || 0,
129
+ evidence: typeof d.evidence === 'string' ? d.evidence.slice(0, 240) : undefined,
130
+ })
131
+ }
132
+ return out2
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {