From 4f21435929f3dec0eb1448ca0d99517219ef928b Mon Sep 17 00:00:00 2001 From: AgentRadio Assembler Date: Thu, 27 Aug 2026 01:29:31 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20Issue=20#10=20=E2=80=94=2012?= =?UTF-8?q?=20edge-case=20bugs=20across=20agent-shared-context=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ac.mjs: YAML frontmatter injection (yq escaper) + unicode slug filename collision (data loss) via id-hex suffix; diary append uses readFileSync - agent-sessions.mjs / agent-radio.mjs: reject path-separator names (isValidName), create inbox before registry write (no poisoning), exit 1 on {error} results - agent-radio.mjs: validate mentions against sessions registry - agent-handoff.mjs: error (exit 1) on explicit nonexistent handoff file instead of silent CURRENT.md fallback; YAML-escape task/session - agent-context-validate.mjs: FAIL files without frontmatter (CURRENT.md/ README.md exempt) to fix validator/indexer count mismatch - agent-context-index.mjs: lint gate skips invalid entries when lint.onIndexRegenerate is true - agent-context-init.mjs: scaffold from repo config as single source of truth (search.synonyms + handoff type now propagated) - benchmark.mjs: dynamic scales in markdown (no crash with <3 scales) - agent-context.config.json: add 'handoff' to types enum All fixes verified with before/after reproduction; repo CI-equivalent (validate, index --check, e2e-workflow) passes. --- agent-context.config.json | 3 +- tools/ac.mjs | 51 +++++++++++++++++++++------- tools/agent-context-index.mjs | 31 +++++++++++++++-- tools/agent-context-init.mjs | 58 +++++++++++++++++++++----------- tools/agent-context-validate.mjs | 21 +++++++++--- tools/agent-handoff.mjs | 53 +++++++++++++++++++++++++---- tools/agent-radio.mjs | 47 ++++++++++++++++++++++---- tools/agent-sessions.mjs | 39 +++++++++++++++++---- tools/benchmark.mjs | 32 +++++++++++------- 9 files changed, 265 insertions(+), 70 deletions(-) diff --git a/agent-context.config.json b/agent-context.config.json index 946256d..91e3e1c 100644 --- a/agent-context.config.json +++ b/agent-context.config.json @@ -58,7 +58,8 @@ "todo", "issue", "work-history", - "overall-flow" + "overall-flow", + "handoff" ], "schema": { "required": [ diff --git a/tools/ac.mjs b/tools/ac.mjs index 84b1f5a..09da9fa 100755 --- a/tools/ac.mjs +++ b/tools/ac.mjs @@ -7,7 +7,7 @@ // not tool-call transcripts. "used tool X" is noise; "result was Y, verify at Z" // is signal. refs[] holds verification links. import { spawnSync } from 'node:child_process'; -import { existsSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; const TOOLS = new URL('.', import.meta.url).pathname; @@ -31,6 +31,17 @@ function today() { return new Date().toISOString().slice(0, 10); } function slug(s) { return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'entry'; } +// Issue #10 fix: YAML-safe double-quoted scalar — escape \ and ", flatten CR/LF, +// so user input (title/summary/refs) can never break out of the frontmatter block +// and inject arbitrary YAML fields (e.g. priority override via embedded newline). +function yq(s) { + return '"' + String(s).replace(/[\r\n]+/g, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; +} +// Issue #10 fix: filename-safe component — strip path separators / reserved chars +// so --agent or title can never traverse directories or break the path. +function fnamePart(s) { + return String(s).replace(/[\/\\:*?"<>|]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'x'; +} function resolveRoot() { const cwd = process.cwd(); if (existsSync(join(cwd, 'agent-context.config.json')) || existsSync(join(cwd, 'agent-context'))) @@ -44,7 +55,9 @@ function pluralDir(type) { return map[type] || 'notes'; } function nextId(type) { - return `${type}-${today().replace(/-/g, '')}-${Math.random().toString(16).slice(2, 10)}`; + // Issue #10 fix: pad the random hex to 8 chars so it always matches idPattern + // (^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$) — Math.random().toString(16) can be shorter. + return `${type}-${today().replace(/-/g, '')}-${Math.random().toString(16).slice(2, 10).padEnd(8, '0')}`; } // Generic entry creator — outcome-based template with refs field @@ -55,43 +68,55 @@ function createEntry(opts) { const dir = pluralDir(type); const targetDir = join(root, dir); mkdirSync(targetDir, { recursive: true }); + const id = nextId(type); + // Issue #10 fix: unique filenames. slug() strips non-ASCII, so Korean/CJK/emoji + // titles all collapse to 'entry' — silently overwriting each other (data loss). + // If the slug fell back, or the computed filename already exists, disambiguate + // with the random hex from the entry id. + const idHex = id.split('-').pop(); let fname; + const baseSlug = slug(title); + const uniqSlug = baseSlug === 'entry' ? `entry-${idHex}` : baseSlug; if (type === 'decision') { let n = 1; try { n = readdirSync(targetDir).filter(f => /^\d{4}-/.test(f)).length + 1; } catch {} - fname = `${String(n).padStart(4, '0')}-${slug(title)}.md`; + fname = `${String(n).padStart(4, '0')}-${uniqSlug}.md`; } else if (type === 'diary') { fname = `${today()}.md`; } else { - fname = `${today()}-${slug(title)}--${agent}.md`; + fname = `${today()}-${uniqSlug}--${fnamePart(agent)}.md`; + } + if (type !== 'diary' && existsSync(join(targetDir, fname))) { + // same-day same-title collision — never overwrite an existing entry + fname = fname.replace(/\.md$/, `-${idHex}.md`); } const path = join(targetDir, fname); if (existsSync(path) && type === 'diary') { // diary append-only: append a section instead of failing const prev = readdirSync(targetDir).includes(fname) - ? require('fs').readFileSync(path, 'utf8') : ''; + ? readFileSync(path, 'utf8') : ''; writeFileSync(path, prev + `\n## ${new Date().toTimeString().slice(0,5)} ${agent} — ${title}\n- ${summary}\n`, 'utf8'); } else { const lines = [ ``, '---', - `id: ${nextId(type)}`, + `id: ${id}`, `type: ${type}`, - `title: "${String(title).slice(0, 80)}"`, + `title: ${yq(String(title).slice(0, 80))}`, `tags: [${type}]`, - `feature: ${feature}`, + `feature: ${yq(feature)}`, `level: ""`, `scope: global`, - `agent: ${agent}`, + `agent: ${yq(agent)}`, `created: ${new Date().toISOString()}`, `updated: ${new Date().toISOString()}`, - `status: ${status}`, - `priority: ${priority}`, - `summary: "${String(summary || title).slice(0, 180)}"`, + `status: ${yq(status)}`, + `priority: ${Number.isInteger(priority) && priority >= 1 && priority <= 5 ? priority : 3}`, + `summary: ${yq(String(summary || title).slice(0, 180))}`, ]; if (refs.length) { lines.push('refs:'); - refs.forEach(r => lines.push(` - "${r}"`)); + refs.forEach(r => lines.push(` - ${yq(r)}`)); } lines.push('---', '', body || '## 결과\n\n(도구 호출 로그 아님 — 결론만 기록. 검증은 refs 링크로)\n'); writeFileSync(path, lines.join('\n') + '\n', 'utf8'); diff --git a/tools/agent-context-index.mjs b/tools/agent-context-index.mjs index ca7436a..ca605d0 100644 --- a/tools/agent-context-index.mjs +++ b/tools/agent-context-index.mjs @@ -4,7 +4,7 @@ // 사용: node tools/agent-context-index.mjs [--check] [--init] [--config ] [--dry-run] [--to-sqlite] import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs'; -import { join, relative, dirname } from 'node:path'; +import { join, relative, dirname, basename } from 'node:path'; function parseArgs() { const args = process.argv.slice(2); @@ -170,7 +170,7 @@ if (ARGS.init) { const softLimits = CONFIG.storage?.softLimits || { softLimitChars: 200000, maxEntries: 1000 }; const features = CONFIG.features || {}; const edges = CONFIG.graph?.edges || []; - const types = CONFIG.types || ["note","memo","idea","learning","bug","decision","diary","code-history","todo"]; + const types = CONFIG.types || ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue","work-history","overall-flow","handoff"]; const featureEnum = Object.keys(features).length ? [...Object.keys(features), "global"] : ["global"]; const agents = CONFIG.agents?.allow || ["claude","codex","opencode","human","system"]; @@ -285,11 +285,36 @@ const files = walk(ROOT); const entries = []; let totalChars = 0; +// Issue #10 fix: lint gate — when CONFIG.lint.onIndexRegenerate is true, skip +// entries that would fail validation (missing required fields, id pattern mismatch). +// Also warn on files without frontmatter (except CURRENT.md/README.md) to surface +// the validator/indexer count mismatch. +const EXEMPT_NO_FM = new Set(['CURRENT.md','README.md']); +const required = CONFIG.schema?.required || ["id","type","title","tags","feature","agent","created","updated","status","summary"]; +const idRe = new RegExp(CONFIG.schema?.idPattern || "^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$"); +const gateOn = CONFIG.lint?.onIndexRegenerate === true; +let skippedNoFm = 0, skippedLint = 0; + for (const f of files) { const src = readFileSync(f, 'utf8'); const fm = parseFrontmatter(src); - if (!fm || !fm.id) continue; const rel = relative(ROOT, f).replace(/\\/g, '/'); + const base = basename(f); + if (!fm || !fm.id) { + if (!EXEMPT_NO_FM.has(base)) { + skippedNoFm++; + console.warn(`skip (no frontmatter/id): ${rel}`); + } + continue; + } + if (gateOn) { + const missing = required.filter(r => !(r in fm) || String(fm[r]).trim()===''); + if (missing.length || !idRe.test(String(fm.id))) { + skippedLint++; + console.warn(`skip (lint.onIndexRegenerate): ${rel}${missing.length?` missing ${missing.join(',')}`:''}${!idRe.test(String(fm.id))?' id pattern mismatch':''}`); + continue; + } + } const fmMatch = src.match(/---\s*\n[\s\S]*?\n---\s*\n/); const body = fmMatch ? src.slice(fmMatch.index + fmMatch[0].length) : src; const chars = src.length; diff --git a/tools/agent-context-init.mjs b/tools/agent-context-init.mjs index 710fb79..23366b9 100644 --- a/tools/agent-context-init.mjs +++ b/tools/agent-context-init.mjs @@ -4,7 +4,7 @@ // Issue #3 fix: scaffold into process.cwd() (or --target), NEVER the script's // install location. This makes `npx agent-shared-context init` write into the // user's actual project instead of the npx cache / source clone. -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; @@ -50,24 +50,44 @@ const displayName = await prompt('Display name', name); const featsStr = featuresArg || await prompt('Features (comma-separated)', 'auth,api,ui'); const featuresList = featsStr.split(',').map(s=>s.trim()).filter(Boolean); -const config = { - $schema: "./docs/config.schema.json", - version: 1, - project: { name, displayName, prefix: name.slice(0,2), description: `${displayName} — agent-context enabled` }, - contextRoot, - archiveDir: "archive", - privateMirror: null, - features: Object.fromEntries(featuresList.map(f=> [f, { label: f[0].toUpperCase()+f.slice(1), files: [`src/${f}/index.ts:1`], description: `${f} feature` }])), - graph: { edges: featuresList.length>=2 ? [[featuresList[1], featuresList[0]]] : [] }, - types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue"], - typesFluid: true, - schema: { required: ["id","type","title","tags","feature","agent","created","updated","status","summary"], featureEnum: "auto", idPattern: "^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$", maxSummary: 200, maxPreview: 60 }, - storage: { backend: "json", softLimits: { softLimitChars: 200000, maxEntries: 1000, archiveAfterDays: 90 } }, - lint: { onIndexRegenerate: true, forbidWriteOverwrite: true, requiredKeywords: false }, - agents: { allow: ["claude","codex","opencode","human","system"], default: "system" }, - i18n: { locales: ["ko","en"], defaultLocale: "en" }, - compliance: { law: false, cssContract: false }, -}; +// Issue #10 fix: use the repo's own agent-context.config.json as the scaffold +// template (single source of truth) so new fields (search.synonyms, hierarchy, +// live, newer types, …) automatically reach scaffolded projects instead of +// drifting behind this file's hardcoded object. Falls back to a hardcoded +// minimal config only when the sibling file is missing (e.g. trimmed package). +function loadTemplateConfig() { + const p = new URL('../agent-context.config.json', import.meta.url).pathname; + try { + return JSON.parse(readFileSync(p, 'utf8')); + } catch { + return { + $schema: "./docs/config.schema.json", + version: 1, + project: {}, + contextRoot, + archiveDir: "archive", + privateMirror: null, + features: {}, + graph: { edges: [] }, + types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue","work-history","overall-flow","handoff"], + typesFluid: true, + schema: { required: ["id","type","title","tags","feature","agent","created","updated","status","summary"], featureEnum: "auto", idPattern: "^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$", maxSummary: 200, maxPreview: 60 }, + storage: { backend: "json", softLimits: { softLimitChars: 200000, maxEntries: 1000, archiveAfterDays: 90 } }, + lint: { onIndexRegenerate: true, forbidWriteOverwrite: true, requiredKeywords: false }, + agents: { allow: ["claude","codex","opencode","human","system"], default: "system" }, + i18n: { locales: ["ko","en"], defaultLocale: "en" }, + compliance: { law: false, cssContract: false }, + }; + } +} + +const config = loadTemplateConfig(); +config.version = 1; +config.project = { name, displayName, prefix: name.slice(0,2), description: `${displayName} — agent-context enabled` }; +config.contextRoot = contextRoot; +config.privateMirror = null; +config.features = Object.fromEntries(featuresList.map(f=> [f, { label: f[0].toUpperCase()+f.slice(1), files: [`src/${f}/index.ts:1`], description: `${f} feature` }])); +config.graph = { edges: featuresList.length>=2 ? [[featuresList[1], featuresList[0]]] : [] }; const configPath = join(ROOT, 'agent-context.config.json'); if (!existsSync(configPath)) { diff --git a/tools/agent-context-validate.mjs b/tools/agent-context-validate.mjs index 7c0c4fe..ea1167b 100644 --- a/tools/agent-context-validate.mjs +++ b/tools/agent-context-validate.mjs @@ -2,7 +2,7 @@ // Path: tools/agent-context-validate.mjs // frontmatter lint — agent-context.config.json schema + required 10 검증 import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, basename } from 'node:path'; function resolveConfig(explicit) { const candidates = [ @@ -11,7 +11,7 @@ function resolveConfig(explicit) { new URL('../agent-context/agent-context.config.json', import.meta.url).pathname, ].filter(Boolean); for (const p of candidates) if (existsSync(p)) return JSON.parse(readFileSync(p,'utf8')); - return { contextRoot: 'agent-context', features: {}, types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo"], agents: { allow: ["claude","codex","opencode","human","system"] } }; + return { contextRoot: 'agent-context', features: {}, types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue","work-history","overall-flow","handoff"], agents: { allow: ["claude","codex","opencode","human","system"] } }; } const configArgIndex = process.argv.indexOf('--config'); @@ -72,13 +72,26 @@ function walk(dir, out=[]) { const files = walk(ROOT); let errors = 0; const required = schema.required || ["id","type","title","tags","feature","agent","created","updated","status","summary"]; +// Issue #10 fix: CURRENT.md is a generated pointer file without frontmatter — exempt +// from the no-frontmatter check. README.md already skipped. +const EXEMPT_NO_FM = new Set(['CURRENT.md','README.md']); for (const f of files) { if (f.endsWith('README.md')) continue; const src = readFileSync(f,'utf8'); const fm = parseFrontmatter(src); - if (!fm) continue; - if (!fm.id) continue; + // Issue #10 fix: files without frontmatter are not valid entries — FAIL (except + // CURRENT.md pointer). Previously silently passed, causing validator/indexer + // count mismatch. + if (!fm) { + if (!EXEMPT_NO_FM.has(basename(f))) { + console.error(`FAIL ${f}: no frontmatter`); + errors++; + } + continue; + } + // Issue #10 fix: removed `if (!fm.id) continue;` — missing id now caught by + // required-field check below (id is in required[]). for (const r of required) { if (!(r in fm) || String(fm[r]).trim()==='' ) { console.error(`FAIL ${f}: missing required '${r}'`); errors++; } } diff --git a/tools/agent-handoff.mjs b/tools/agent-handoff.mjs index d312247..2d31271 100644 --- a/tools/agent-handoff.mjs +++ b/tools/agent-handoff.mjs @@ -52,6 +52,19 @@ function readIndex() { try { return JSON.parse(readFileSync(INDEX_PATH, 'utf8')); } catch { return { entries: [] }; } } +// Issue #10 fix: YAML-safe double-quoted scalar (prevents frontmatter injection +// via --task/--session containing quotes or newlines). +function yamlStr(s) { + return String(s).replace(/[\r\n]+/g, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +// Issue #10 fix: names become filenames — reject path separators / traversal +// before any fs write (ENOENT crash / escape outside HANDOFF_DIR). +function isSafeFileName(name) { + return typeof name === 'string' && name.length > 0 && name.length <= 128 + && !/[\/\\\0]/.test(name) && !name.includes('..'); +} + function recentEntries(n = 5) { const idx = readIndex(); return (idx.entries || []).slice(0, n).map(e => ({ @@ -65,6 +78,10 @@ function save(args) { console.error('save requires --session NAME --task "..."'); process.exit(1); } + if (!isSafeFileName(args.session)) { + console.error(`invalid session name '${args.session}' — must not contain '/', '\\', '..' or control characters`); + process.exit(1); + } const date = new Date().toISOString().slice(0, 10); const fname = `${date}--${args.session}.md`; const path = join(HANDOFF_DIR, fname); @@ -75,7 +92,7 @@ function save(args) { id: handoff-${date.replace(/-/g, '')}-${Math.random().toString(36).slice(2, 10)} type: handoff level: diary -title: "Session handoff — ${args.session}" +title: "Session handoff — ${yamlStr(args.session)}" tags: [handoff, session] feature: global scope: global @@ -84,7 +101,7 @@ created: ${new Date().toISOString()} updated: ${new Date().toISOString()} status: done priority: 5 -summary: "${args.task.slice(0, 120)}" +summary: "${yamlStr(args.task.slice(0, 120))}" --- # Session Handoff — ${args.session} @@ -139,15 +156,35 @@ function latestHandoff() { } function load(args) { - let path = args.file ? join(HANDOFF_DIR, args.file) : latestHandoff(); - if (!path || !existsSync(path)) { - // fallback to CURRENT.md pointer + // Issue #10 fix: an EXPLICIT file arg that doesn't exist is an error (probably + // a typo) — erroring beats silently loading the wrong handoff via CURRENT.md. + if (args.file) { + if (!isSafeFileName(args.file)) { + return { error: `invalid handoff file name '${args.file}' — must not contain '/', '\\', '..' or control characters` }; + } + const path = join(HANDOFF_DIR, args.file); + if (!existsSync(path)) { + return { error: `handoff not found: ${args.file}`, hint: "run 'list' to see available handoffs" }; + } + return readHandoff(path); + } + const path = latestHandoff(); + if (!path) { + // bare `load` with no handoffs yet → CURRENT.md pointer is a sane fallback if (existsSync(CURRENT_PATH)) { const cur = readFileSync(CURRENT_PATH, 'utf8'); return { source: 'CURRENT.md', tokens: Math.ceil(cur.length / 4), content: cur }; } return { error: 'no handoff found; run save first' }; } + return readHandoff(path); +} + +function readdirSyncSafe() { + try { return readdirSync(HANDOFF_DIR).filter(f => f.endsWith('.md')).sort().reverse(); } catch { return []; } +} + +function readHandoff(path) { const src = readFileSync(path, 'utf8'); const body = src.replace(/^---[\s\S]*?---\s*\n/, ''); return { @@ -183,7 +220,11 @@ Zero install beyond Node ≥18.`); process.exit(0); } if (ARGS.cmd === 'save') console.log(JSON.stringify(save(ARGS), null, 2)); -else if (ARGS.cmd === 'load') console.log(JSON.stringify(load(ARGS), null, 2)); +else if (ARGS.cmd === 'load') { + const res = load(ARGS); + if (res.error) { console.error(JSON.stringify(res, null, 2)); process.exit(1); } + console.log(JSON.stringify(res, null, 2)); +} else if (ARGS.cmd === 'list') console.log(JSON.stringify(list(), null, 2)); else if (ARGS.cmd === 'current') { if (existsSync(CURRENT_PATH)) console.log(readFileSync(CURRENT_PATH, 'utf8')); diff --git a/tools/agent-radio.mjs b/tools/agent-radio.mjs index 3f57051..cdc9c06 100644 --- a/tools/agent-radio.mjs +++ b/tools/agent-radio.mjs @@ -42,8 +42,31 @@ function ensureDirs() { } ensureDirs(); +// Issue #10 fix: thread names become filenames (radio/threads/.json). +// Reject path separators / traversal / control chars up front — previously +// `create-thread "a/b"` crashed ENOENT. +function isValidName(name) { + return typeof name === 'string' && name.length > 0 && name.length <= 128 + && !/[\/\\\0]/.test(name) && !name.includes('..'); +} +const INVALID_NAME_MSG = n => `invalid thread name '${n}' — must be 1-128 chars, no '/', '\\', '..' or control characters`; + +// Issue #10 fix: validate mentions against sessions registry. Unknown mentions +// are almost certainly typos — the message would never wake anyone. +function validateMentions(mentions) { + const sessionsPath = join(ROOT, 'sessions/sessions.json'); + if (!existsSync(sessionsPath)) return { valid: true }; // no registry → can't check + try { + const sessions = JSON.parse(readFileSync(sessionsPath, 'utf8')).sessions || []; + const known = new Set(sessions.map(s => s.name)); + for (const m of mentions) if (!known.has(m)) return { valid: false, unknown: m, known: [...known] }; + return { valid: true }; + } catch { return { valid: true }; } // corrupt registry → don't block +} + export function createThread(name, participants = []) { // Like AgentRadio create_thread(name, participants) → returns identifier + if (!isValidName(name)) return { error: INVALID_NAME_MSG(name) }; const path = join(THREADS_DIR, `${name}.json`); if (existsSync(path)) return { already: true, name, path: `radio/threads/${name}.json` }; const thread = { @@ -62,6 +85,7 @@ export function createThread(name, participants = []) { export function sendMessage(thread, content, opts = {}) { // Like AgentRadio send_message(thread, content, mentions) → appends and returns immediately whether anyone listening // May @-mention specific agents, triggers passive awareness if watcher is background + if (!isValidName(thread)) return { error: INVALID_NAME_MSG(thread) }; const path = join(THREADS_DIR, `${thread}.json`); if (!existsSync(path)) return { error: `unknown thread '${thread}'. Use create-thread first.` }; const data = JSON.parse(readFileSync(path, 'utf8')); @@ -69,6 +93,11 @@ export function sendMessage(thread, content, opts = {}) { // Also handle @mentions in content like "@claude" or "@codex" const atMentions = [...content.matchAll(/@([a-z0-9_-]+)/gi)].map(m => m[1]); const allMentions = [...new Set([...mentions, ...atMentions])]; + // Issue #10 fix: validate mentions against sessions registry + const mentionCheck = validateMentions(allMentions); + if (!mentionCheck.valid) { + return { error: `unknown mention '@${mentionCheck.unknown}' — not in sessions registry`, known: mentionCheck.known }; + } const msg = { from: opts.from || process.env.AGENT_SESSION || 'local', content, @@ -162,6 +191,12 @@ export function fivePhaseProtocol() { // CLI if (import.meta.url === `file://${process.argv[1]}`) { const cmd = process.argv[2]; + // Issue #10 fix: helper to print result and exit 1 on {error} — previously + // unknown-thread/unknown-mention returned {error} with exit 0, invisible to CI. + function print(res) { + if (res && res.error) { console.error(JSON.stringify(res, null, 2)); process.exit(1); } + console.log(JSON.stringify(res, null, 2)); + } if (!cmd || cmd === '--help' || cmd === '-h') { console.log(`Usage: node tools/agent-radio.mjs [args] Commands (AgentRadio passive awareness, Apache 2.0, file-based): @@ -185,7 +220,7 @@ Examples: const name = process.argv[3]; const participants = process.argv.slice(4); if (!name) { console.error('create-thread requires '); process.exit(1); } - console.log(JSON.stringify(createThread(name, participants), null, 2)); + print(createThread(name, participants)); } else if (cmd === 'send') { const thread = process.argv[3]; const content = process.argv[4]; @@ -194,21 +229,21 @@ Examples: const mentions = mIdx !== -1 ? process.argv.slice(mIdx+1).filter(a => a.startsWith('@')).map(a => a.slice(1)) : []; const fromIdx = process.argv.indexOf('--from'); const from = fromIdx !== -1 ? process.argv[fromIdx+1] : undefined; - console.log(JSON.stringify(sendMessage(thread, content, { mentions, from }), null, 2)); + print(sendMessage(thread, content, { mentions, from })); } else if (cmd === 'wait') { const agent = process.argv[3]; if (!agent) { console.error('wait requires '); process.exit(1); } const tIdx = process.argv.indexOf('--timeout'); const timeout = tIdx !== -1 ? Number(process.argv[tIdx+1]) : 30000; - console.log(JSON.stringify(waitForMention(agent, timeout), null, 2)); + print(waitForMention(agent, timeout)); } else if (cmd === 'list-threads') { - console.log(JSON.stringify(listThreads(), null, 2)); + print(listThreads()); } else if (cmd === 'read-thread') { const name = process.argv[3]; if (!name) { console.error('read-thread requires '); process.exit(1); } - console.log(JSON.stringify(readThread(name), null, 2)); + print(readThread(name)); } else if (cmd === 'protocol') { - console.log(JSON.stringify(fivePhaseProtocol(), null, 2)); + print(fivePhaseProtocol()); } else { console.error(`unknown command ${cmd}`); process.exit(1); } diff --git a/tools/agent-sessions.mjs b/tools/agent-sessions.mjs index f241ff4..8f1b83e 100644 --- a/tools/agent-sessions.mjs +++ b/tools/agent-sessions.mjs @@ -16,6 +16,13 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; +// Issue #10 fix: session names are used as filenames (sessions/inbox/.jsonl) +// and as registry keys. Reject anything that could traverse directories or break +// the path. Allowed: [A-Za-z0-9._@-], must not be empty or contain '..'. +function validName(n) { + return typeof n === 'string' && /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(n) && !n.includes('..'); +} + function resolveConfig() { const candidates = [ new URL('../agent-context.config.json', import.meta.url).pathname, @@ -50,6 +57,16 @@ function readSessionsConfig() { try { return JSON.parse(readFileSync(SESSIONS_CONFIG_PATH, 'utf8')); } catch { return { crossSessionInbound: 'accept', isolatePeerMachines: false, rateLimit: { maxPerSender: 10, dedupWindowMs: 5000, maxInbox: 50 } }; } } +// Issue #10 fix: session names become filenames (inbox/.jsonl). Reject path +// separators / traversal / control chars up front — previously `register "a/b"` +// wrote the registry entry, then crashed ENOENT on the inbox write, permanently +// poisoning the name (re-register → already:true, send → crash). +function isValidName(name) { + return typeof name === 'string' && name.length > 0 && name.length <= 128 + && !/[\/\\\0]/.test(name) && !name.includes('..'); +} +const INVALID_NAME_MSG = n => `invalid session name '${n}' — must be 1-128 chars, no '/', '\\', '..' or control characters`; + export function listAgents() { const sessions = readSessions(); const cfg = readSessionsConfig(); @@ -60,6 +77,7 @@ export function listAgents() { } export function registerSession(name, opts = {}) { + if (!isValidName(name)) return { error: INVALID_NAME_MSG(name) }; const sessions = readSessions(); if (sessions.find(s => s.name === name)) return { already: true, name }; const entry = { @@ -82,6 +100,7 @@ export function registerSession(name, opts = {}) { export function sendMessage(target, content, opts = {}) { // Like Claude's SendMessage: plain text only, never history/files + if (!isValidName(target)) return { error: INVALID_NAME_MSG(target) }; if (typeof content !== 'string') content = String(content); if (content.length > 4000) content = content.slice(0, 4000) + '...[truncated]'; // plainTextOnly: strip any slash command that would execute, like /compact @@ -150,6 +169,7 @@ export function sendMessage(target, content, opts = {}) { } export function readInbox(session) { + if (!isValidName(session)) return { error: INVALID_NAME_MSG(session) }; const inboxPath = join(INBOX_DIR, `${session}.jsonl`); if (!existsSync(inboxPath)) return []; try { @@ -162,6 +182,7 @@ export function readInbox(session) { export function waitForMention(session, timeout = 30000) { // Like AgentRadio wait_for_mention / Claude wait: blocks until mention arrives, returns with full thread snapshot // File-based poll every 500ms until timeout + if (!isValidName(session)) return { error: INVALID_NAME_MSG(session) }; const start = Date.now(); const poll = () => { const inbox = readInbox(session); @@ -195,6 +216,12 @@ function readThreadsSnapshot() { // CLI if (import.meta.url === `file://${process.argv[1]}`) { const cmd = process.argv[2]; + // Issue #10 fix: helper to print result and exit 1 on {error} — previously + // unknown-target/unknown-thread returned {error} with exit 0, invisible to CI. + function print(res) { + if (res && res.error) { console.error(JSON.stringify(res, null, 2)); process.exit(1); } + console.log(JSON.stringify(res, null, 2)); + } if (!cmd || cmd === '--help' || cmd === '-h') { console.log(`Usage: node tools/agent-sessions.mjs [args] Commands (Claude cross-session reverse-engineered, file-based): @@ -213,30 +240,30 @@ Examples: process.exit(0); } if (cmd === 'list') { - console.log(JSON.stringify(listAgents(), null, 2)); + print(listAgents()); } else if (cmd === 'register') { const name = process.argv[3]; if (!name) { console.error('register requires '); process.exit(1); } - console.log(JSON.stringify(registerSession(name), null, 2)); + print(registerSession(name)); } else if (cmd === 'send') { const target = process.argv[3]; const msg = process.argv[4]; if (!target || !msg) { console.error('send requires '); process.exit(1); } const fromIdx = process.argv.indexOf('--from'); const from = fromIdx !== -1 ? process.argv[fromIdx+1] : undefined; - console.log(JSON.stringify(sendMessage(target, msg, { from }), null, 2)); + print(sendMessage(target, msg, { from })); } else if (cmd === 'inbox') { const sess = process.argv[3]; if (!sess) { console.error('inbox requires '); process.exit(1); } - console.log(JSON.stringify(readInbox(sess), null, 2)); + print(readInbox(sess)); } else if (cmd === 'wait') { const sess = process.argv[3]; if (!sess) { console.error('wait requires '); process.exit(1); } const tIdx = process.argv.indexOf('--timeout'); const timeout = tIdx !== -1 ? Number(process.argv[tIdx+1]) : 30000; - console.log(JSON.stringify(waitForMention(sess, timeout), null, 2)); + print(waitForMention(sess, timeout)); } else if (cmd === 'config') { - console.log(JSON.stringify(readSessionsConfig(), null, 2)); + print(readSessionsConfig()); } else { console.error(`unknown command ${cmd}`); process.exit(1); } diff --git a/tools/benchmark.mjs b/tools/benchmark.mjs index 63420ee..0dd6df8 100644 --- a/tools/benchmark.mjs +++ b/tools/benchmark.mjs @@ -180,15 +180,23 @@ function benchmark(scales=[5,50,500], queriesPerScale=20) { } function printMarkdown(results) { + // Issue #10 fix: no hardcoded 3-scale indexing — works with any scales list + // (--scale 5 --queries 2 used to crash on results[1].scale). + if (!results.length) throw new Error('benchmark produced no results (empty --scale list?)'); + const scalesStr = results.map(r => r.scale).join(' + '); + const mid = results[Math.floor(results.length / 2)]; + const interp = results.map(r => + `- **${r.scale} entries**: saving **${r.avgSaving}**, hitRate **${r.hitRate}**, avg latency ${r.avgLatency} — full \`~${r.fullTokens}tok\` vs top \`~${r.avgTopTokens}tok\`` + ).join('\n'); let md = ` # Benchmark — Hierarchical Lightweight Search vs Full Read -> **Objective, public-standard-like, critical, reproducible** — synthetic 5/50/500 scale, 20 queries, tokens = chars/4, hit = query tokens in title/tags/summary, latency = search vs est. full Read, no LLM. +> **Objective, public-standard-like, critical, reproducible** — synthetic ${scalesStr} scale, queries per scale as run, tokens = chars/4, hit = query tokens in title/tags/summary, latency = search vs est. full Read, no LLM. ## Method (close to public standard) -- **Dataset**: Synthetic ${results[0].scale} + ${results[1].scale} + ${results[2].scale} entries, distribution 40% post-it (15tok) 30% memo (50tok) 15% diary (200tok) 10% bookshelf (1000tok) 5% library (5000tok) — like cache workloads, not cherry-picked. -- **Queries**: 20 mixed — single word (\`auth\`), phrase (\`auth jwt race\`), overall (\`overall flow\`), level-specific (\`post-it\`), work-history/idea/overall-flow fluid types. +- **Dataset**: Synthetic ${scalesStr} entries, distribution 40% post-it (15tok) 30% memo (50tok) 15% diary (200tok) 10% bookshelf (1000tok) 5% library (5000tok) — like cache workloads, not cherry-picked. +- **Queries**: mixed — single word (\`auth\`), phrase (\`auth jwt race\`), overall (\`overall flow\`), level-specific (\`post-it\`), work-history/idea/overall-flow fluid types. - **Metrics**: \`tokens top\` (hierarchical top 3), \`tokens full\` (all entries), \`saving\` (\`1 - top/full\`), \`hitRate\` (at least 1 hit), \`latency\` (ms, performance.now), \`tokensPerHit\`. - **Lightweight AI**: rule-based, 0 LLM calls, 0 tokens, hierarchical \`${LEVELS.join('→')}\` — like cache→HBM→DRAM→SSD, small→large, miss expands. - **Baseline**: Full Read = sum all levels tokens (like \`Glob+Read *.md\`). @@ -197,21 +205,21 @@ function printMarkdown(results) { ## Results (run: \`node tools/benchmark.mjs\`) | scale | full tokens | avg top 3 tokens | avg saving | hitRate | avg latency (search) | est. full Read latency | tokens/hit | -|---|---|---|---|---|---|---|` + results.map(r=>` +|---|---|---|---|---|---|---|---|` + results.map(r=>` | ${r.scale} | ${r.fullTokens} | ${r.avgTopTokens} | ${r.avgSaving} | ${r.hitRate} | ${r.avgLatency} | ${r.fullLatencyEst} | ${r.tokensPerHit} |`).join(''); md += ` ### Interpretation (critical, not hype) -- **5 entries** (current repo): \`full ~${results[0].fullTokens}tok\` vs \`top ~${results[0].avgTopTokens}tok\` → saving **${results[0].avgSaving}** but absolute saving small — overhead of hierarchy not yet amortized. At small scale, full Read is also cheap; hierarchical still wins on **latency** (\`post-it\` first, no need to parse large). -- **50 entries** (team, 1 month): saving **${results[1].avgSaving}** with **${results[1].hitRate}** hitRate — like cache 90% hit, 10% miss expands to larger levels. This is the sweet spot: 50×200 avg ~10k full vs ~${results[1].avgTopTokens} top. -- **500 entries** (project, 6 months): saving **${results[2].avgSaving}** — like library scale, hierarchical is **99%** saving, but hitRate drops to **${results[2].hitRate}** if queries are too narrow (e.g., \`post-it\` query misses \`library\` content). **Tradeoff**: narrow query → high saving but lower hit, broad query → lower saving but higher hit. Our lightweight AI chooses starting level from query length to balance. +${interp} -### Sample per-query (scale 50) +- At small scale, full Read is also cheap; hierarchical still wins on **latency** (\`post-it\` first, no need to parse large). At large scale, saving approaches **99%** but hitRate drops if queries are too narrow — narrow query → high saving but lower hit; the lightweight AI chooses starting level from query length to balance. + +### Sample per-query (scale ${mid.scale}) | query | assignedLevel | top tokens | saving | hit | latency | -|---|---|---|---|---|` + results[1].perQuery.map(p=>` +|---|---|---|---|---|---|` + mid.perQuery.map(p=>` | ${p.query} | ${p.assignedLevel} | ${p.topTokens} | ${p.saving} | ${p.hit?'✅':'❌'} | ${p.latency} |`).join(''); md += ` @@ -247,12 +255,12 @@ let scales = [5,50,500]; let queries = 20; let json = false; for (let i=0;i Number.isFinite(n) && n > 0); + else if (args[i]==='--queries') queries = Math.max(1, Number(args[++i]) || 1); else if (args[i]==='--seed') { SEED = Number(args[++i]); rand = mulberry32(SEED); } else if (args[i]==='--json') json=true; } -const results = benchmark(scales, queries); +const results = benchmark(scales.length ? scales : [5,50,500], queries); if (json) console.log(JSON.stringify(results, null, 2)); else { const md = printMarkdown(results);