graphlin 0.1.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/.codex-plugin/plugin.json +29 -0
  3. package/.mcp.json +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +71 -0
  6. package/adapters/README.md +32 -0
  7. package/adapters/claude/hooks.json +10 -0
  8. package/adapters/claude/profile.json +18 -0
  9. package/adapters/codex/hooks.json +9 -0
  10. package/adapters/codex/profile.json +22 -0
  11. package/adapters/kiro/profile.json +8 -0
  12. package/mcp.json +11 -0
  13. package/package.json +114 -0
  14. package/plugin.json +20 -0
  15. package/runtime/collector/index.mjs +23 -0
  16. package/runtime/core/candidates.mjs +300 -0
  17. package/runtime/core/common.mjs +69 -0
  18. package/runtime/core/evidence.mjs +150 -0
  19. package/runtime/core/graph.mjs +398 -0
  20. package/runtime/core/index.mjs +4 -0
  21. package/runtime/core/lexical.mjs +255 -0
  22. package/runtime/core/privacy.mjs +206 -0
  23. package/runtime/core/tool-discovery.mjs +122 -0
  24. package/runtime/daemon/auth.mjs +50 -0
  25. package/runtime/daemon/connection-info.mjs +249 -0
  26. package/runtime/daemon/demo.mjs +195 -0
  27. package/runtime/daemon/diagnostics.mjs +404 -0
  28. package/runtime/daemon/export.mjs +7 -0
  29. package/runtime/daemon/ipc.mjs +28 -0
  30. package/runtime/daemon/lock.mjs +137 -0
  31. package/runtime/daemon/manager.mjs +320 -0
  32. package/runtime/daemon/paths.mjs +108 -0
  33. package/runtime/daemon/persistence.mjs +64 -0
  34. package/runtime/daemon/server.mjs +292 -0
  35. package/runtime/daemon/settings.mjs +103 -0
  36. package/runtime/jev/fixture.mjs +99 -0
  37. package/runtime/jev/index.mjs +784 -0
  38. package/runtime/jev/questions.mjs +268 -0
  39. package/runtime/jev/wire.mjs +152 -0
  40. package/runtime/pipeline.mjs +1071 -0
  41. package/runtime/web/app.js +2596 -0
  42. package/runtime/web/index.html +265 -0
  43. package/runtime/web/layout.js +336 -0
  44. package/runtime/web/sidebar.js +525 -0
  45. package/runtime/web/sketch.js +347 -0
  46. package/runtime/web/style.css +593 -0
  47. package/schemas/bundle.schema.json +243 -0
  48. package/schemas/event.schema.json +108 -0
  49. package/schemas/graph.schema.json +449 -0
  50. package/schemas/patch.schema.json +111 -0
  51. package/scripts/arguments.mjs +37 -0
  52. package/scripts/build-packages.mjs +160 -0
  53. package/scripts/collect.sh +23 -0
  54. package/scripts/collector.mjs +11 -0
  55. package/scripts/control.mjs +80 -0
  56. package/scripts/daemon.mjs +28 -0
  57. package/scripts/graphlin.mjs +112 -0
  58. package/scripts/onboarding.mjs +413 -0
  59. package/scripts/validate-packages.mjs +118 -0
  60. package/skills/graphlin/SKILL.md +103 -0
@@ -0,0 +1,206 @@
1
+ import { CATEGORIES, KINDS, LIMITS, OUTCOMES, freeze, hash, integer, isId, opaque, plain } from './common.mjs';
2
+ import { toolResultPaths } from './tool-discovery.mjs';
3
+
4
+ const DEFAULT_EXCLUDES = Object.freeze([
5
+ '**/.git/**', '**/node_modules/**', '**/.env*', '**/.ssh/**', '**/.aws/**',
6
+ '**/.npmrc*', '**/.pypirc*', '**/.netrc*', '**/_netrc*', '**/.yarnrc*',
7
+ '**/.gitconfig', '**/.dockercfg', '**/.docker/config.json', '**/.kube/config',
8
+ '**/.config/gcloud/**', '**/.config/gh/hosts.yml', '**/.boto', '**/.s3cfg',
9
+ '**/.pgpass', '**/.my.cnf', '**/pip.conf', '**/pip.ini', '**/nuget.config', '**/auth.json',
10
+ '**/*credential*', '**/*secret*', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx',
11
+ '**/id_rsa*', '**/id_ed25519*',
12
+ ]);
13
+ export function createPolicy(options = {}) {
14
+ options = plain(options) ? options : {};
15
+ const excludePaths = [...new Set([...DEFAULT_EXCLUDES, ...(
16
+ Array.isArray(options.excludePaths) ? options.excludePaths.slice(0, 128)
17
+ .filter(p => typeof p === 'string' && p.length > 0 && p.length <= 256 && !/[\0\r\n]/.test(p))
18
+ .map(p => p.replaceAll('\\', '/').replace(/^\.\//, ''))
19
+ .filter(p => !DEFAULT_EXCLUDES.includes(p)).slice(0, 64) : []
20
+ )])].sort();
21
+ const fields = {
22
+ transmitSource: options.transmitSource === true,
23
+ displayEvidence: options.displayEvidence !== false,
24
+ persistEvidence: options.persistEvidence === true,
25
+ excludePaths,
26
+ };
27
+ return freeze({ ...fields, version: `policy-${hash(fields).slice(0, 32)}` });
28
+ }
29
+
30
+ function globRegex(pattern) {
31
+ let result = '';
32
+ for (let i = 0; i < pattern.length; i++) {
33
+ if (pattern[i] === '*' && pattern[i + 1] === '*') {
34
+ i++;
35
+ if (pattern[i + 1] === '/') { result += '(?:.*/)?'; i++; }
36
+ else result += '.*';
37
+ } else if (pattern[i] === '*') result += '[^/]*';
38
+ else if (pattern[i] === '?') result += '[^/]';
39
+ else result += pattern[i].replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
40
+ }
41
+ return new RegExp(`^(?:${result})(?:/.*)?$`, 'i');
42
+ }
43
+
44
+ export function excluded(relativePath, policy) {
45
+ if (typeof relativePath !== 'string' || relativePath.length > 4096 || /[\0\r\n\\]/.test(relativePath)) return true;
46
+ const normalized = relativePath.replace(/^\.\//, '');
47
+ return createPolicy(policy).excludePaths.some(pattern => globRegex(pattern).test(normalized));
48
+ }
49
+
50
+ const SECRET_NAME = /(?:password|passwd|passphrase|pwd|apikey|accesskeyid|(?:access|secret|private|signing|encryption)key|token|secret|auth|credentials?)(?:value)?$/i;
51
+ const ENV_NAME = String.raw`[A-Za-z_][A-Za-z0-9_]*`;
52
+ const ENV_LOOKUP = '(?:' + [
53
+ String.raw`(?:process\.env|import\.meta\.env|Bun\.env)(?:\.${ENV_NAME}|\[\s*["']${ENV_NAME}["']\s*\])`,
54
+ String.raw`os\.environ\[\s*["']${ENV_NAME}["']\s*\]`,
55
+ String.raw`(?:os\.getenv|os\.environ\.get|Deno\.env\.get)\(\s*["']${ENV_NAME}["']\s*\)`,
56
+ ].join('|') + ')';
57
+ const REFERENCE_VALUE = new RegExp(String.raw`^(?:${ENV_LOOKUP}|\$(?:${ENV_NAME}|\{${ENV_NAME}\})|(["'\x60])\$(?:${ENV_NAME}|\{(?:${ENV_NAME}|${ENV_LOOKUP})\})\1|""|''|\x60\x60|null\b|undefined\b)`);
58
+
59
+ function referenceOnly(value) {
60
+ const reference = REFERENCE_VALUE.exec(value);
61
+ if (!reference) return false;
62
+ const tail = value.slice(reference[0].length);
63
+ const trivia = /^(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*)*/.exec(tail)[0];
64
+ const next = tail.slice(trivia.length);
65
+ if (!next || /^[;,})\]]/.test(next)) return true;
66
+ // A newline may terminate an assignment without a semicolon, but a continued
67
+ // expression (including a literal fallback) must never inherit the exemption.
68
+ return /[\r\n]/.test(trivia) &&
69
+ !/^(?:[.?'"\x60+*/%|&^<>=!:([\\-]|(?:in|instanceof|or|and|if|else)\b)/.test(next);
70
+ }
71
+
72
+ function privateBinding(text) {
73
+ // Match the entire binding/property name before normalizing separators.
74
+ // A word boundary immediately before "API_KEY" misses TYPESAFE_API_KEY and
75
+ // camelCase names. The value check deliberately withholds unknown expressions.
76
+ for (const match of text.matchAll(/\b([A-Za-z_$][\w$-]*)['"]?\s*(?:\]\s*)?([:=])\s*/g)) {
77
+ if (!SECRET_NAME.test(match[1].replace(/[_$-]/g, ''))) continue;
78
+ let value = text.slice(match.index + match[0].length);
79
+ // A common TypeScript scalar annotation is not the assigned value.
80
+ if (match[2] === ':') value = value.replace(/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*\s*=\s*/, '');
81
+ if (!referenceOnly(value)) return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ export function privateText(text) {
87
+ if (typeof text !== 'string') return true;
88
+ return /-----BEGIN (?:[A-Z ]*PRIVATE KEY|OPENSSH PRIVATE KEY)-----/i.test(text) ||
89
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/.test(text) ||
90
+ /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{16,})\b/.test(text) ||
91
+ /\b(?:authorization|proxy-authorization)\s*[:=]\s*['"]?(?:bearer|basic)\s+[^\s'"]+/i.test(text) ||
92
+ privateBinding(text) ||
93
+ /[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i.test(text) ||
94
+ /(?:^|[\s"'`(=])(?:\/(?:Users|home|private|etc|root)\/|[A-Za-z]:[\\/])/.test(text);
95
+ }
96
+ export function safeText(text, max = LIMITS.snippetChars) {
97
+ return typeof text === 'string' && text.length > 0 && text.length <= max &&
98
+ !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(text) &&
99
+ !privateText(text);
100
+ }
101
+ export function safeLabel(label) {
102
+ return safeText(label, LIMITS.labelChars) && !/[\r\n<>]/.test(label) && !/^(?:\/|https?:|file:|javascript:)/i.test(label);
103
+ }
104
+
105
+ const EVENTS = Object.freeze({
106
+ SessionStart: 'session.started', UserPromptSubmit: 'turn.prompted',
107
+ PreToolUse: 'tool.requested', PostToolUse: 'tool.succeeded',
108
+ PostToolUseFailure: 'tool.failed', PermissionDenied: 'tool.denied',
109
+ Interrupt: 'tool.interrupted', Stop: 'turn.stopped', SessionEnd: 'session.ended',
110
+ SubagentStart: 'agent.started', SubagentStop: 'agent.stopped',
111
+ AssistantMessage: 'intent.observed', PublicMessage: 'intent.observed',
112
+ });
113
+ const TOOLS = new Map([
114
+ ['read', 'read'], ['read_file', 'read'], ['readfile', 'read'],
115
+ ['write', 'write'], ['write_file', 'write'], ['writefile', 'write'],
116
+ ['edit', 'edit'], ['multiedit', 'edit'], ['apply_patch', 'edit'],
117
+ ['bash', 'shell'], ['exec_command', 'shell'], ['execute_bash', 'shell'],
118
+ ['shell', 'shell'], ['terminal', 'shell'], ['test', 'test'],
119
+ ['grep', 'search'], ['glob', 'search'], ['search', 'search'],
120
+ ['webfetch', 'other'], ['websearch', 'other'],
121
+ ]);
122
+ const boundedString = (value, max = 1024) => typeof value === 'string' && value.length <= max ? value : '';
123
+ const safeIdentity = (value, prefix, ...scope) => isId(value) ? value : opaque(prefix, ...scope, boundedString(value));
124
+ function safeTime(value) {
125
+ const time = typeof value === 'number' ? value
126
+ : typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(value) ? Date.parse(value) : NaN;
127
+ return Number.isFinite(time) && time >= 0 && time <= 8640000000000000 ? new Date(time).toISOString() : '1970-01-01T00:00:00.000Z';
128
+ }
129
+
130
+ export function metadataEvent(event = {}) {
131
+ const projectId = safeIdentity(event.projectId, 'project');
132
+ const sessionId = safeIdentity(event.sessionId, 'session', projectId);
133
+ return freeze({
134
+ schemaVersion: 1,
135
+ id: safeIdentity(event.id, 'event', projectId, sessionId),
136
+ projectId, sessionId,
137
+ agentId: safeIdentity(event.agentId, 'agent', projectId, sessionId),
138
+ toolCallId: event.toolCallId == null ? null : safeIdentity(event.toolCallId, 'call', projectId, sessionId),
139
+ kind: KINDS.includes(event.kind) ? event.kind : 'capture.gap',
140
+ toolCategory: CATEGORIES.includes(event.toolCategory) ? event.toolCategory : 'other',
141
+ outcome: OUTCOMES.includes(event.outcome) ? event.outcome : 'unresolved',
142
+ at: safeTime(event.at),
143
+ sequence: integer(event.sequence) ? event.sequence : 0,
144
+ incomplete: event.incomplete !== false,
145
+ });
146
+ }
147
+
148
+ export function normalizeHostEvent(raw, { host = 'claude', projectId = '', sequence = 0, now = Date.now() } = {}) {
149
+ let incomplete = false;
150
+ if (typeof raw === 'string') {
151
+ if (raw.length > LIMITS.rawChars) { raw = {}; incomplete = true; }
152
+ else { try { raw = JSON.parse(raw); } catch { raw = {}; incomplete = true; } }
153
+ }
154
+ if (!plain(raw)) { raw = {}; incomplete = true; }
155
+ host = ['claude', 'codex', 'kiro'].includes(host) ? host : 'unknown';
156
+ projectId = safeIdentity(projectId, 'project');
157
+ const sessionId = opaque('session', projectId, host, boundedString(raw.session_id ?? raw.sessionId));
158
+ const agentId = opaque('agent', sessionId, boundedString(raw.agent_id ?? raw.agentId) || 'root');
159
+ const call = boundedString(raw.tool_use_id ?? raw.tool_call_id ?? raw.toolCallId);
160
+ const toolCallId = call ? opaque('call', sessionId, agentId, call) : null;
161
+ const sourceKind = boundedString(raw.hook_event_name ?? raw.event_type ?? raw.type ?? raw.kind, 80);
162
+ let kind = EVENTS[sourceKind] ?? (KINDS.includes(sourceKind) ? sourceKind : 'capture.gap');
163
+ // Delta/batch reconstruction is deliberately outside the first adapter's coverage.
164
+ if (raw.delta !== undefined || raw.batch_index !== undefined || raw.batchIndex !== undefined ||
165
+ /(?:delta|batch)/i.test(sourceKind)) kind = 'capture.gap';
166
+ const input = plain(raw.tool_input) ? raw.tool_input : plain(raw.input) ? raw.input : {};
167
+ const result = plain(raw.tool_response) ? raw.tool_response : plain(raw.result) ? raw.result : {};
168
+ const status = raw.outcome ?? result.status;
169
+ if (kind === 'tool.succeeded') {
170
+ if (status === 'denied') kind = 'tool.denied';
171
+ else if (status === 'interrupted' || status === 'cancelled' || result.interrupted === true) kind = 'tool.interrupted';
172
+ else if (status === 'failed' || result.is_error === true || raw.is_error === true ||
173
+ result.success === false || (Number.isInteger(result.exit_code) && result.exit_code !== 0)) kind = 'tool.failed';
174
+ else if (host !== 'claude' && status !== 'succeeded' && result.success !== true && result.exit_code !== 0) kind = 'tool.unresolved';
175
+ }
176
+ if (kind === 'capture.gap') incomplete = true;
177
+ const toolCategory = TOOLS.get(boundedString(raw.tool_name ?? raw.toolName, 100).toLowerCase()) ?? 'other';
178
+ let outcome = 'observed';
179
+ if (kind.startsWith('tool.')) outcome = kind === 'tool.requested' ? 'pending' : kind.slice(5);
180
+ if (kind === 'capture.gap') outcome = 'unresolved';
181
+ const paths = new Set();
182
+ function add(value) {
183
+ if (paths.size < LIMITS.paths && typeof value === 'string' && value.length > 0 && value.length <= 4096 && !/[\0\r\n]/.test(value)) paths.add(value);
184
+ }
185
+ if (kind !== 'capture.gap') {
186
+ // Returned filenames take priority over input directories and are only hints
187
+ // for a fresh EvidenceStore capture, never source evidence from tool output.
188
+ if (kind === 'tool.succeeded') for (const value of toolResultPaths(result, { toolCategory, input })) add(value);
189
+ for (const value of [input.file_path, input.path]) add(value);
190
+ for (const key of ['paths', 'files', 'changed_files']) {
191
+ if (Array.isArray(input[key])) for (const value of input[key].slice(0, LIMITS.paths)) add(plain(value) ? value.path ?? value.file_path : value);
192
+ }
193
+ const patch = boundedString(input.patch ?? input.input, LIMITS.rawChars);
194
+ if (toolCategory === 'edit') for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)$/gm)) add(match[1]);
195
+ }
196
+ const publicText = ['intent.observed', 'turn.prompted'].includes(kind)
197
+ ? boundedString(raw.publicText ?? raw.prompt ?? raw.text ?? raw.message?.text, LIMITS.snippetChars * 4) || null : null;
198
+ const sourceId = boundedString(raw.event_id ?? raw.id ?? raw.message_id);
199
+ const id = opaque('event', projectId, sessionId, agentId, sourceId || toolCallId || sequence, kind, outcome);
200
+ const timestamp = typeof now === 'function' ? now() : now;
201
+ const event = metadataEvent({
202
+ id, projectId, sessionId, agentId, toolCallId, kind, toolCategory, outcome,
203
+ at: timestamp, sequence, incomplete: incomplete || raw.incomplete === true,
204
+ });
205
+ return { event, paths: [...paths], publicText };
206
+ }
@@ -0,0 +1,122 @@
1
+ import path from 'node:path';
2
+ import { LIMITS, plain } from './common.mjs';
3
+
4
+ const MAX_OUTPUT_CHARS = 64 * 1024;
5
+ const MAX_OUTPUT_LINES = 256;
6
+
7
+ const pathValue = value => typeof value === 'string' && value.length > 0 && value.length <= 4096 &&
8
+ !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value);
9
+
10
+ function filePath(value) {
11
+ if (!plain(value)) return value;
12
+ return value.filePath ?? value.file_path ?? value.path ?? value.filename ??
13
+ (typeof value.file === 'string' ? value.file : undefined);
14
+ }
15
+
16
+ // This recognizes a small command grammar; it never executes or expands shell
17
+ // text. A plain grep exclusion only removes listing lines. Other compound
18
+ // commands and output transformations are intentionally omitted.
19
+ function listingCommand(command) {
20
+ if (typeof command !== 'string' || command.length > 8192) return null;
21
+ const filter = /\s*\|\s*(?:(?:\/usr)?\/bin\/)?grep\s+-v\s+(?:"[A-Za-z0-9_./][A-Za-z0-9_./-]*"|'[A-Za-z0-9_./][A-Za-z0-9_./-]*'|[A-Za-z0-9_./][A-Za-z0-9_./-]*)\s*$/.exec(command);
22
+ if (filter) command = command.slice(0, filter.index);
23
+ if (/[\u0000-\u001f\u007f`$\\;&|<>#]/.test(command)) return null;
24
+ command = command.trim();
25
+ const word = /\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))(?=\s|$)/y;
26
+ const words = [];
27
+ let offset = 0;
28
+ while (offset < command.length && words.length < 128) {
29
+ word.lastIndex = offset;
30
+ const match = word.exec(command);
31
+ if (!match) return null;
32
+ words.push(match[1] ?? match[2] ?? match[3]);
33
+ offset = word.lastIndex;
34
+ }
35
+ if (offset !== command.length) return null;
36
+ const program = /^(?:(?:\/usr)?\/bin\/|\/opt\/homebrew\/bin\/)?(ls|find|rg)$/.exec(words.shift() ?? '')?.[1];
37
+ if (!program) return null;
38
+
39
+ if (program === 'ls') {
40
+ const operands = [];
41
+ let options = true;
42
+ for (const value of words) {
43
+ if (options && value === '--') { options = false; continue; }
44
+ if (options && value.startsWith('-')) {
45
+ if (!/^-[1aAF]+$/.test(value) && value !== '--color=never') return null;
46
+ } else {
47
+ if (!value || /[*?[\]{}]/.test(value)) return null;
48
+ operands.push(value);
49
+ }
50
+ }
51
+ // Multiple directory listings have headings and ambiguous relative names.
52
+ if (operands.length > 1) return null;
53
+ const operand = operands[0] ?? '.';
54
+ return { prefix: operand, operand };
55
+ }
56
+
57
+ if (program === 'find') {
58
+ let predicates = false;
59
+ for (let index = 0; index < words.length; index++) {
60
+ const value = words[index];
61
+ if (!predicates && value && !value.startsWith('-')) continue;
62
+ predicates = true;
63
+ if (['-print', '-prune', '-o', '-or', '-a', '-and', '-not', '!'].includes(value)) continue;
64
+ if (['-type', '-name', '-iname', '-path', '-ipath', '-maxdepth', '-mindepth'].includes(value) &&
65
+ typeof words[index + 1] === 'string' && words[index + 1].length > 0) { index++; continue; }
66
+ return null;
67
+ }
68
+ return { prefix: '.' };
69
+ }
70
+
71
+ if (!words.includes('--files')) return null;
72
+ for (let index = 0; index < words.length; index++) {
73
+ const value = words[index];
74
+ if (!value.startsWith('-') || [
75
+ '--files', '--hidden', '--no-ignore', '--no-ignore-vcs', '--follow',
76
+ '-L', '-u', '-uu', '-uuu', '--color=never', '--sort=path',
77
+ ].includes(value)) continue;
78
+ if (['-g', '--glob', '-t', '--type', '-T', '--type-not'].includes(value) &&
79
+ typeof words[index + 1] === 'string' && words[index + 1].length > 0) { index++; continue; }
80
+ if (/^(?:--glob|--type|--type-not)=.+$/.test(value)) continue;
81
+ return null;
82
+ }
83
+ return { prefix: '.' };
84
+ }
85
+
86
+ function looksLikeFile(value) {
87
+ // Whitespace, control sequences, URLs, source lines and prose are not file
88
+ // listings. Paths with spaces remain supported by structured result fields.
89
+ return pathValue(value) && /^[\p{L}\p{N}_./@+,[\]-]+$/u.test(value) &&
90
+ /(?:^|\/)(?:[^/]+\.[\p{L}\p{N}_-]{1,16}|Dockerfile|Containerfile|Makefile|Procfile|Gemfile|Rakefile)$/u.test(value);
91
+ }
92
+
93
+ export function toolResultPaths(result, { toolCategory, input = {} } = {}) {
94
+ if (!plain(result) || result.interrupted === true || result.isImage === true) return [];
95
+ const paths = new Set();
96
+ const add = value => { if (paths.size < LIMITS.paths && pathValue(value)) paths.add(value); };
97
+ for (const value of [result.filePath, result.file_path, result.path]) add(value);
98
+ if (plain(result.file)) add(filePath(result.file));
99
+ for (const key of ['paths', 'files', 'changed_files', 'filenames']) {
100
+ if (Array.isArray(result[key])) {
101
+ for (const value of result[key].slice(0, LIMITS.paths)) add(filePath(value));
102
+ }
103
+ }
104
+ if (toolCategory === 'search' && Array.isArray(result.matches)) {
105
+ for (const match of result.matches.slice(0, LIMITS.paths)) if (plain(match)) add(filePath(match));
106
+ }
107
+ if (toolCategory !== 'shell' || typeof result.stdout !== 'string') return [...paths];
108
+ const command = listingCommand(input.command ?? input.cmd);
109
+ if (!command) return [...paths];
110
+ let output = result.stdout.slice(0, MAX_OUTPUT_CHARS);
111
+ // Never turn a truncated fragment into a filename.
112
+ if (result.stdout.length > MAX_OUTPUT_CHARS) output = output.slice(0, output.lastIndexOf('\n') + 1);
113
+ for (const line of output.split('\n', MAX_OUTPUT_LINES)) {
114
+ const value = line.endsWith('\r') ? line.slice(0, -1) : line;
115
+ if (paths.size >= LIMITS.paths) break;
116
+ // ls echoes an explicit file operand. Other returned names are children of
117
+ // its directory operand, including directories whose names contain dots.
118
+ if (looksLikeFile(value)) add(value === command.operand || command.prefix === '.' || path.isAbsolute(value)
119
+ ? value : path.join(command.prefix, value));
120
+ }
121
+ return [...paths];
122
+ }
@@ -0,0 +1,50 @@
1
+ import { randomBytes, createHash } from 'node:crypto';
2
+
3
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
4
+ const secret = () => randomBytes(32).toString('base64url');
5
+
6
+ export function createAuth({ origin, instanceId, now = Date.now }) {
7
+ const tokens = new Map(), sessions = new Map();
8
+ const cookieName = `graphlin_${instanceId.replaceAll('-', '').slice(0, 16)}`;
9
+ function prune(map) {
10
+ for (const [key, expires] of map) if (expires <= now()) map.delete(key);
11
+ while (map.size >= 16) map.delete(map.keys().next().value);
12
+ }
13
+ return {
14
+ cookieName,
15
+ validRequest(req, { mutation = false } = {}) {
16
+ const hosts = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'host');
17
+ if (hosts.length !== 1 || req.headers.host !== new URL(origin).host) return false;
18
+ if (!['127.0.0.1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress)) return false;
19
+ if (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site'])) return false;
20
+ if (mutation) return req.headers.origin === origin;
21
+ return req.headers.origin === undefined || req.headers.origin === origin;
22
+ },
23
+ launchToken() {
24
+ prune(tokens);
25
+ const token = secret(); tokens.set(digest(token), now() + 60_000);
26
+ return token;
27
+ },
28
+ exchange(token) {
29
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
30
+ const key = digest(token), expires = tokens.get(key);
31
+ tokens.delete(key);
32
+ if (!expires || expires <= now()) return null;
33
+ prune(sessions);
34
+ const session = secret(); sessions.set(digest(session), now() + 8 * 60 * 60 * 1000);
35
+ return `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`;
36
+ },
37
+ authorized(req) {
38
+ const raw = req.headers.cookie;
39
+ if (typeof raw !== 'string' || raw.length > 8192) return false;
40
+ const values = raw.split(';').map((part) => part.trim()).filter((part) => part.startsWith(`${cookieName}=`));
41
+ if (values.length !== 1) return false;
42
+ const token = values[0].slice(cookieName.length + 1);
43
+ if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return false;
44
+ const key = digest(token), expires = sessions.get(key);
45
+ if (!expires || expires <= now()) { sessions.delete(key); return false; }
46
+ return true;
47
+ },
48
+ clear() { tokens.clear(); sessions.clear(); },
49
+ };
50
+ }
@@ -0,0 +1,249 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const PLUGIN_ROOT = fileURLToPath(new URL('../../', import.meta.url));
7
+ const PROFILES = new Set(['claude', 'codex', 'portable']);
8
+ const MAX_PATH_BYTES = 4096;
9
+ const MAX_COMMAND_BYTES = 8192;
10
+ const MAX_MANIFEST_BYTES = 16 * 1024;
11
+ const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
12
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ const version = value => typeof value === 'string' && value.length <= 80 &&
14
+ /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(value);
15
+ const manifest = value => record(value) && value.name === 'graphlin' && version(value.version);
16
+
17
+ function absolute(value) {
18
+ if (typeof value !== 'string' || !path.isAbsolute(value) || CONTROLS.test(value) ||
19
+ Buffer.byteLength(value) > MAX_PATH_BYTES) throw new TypeError('invalid_connection_info');
20
+ return path.resolve(value);
21
+ }
22
+
23
+ // A single POSIX shell argument. No caller value is treated as shell syntax.
24
+ const quote = value => `'${value.replaceAll("'", "'\"'\"'")}'`;
25
+
26
+ async function regularFile(filename) {
27
+ try { return (await lstat(filename)).isFile(); }
28
+ catch { return false; }
29
+ }
30
+
31
+ async function directory(filename) {
32
+ try { return (await lstat(filename)).isDirectory(); }
33
+ catch { return false; }
34
+ }
35
+
36
+ async function readSmall(filename, limit = MAX_MANIFEST_BYTES) {
37
+ let handle;
38
+ try {
39
+ // NONBLOCK avoids waiting on a substituted FIFO; NOFOLLOW rejects a linked
40
+ // metadata file. The handle check and bounded read also cover replacement.
41
+ handle = await open(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
42
+ const info = await handle.stat();
43
+ if (!info.isFile() || info.size > limit) return { status: 'invalid' };
44
+ const bytes = Buffer.alloc(limit + 1);
45
+ let length = 0;
46
+ while (length <= limit) {
47
+ const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null);
48
+ if (!bytesRead) break;
49
+ length += bytesRead;
50
+ }
51
+ return length > limit ? { status: 'invalid' }
52
+ : { status: 'present', text: bytes.subarray(0, length).toString('utf8') };
53
+ } catch (error) {
54
+ return { status: error.code === 'ENOENT' ? 'missing' : 'invalid' };
55
+ } finally { await handle?.close(); }
56
+ }
57
+
58
+ async function json(filename) {
59
+ const result = await readSmall(filename);
60
+ try { return result.status === 'present' ? JSON.parse(result.text) : null; }
61
+ catch { return null; }
62
+ }
63
+
64
+ async function packageMarker(root) {
65
+ const result = await readSmall(path.join(root, '.graphlin-package'), 64);
66
+ if (result.status !== 'present') return result;
67
+ const profile = result.text.trim();
68
+ return PROFILES.has(profile) ? { status: 'present', profile } : { status: 'invalid' };
69
+ }
70
+
71
+ async function sourceCheckout(root, current) {
72
+ const [claude, codex, builder] = await Promise.all([
73
+ json(path.join(root, '.claude-plugin/plugin.json')),
74
+ json(path.join(root, '.codex-plugin/plugin.json')),
75
+ regularFile(path.join(root, 'scripts/build-packages.mjs')),
76
+ ]);
77
+ return builder && manifest(claude) && manifest(codex) &&
78
+ claude.version === current.version && codex.version === current.version &&
79
+ claude.hooks === './adapters/claude/hooks.json' &&
80
+ current.extensions?.['com.openai']?.hooks === './adapters/codex/hooks.json';
81
+ }
82
+
83
+ async function hostPackage(root, host, currentVersion) {
84
+ const [isDirectory, marker, portable, native, mcp, hooks, ...files] = await Promise.all([
85
+ directory(root), packageMarker(root), json(path.join(root, 'plugin.json')),
86
+ json(path.join(root, `.${host}-plugin/plugin.json`)),
87
+ json(path.join(root, '.mcp.json')),
88
+ json(path.join(root, `adapters/${host}/hooks.json`)),
89
+ ...['scripts/control.mjs', 'scripts/collect.sh', 'scripts/collector.mjs', 'runtime/collector/index.mjs']
90
+ .map(file => regularFile(path.join(root, file))),
91
+ ]);
92
+ const expectedRoot = host === 'claude' ? '${CLAUDE_PLUGIN_ROOT}' : '${PLUGIN_ROOT}';
93
+ const server = mcp?.mcpServers?.graphlin;
94
+ return isDirectory && marker.profile === host && manifest(portable) && manifest(native) &&
95
+ portable.version === currentVersion && native.version === currentVersion && files.every(Boolean) &&
96
+ server?.command === 'node' && Array.isArray(server.args) && server.args.length === 1 &&
97
+ server.args[0] === `${expectedRoot}/scripts/control.mjs` &&
98
+ record(hooks?.hooks) && Object.keys(hooks.hooks).length > 0 &&
99
+ (host === 'claude' ? native.hooks === './adapters/claude/hooks.json'
100
+ : native.mcpServers === './.mcp.json' &&
101
+ portable.extensions?.['com.openai']?.hooks === './adapters/codex/hooks.json');
102
+ }
103
+
104
+ async function localMarketplace(root, codexRoot) {
105
+ const value = await json(path.join(root, '.agents/plugins/marketplace.json'));
106
+ if (!record(value) || value.name !== 'graphlin-local' || !Array.isArray(value.plugins)) return false;
107
+ const entries = value.plugins.filter(entry => entry?.name === 'graphlin');
108
+ if (entries.length !== 1) return false;
109
+ const entry = entries[0];
110
+ return entry.source?.source === 'local' && entry.source.path === './graphlin' &&
111
+ path.join(root, 'graphlin') === codexRoot &&
112
+ entry.policy?.installation === 'AVAILABLE' && entry.policy?.authentication === 'ON_INSTALL';
113
+ }
114
+
115
+ export async function inspectInstalledPackages({ dataDir, version: currentVersion }) {
116
+ if (!version(currentVersion)) return { claude: false, codex: false };
117
+ const root = path.join(absolute(dataDir), 'plugins', 'graphlin', currentVersion);
118
+ const [claude, codex] = await Promise.all(['claude', 'codex'].map(host =>
119
+ hostPackage(path.join(root, host, 'graphlin'), host, currentVersion)));
120
+ return { claude, codex };
121
+ }
122
+
123
+ // Discovery reads only fixed package metadata and checks file availability.
124
+ // It never scans the project, reads state/credentials, or executes commands.
125
+ async function discover(pluginRoot, dataDir) {
126
+ const [marker, current, isDirectory] = await Promise.all([
127
+ packageMarker(pluginRoot), json(path.join(pluginRoot, 'plugin.json')), directory(pluginRoot),
128
+ ]);
129
+ if (!isDirectory || !manifest(current) || marker.status === 'invalid') return { available: false };
130
+ if (marker.status === 'missing') {
131
+ if (!await sourceCheckout(pluginRoot, current)) return { available: false };
132
+ // npm installations can be read-only. Generated host packages belong in
133
+ // the same user-owned data directory as the running service, not beside
134
+ // installed code. Versioned paths stay stable across projects and restarts.
135
+ const output = path.join(dataDir, 'plugins', 'graphlin', current.version);
136
+ const claudeRoot = path.join(output, 'claude/graphlin');
137
+ const codexRoot = path.join(output, 'codex/graphlin');
138
+ const [claudeReady, codexReady, marketplaceReady] = await Promise.all([
139
+ hostPackage(claudeRoot, 'claude', current.version),
140
+ hostPackage(codexRoot, 'codex', current.version),
141
+ localMarketplace(path.dirname(codexRoot), codexRoot),
142
+ ]);
143
+ return {
144
+ available: true,
145
+ ...(claudeReady && codexReady && marketplaceReady ? {} : { build: path.join(pluginRoot, 'scripts/build-packages.mjs') }),
146
+ output,
147
+ claude: claudeRoot,
148
+ marketplace: path.join(output, 'codex'),
149
+ };
150
+ }
151
+ const distribution = path.resolve(pluginRoot, '../..');
152
+ const claudeRoot = marker.profile === 'claude' ? pluginRoot : path.join(distribution, 'claude/graphlin');
153
+ const codexRoot = marker.profile === 'codex' ? pluginRoot : path.join(distribution, 'codex/graphlin');
154
+ const marketplaceRoot = path.dirname(codexRoot);
155
+ const [claude, codex, marketplace] = await Promise.all([
156
+ hostPackage(claudeRoot, 'claude', current.version),
157
+ hostPackage(codexRoot, 'codex', current.version),
158
+ localMarketplace(marketplaceRoot, codexRoot),
159
+ ]);
160
+ return {
161
+ available: true, claude: claude ? claudeRoot : null,
162
+ marketplace: codex && marketplace ? marketplaceRoot : null,
163
+ };
164
+ }
165
+
166
+ function format({ projectRoot, dataDir, mode }, found) {
167
+ const result = { projectRoot, mode, instructions: [], notes: [] };
168
+ if (mode === 'demo') {
169
+ result.notes.push('This viewer is in demo mode and uses fixture classifications. Open a live Graphlin viewer for your own project before connecting your work.');
170
+ }
171
+ if (!found.available) {
172
+ result.notes.push('Connection setup is unavailable because this Graphlin source checkout or package could not be verified. Obtain a complete Graphlin distribution.');
173
+ return result;
174
+ }
175
+ const terminal = words => `cd ${quote(projectRoot)} && GRAPHLIN_DATA_DIR=${quote(dataDir)} ${words}`;
176
+ const instruction = (id, title, description, steps) => ({ id, title, description, steps });
177
+ const fits = entries => entries.every(item => item.steps.every(step =>
178
+ Buffer.byteLength(step.command) <= MAX_COMMAND_BYTES));
179
+ function append(entries, host) {
180
+ if (fits(entries)) result.instructions.push(...entries);
181
+ else result.notes.push(`${host} commands are too long to display safely. Use shorter project, package, or data directory paths.`);
182
+ }
183
+
184
+ if (found.build) {
185
+ const build = [instruction('build-packages', 'Build current plugin packages',
186
+ 'Run this prerequisite first. It rebuilds both host profiles in your Graphlin data directory; existing generated packages may be out of date.',
187
+ [{ label: 'Build packages in Terminal', command: terminal(`node ${quote(found.build)} --out ${quote(found.output)}`) }])];
188
+ if (!fits(build)) {
189
+ result.notes.push('The build command is too long to display safely. Use shorter project, package, or data directory paths.');
190
+ return result;
191
+ }
192
+ result.instructions.push(...build);
193
+ }
194
+ const prerequisite = found.build ? 'Complete the build step above first. ' : '';
195
+ if (found.claude) {
196
+ const launch = terminal(`claude --plugin-dir ${quote(found.claude)}`);
197
+ append([
198
+ instruction('claude-new', 'Claude: new session',
199
+ `${prerequisite}Start a new Claude session with this Graphlin plugin loaded.`,
200
+ [{ label: 'Start Claude in Terminal', command: launch }]),
201
+ instruction('claude-resume', 'Claude: resume',
202
+ `${prerequisite}Instead of starting a new session, continue the most recent conversation in this project.`,
203
+ [{ label: 'Resume Claude in Terminal', command: `${launch} --continue` }]),
204
+ ], 'Claude');
205
+ } else {
206
+ result.notes.push('Claude connection is unavailable: matching packaged files were not found. Rebuild or obtain the complete Graphlin distribution, including its Claude profile.');
207
+ }
208
+ if (found.marketplace) {
209
+ const hooks = () => ({
210
+ label: 'Inside Codex: /hooks', command: '/hooks',
211
+ description: 'Inside Codex, review and trust Graphlin hooks; then start your work',
212
+ });
213
+ const launch = terminal(`codex -C ${quote(projectRoot)}`);
214
+ append([
215
+ instruction('codex-setup', 'Codex: install the local plugin',
216
+ `${prerequisite}Run both setup commands before launching. They register this local marketplace and install its Graphlin package in Codex.`,
217
+ [
218
+ { label: 'Register marketplace in Terminal', command: terminal(`codex plugin marketplace add ${quote(found.marketplace)}`) },
219
+ { label: 'Install Graphlin in Terminal', command: terminal(`codex plugin add ${quote('graphlin@graphlin-local')}`) },
220
+ ]),
221
+ instruction('codex-new', 'Codex: new session',
222
+ 'Complete Codex setup above, then start a new session and review the hooks inside Codex.',
223
+ [{ label: 'Start Codex in Terminal', command: launch }, hooks()]),
224
+ instruction('codex-resume', 'Codex: resume',
225
+ 'Complete Codex setup above. Instead of starting a new session, resume the most recent conversation in this project.',
226
+ [{ label: 'Resume Codex in Terminal', command: `${launch} resume --last` }, hooks()]),
227
+ ], 'Codex');
228
+ } else {
229
+ result.notes.push('Codex connection is unavailable: matching packaged files and a valid local marketplace were not found. Rebuild or obtain the complete Graphlin distribution, including its Codex marketplace.');
230
+ }
231
+ result.notes.push('Choose either a new session or resume. Keep the Graphlin viewer running while you work.');
232
+ result.notes.push('Package discovery does not confirm that host hooks are active. Follow the host prompts to load and trust the plugin.');
233
+ return result;
234
+ }
235
+
236
+ /**
237
+ * Read-only connection instructions for an authenticated local endpoint.
238
+ * All inputs must come from the daemon's trusted startup context, not a request
239
+ * body/query. projectRoot and dataDir are already canonicalized by projectPaths.
240
+ * No environment, graph, launch URL, or credential is copied into the result.
241
+ */
242
+ export async function createConnectionInfo({
243
+ projectRoot, dataDir, mode = 'live', pluginRoot = PLUGIN_ROOT,
244
+ } = {}) {
245
+ if (!['live', 'demo'].includes(mode)) throw new TypeError('invalid_connection_info');
246
+ const context = { projectRoot: absolute(projectRoot), dataDir: absolute(dataDir), mode };
247
+ const root = absolute(pluginRoot);
248
+ return format(context, await discover(root, context.dataDir));
249
+ }