@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.
- package/bin/cortex-mcp.mjs +6 -0
- package/lib/capture.mjs +6 -1
- package/lib/edge_extract.mjs +7 -2
- package/lib/redact.mjs +32 -0
- package/lib/redact.test.mjs +55 -0
- package/lib/relationships.mjs +133 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|