diff --git a/.cursor/.gitkeep b/.cursor/.gitkeep new file mode 100644 index 00000000..48cdce85 --- /dev/null +++ b/.cursor/.gitkeep @@ -0,0 +1 @@ +placeholder diff --git "a/.cursor/allow-payload/{\"permissions\":{\"allow\":[\"Shell(ls)\",\"Shell(npx*)\",\"Shell(node*)\",\"Shell(npm*)\",\"Shell(**)\"],\"deny\":[]}}" "b/.cursor/allow-payload/{\"permissions\":{\"allow\":[\"Shell(ls)\",\"Shell(npx*)\",\"Shell(node*)\",\"Shell(npm*)\",\"Shell(**)\"],\"deny\":[]}}" new file mode 100644 index 00000000..e69de29b diff --git a/.cursor/cli.json.bak b/.cursor/cli.json.bak new file mode 100644 index 00000000..36366fec --- /dev/null +++ b/.cursor/cli.json.bak @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Shell(ls)", + "Shell(npx*)", + "Shell(node*)", + "Shell(npm*)", + "Shell(**)" + ], + "deny": [] + } +} diff --git a/package-lock.json b/package-lock.json index f15560fc..87077e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/react": "^19.2.17", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.11", "bun-types": "^1.1.0", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", @@ -53,7 +53,7 @@ "playwright": "^1.47.0", "tsx": "^4.21.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" @@ -1300,9 +1300,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1319,9 +1316,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1338,9 +1332,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1357,9 +1348,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1376,9 +1364,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1395,9 +1380,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1414,9 +1396,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1433,9 +1412,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1452,9 +1428,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1477,9 +1450,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1502,9 +1472,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1527,9 +1494,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1552,9 +1516,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,9 +1538,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1602,9 +1560,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1627,9 +1582,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2948,7 +2900,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2970,14 +2922,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2991,8 +2943,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3001,16 +2953,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3019,13 +2971,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3046,9 +2998,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3059,13 +3011,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3073,14 +3025,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3089,9 +3041,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3099,13 +3051,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4050,7 +4002,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -7727,19 +7679,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7767,12 +7719,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 54953ef0..303e8c83 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/react": "^19.2.17", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.11", "bun-types": "^1.1.0", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", @@ -87,7 +87,7 @@ "playwright": "^1.47.0", "tsx": "^4.21.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" diff --git a/scripts/verify-bash-guard.mjs b/scripts/verify-bash-guard.mjs new file mode 100644 index 00000000..b8a99ebd --- /dev/null +++ b/scripts/verify-bash-guard.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * Standalone lexical check of the bash destructive-command guard + * (mirrors src/adapters/tools.ts normalizeForGuard / isCommandBlocked). + * Run with: node scripts/verify-bash-guard.mjs + */ +const BLOCKED_COMMANDS = [ + /\brm\s+(-[rR]f?|--recursive)\b/, + /\bgit\s+reset\s+--hard\b/, + /\bgit\s+clean\s+-fd\b/, + /\bdrop\s+database\b/i, + /\btruncate\s+table\b/i, + /\bchmod\s+777\b/, + /\bchown\s+-R\b/, + />\s*\/dev\/sd/, + /\bdd\s+if=/, + /\bpkill\s+-9\b/, + /\bkill\s+-9\b/, +]; + +function normalizeForGuard(command) { + return command + .replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/\\(\d{3})/g, (_m, octal) => String.fromCharCode(parseInt(octal, 8))) + .replace(/\\(.)/g, '$1') + .replace(/['"]/g, ''); +} + +const SUBSTITUTION_SPAN_PATTERNS = [ + /\$\([^()]*\)/g, + /\$\{[^{}]*\}/g, + /`[^`]*`/g, + /<\([^()]*\)/g, + />\([^()]*\)/g, +]; + +function hasMidWordSubstitution(command) { + for (const spanPattern of SUBSTITUTION_SPAN_PATTERNS) { + for (const match of command.matchAll(spanPattern)) { + const start = match.index ?? 0; + const end = start + match[0].length; + const before = command[start - 1]; + const after = command[end]; + if ((before && /[A-Za-z0-9_]/.test(before)) || (after && /[A-Za-z0-9_]/.test(after))) { + return true; + } + } + } + return false; +} + +function isCommandBlocked(command) { + const normalized = normalizeForGuard(command); + if (hasMidWordSubstitution(command) || hasMidWordSubstitution(normalized)) return true; + return BLOCKED_COMMANDS.some((pattern) => pattern.test(command) || pattern.test(normalized)); +} + +const mustBlock = [ + 'rm -rf /foo', + "printf '\\162\\155 -rf /foo' | bash", + "printf '\\x72\\x6d -rf /foo' | bash", + 'echo <(rm -rf /foo)', + 'echo >(rm -rf /foo)', + 'cat <(chmod 777 somefile)', + "r'm' -rf /foo", + 'r\\m -rf /foo', + 'r$(true)m -rf /foo', +]; + +const mustAllow = [ + "printf '%s\\n' hello", + 'diff <(ls a) <(ls b)', + 'echo >(cat /etc/passwd)', + 'VERSION=$(cat package.json)', + 'echo `date`', +]; + +let failed = 0; +for (const cmd of mustBlock) { + if (!isCommandBlocked(cmd)) { + console.error('FAIL expected blocked:', cmd); + failed++; + } else { + console.log('OK blocked:', cmd); + } +} +for (const cmd of mustAllow) { + if (isCommandBlocked(cmd)) { + console.error('FAIL expected allowed:', cmd); + failed++; + } else { + console.log('OK allowed:', cmd); + } +} +process.exit(failed === 0 ? 0 : 1); diff --git a/src/adapters/__tests__/streamBuffer.test.ts b/src/adapters/__tests__/streamBuffer.test.ts index c136c636..b9a89b1d 100644 --- a/src/adapters/__tests__/streamBuffer.test.ts +++ b/src/adapters/__tests__/streamBuffer.test.ts @@ -298,6 +298,24 @@ describe('SmartStreamBuffer', () => { expect(stdout.split('\n').filter((line) => JSON.parse(line).type === 'result')).toHaveLength(8); }); + it('caps a single oversized result event rather than retaining it verbatim', () => { + const buf = new SmartStreamBuffer(); + const huge = 'H'.repeat(200_000); + buf.processChunk(ndjson({ + type: 'result', + result: huge, + total_cost_usd: 0.01, + usage: { input_tokens: 1, output_tokens: 1 }, + })); + const stdout = buf.buildFilteredStdout(); + expect(Buffer.byteLength(stdout, 'utf8')).toBeLessThanOrEqual(64 * 1024 + 64); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.type).toBe('result'); + expect(parsed.total_cost_usd).toBe(0.01); + expect(String(parsed.result).length).toBeLessThan(huge.length); + expect(String(parsed.result)).toContain('[truncated]'); + }); + it('handles non-JSON lines gracefully', () => { const buf = new SmartStreamBuffer(); buf.processChunk('this is not json\n'); diff --git a/src/adapters/streamBuffer.ts b/src/adapters/streamBuffer.ts index a8a594a0..09b6471e 100644 --- a/src/adapters/streamBuffer.ts +++ b/src/adapters/streamBuffer.ts @@ -16,12 +16,51 @@ const MAX_RESULT_EVENTS = 8; const MAX_TEXT_FRAGMENTS = 256; const MAX_TEXT_BYTES = 256 * 1024; const MAX_LINE_BUFFER_BYTES = 1024 * 1024; +/** Per result-event NDJSON line — without this, eight unbounded results defeat the event-count cap. */ +const MAX_RESULT_EVENT_BYTES = 64 * 1024; + +/** + * Rebuild a result event under a byte budget, preferring to keep cost/usage + * fields and truncating the free-form `result` / text payload. + */ +function truncateResultEventLine( + line: string, + event: Record, + maxBytes: number, +): string { + if (Buffer.byteLength(line, 'utf8') <= maxBytes) return line; + const copy: Record = { ...event }; + const truncatableKeys = ['result', 'error', 'message'] as const; + for (const key of truncatableKeys) { + const value = copy[key]; + if (typeof value !== 'string') continue; + // Binary-search a UTF-8-safe prefix that fits with the rest of the object. + let lo = 0; + let hi = value.length; + let best = ''; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + copy[key] = `${value.slice(0, mid)}…[truncated]`; + const candidate = JSON.stringify(copy); + if (Buffer.byteLength(candidate, 'utf8') <= maxBytes) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + if (best) return best; + copy[key] = '…[truncated]'; + } + const fallback = JSON.stringify({ type: 'result', result: '…[truncated]', truncated: true }); + return Buffer.byteLength(fallback, 'utf8') <= maxBytes ? fallback : '{"type":"result","result":"…[truncated]"}'; +} /** * SmartStreamBuffer filters Claude CLI stream-json (NDJSON) output in real-time. * * Kept events: - * - type 'result' — preserved verbatim (contains cost, usage, final result text) + * - type 'result' — retained (cost/usage) with a per-event byte cap * - type 'assistant' — only text blocks extracted (tool_use blocks discarded) * * Discarded events: @@ -78,8 +117,15 @@ export class SmartStreamBuffer { const event = JSON.parse(line); if (event.type === 'result') { - // Preserve result events verbatim — they contain cost/usage data - this.resultEvents.push(line); + // Preserve result events for cost/usage parsers, but bound each line so + // a single huge `result` string cannot defeat MAX_RESULT_EVENTS. + const asRecord = (event && typeof event === 'object') + ? event as Record + : { type: 'result' }; + const capped = Buffer.byteLength(line, 'utf8') > MAX_RESULT_EVENT_BYTES + ? truncateResultEventLine(line, asRecord, MAX_RESULT_EVENT_BYTES) + : line; + this.resultEvents.push(capped); if (this.resultEvents.length > MAX_RESULT_EVENTS) this.resultEvents.shift(); return; } diff --git a/src/adapters/tools.test.ts b/src/adapters/tools.test.ts index 544f1d93..eddfa4f7 100644 --- a/src/adapters/tools.test.ts +++ b/src/adapters/tools.test.ts @@ -193,6 +193,17 @@ describe('executeTool', () => { expect(result.content).toContain('3\tgamma'); expect(result.content).not.toContain('1\talpha'); }); + + it('bounds a single huge line and notes output truncation', async () => { + const hugePath = path.join(TMP_DIR, 'huge-line.txt'); + // Well over MAX_READ_LINE_CHARS (16_384) and enough to hit output budget if many lines. + await fs.writeFile(hugePath, `${'x'.repeat(40_000)}\nsecond\n`, 'utf-8'); + const result = await executeTool(makeCall('read_file', { path: hugePath, limit: 1 }), TMP_DIR); + expect(result.is_error).toBe(false); + expect(result.content).toContain('1\t'); + expect(result.content).toContain('…'); + expect(result.content.length).toBeLessThan(40_000); + }); }); // ── write_file ── @@ -418,6 +429,35 @@ describe('Safety guards (isCommandBlocked via bash)', () => { expect(result.content).toContain('BLOCKED'); }); + // Audit 2026-08-09 (src/adapters 3/3): printf-style octal/hex escapes and + // process substitution also reconstruct a blocked verb at shell-eval time + // while the raw text contains neither a blocked token nor a mid-word splice. + const obfuscationBypassCommands = [ + "printf '\\162\\155 -rf /foo' | bash", // octal escapes decode to rm + "printf '\\x72\\x6d -rf /foo' | bash", // hex escapes decode to rm + 'echo <(rm -rf /foo)', // process substitution + 'echo >(rm -rf /foo)', // output process substitution + 'cat <(chmod 777 somefile)', // process substitution, other blocked verb + ]; + + it.each(obfuscationBypassCommands)('blocks obfuscation-based bypass: %s', async (cmd) => { + const result = await executeTool(makeCall('bash', { command: cmd }), TMP_DIR); + expect(result.is_error).toBe(true); + expect(result.content).toContain('BLOCKED'); + }); + + // Legitimate uses of the same syntax must keep working. + const legitimateObfuscationCommands = [ + "printf '%s\\n' hello", + 'diff <(ls a) <(ls b)', + 'grep -r "\\d{3}" src/', + ]; + + it.each(legitimateObfuscationCommands)('allows legitimate escape/substitution use: %s', async (cmd) => { + const result = await executeTool(makeCall('bash', { command: cmd }), TMP_DIR); + expect(result.content).not.toContain('BLOCKED'); + }); + // Whitespace-delimited substitution (the overwhelmingly common real-world // shape) must keep working — only mid-word gluing is rejected. const legitimateExpansionCommands = [ diff --git a/src/adapters/tools.ts b/src/adapters/tools.ts index 9eec2218..8f7fc288 100644 --- a/src/adapters/tools.ts +++ b/src/adapters/tools.ts @@ -6,7 +6,8 @@ // ============================================ import fs from 'node:fs/promises'; -import { existsSync, realpathSync } from 'node:fs'; +import { createReadStream, existsSync, realpathSync } from 'node:fs'; +import { createInterface } from 'node:readline'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { homedir } from 'node:os'; @@ -296,12 +297,18 @@ async function searchWithGitGrep( */ function normalizeForGuard(command: string): string { return command + // Decode printf/ANSI-C-style escapes FIRST: `printf '\162\155 -rf x' | bash` + // and `$'\162\155 ...'` execute `rm -rf x` while the raw text holds no + // blocked token. Decoding before the generic backslash strip matters — + // `\\(.)` would consume the escape's backslash and leave bare digits. + .replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/\\(\d{3})/g, (_m, octal) => String.fromCharCode(parseInt(octal, 8))) .replace(/\\(.)/g, '$1') .replace(/['"]/g, ''); } /** Command/parameter-substitution spans; open and close pair unambiguously (unlike bare backticks alone). */ -const SUBSTITUTION_SPAN_PATTERNS = [/\$\([^()]*\)/g, /\$\{[^{}]*\}/g, /`[^`]*`/g]; +const SUBSTITUTION_SPAN_PATTERNS = [/\$\([^()]*\)/g, /\$\{[^{}]*\}/g, /`[^`]*`/g, /<\([^()]*\)/g, />\([^()]*\)/g]; /** * True if any substitution span is glued directly onto an adjacent word @@ -330,6 +337,11 @@ function isCommandBlocked(command: string): boolean { // Checked against both forms: quoting can hide a mid-word splice from the // raw text (`r"$(true)"m`) until the quotes are stripped away. if (hasMidWordSubstitution(command) || hasMidWordSubstitution(normalized)) return true; + // Process-substitution payloads are still part of the command string, so + // blocked verbs inside `<(...)` / `>(...)` match BLOCKED_COMMANDS after + // normalizeForGuard (including printf/ANSI-C escapes). Mid-word gluing of + // those spans is rejected above; bare `>(cat …)` is intentionally not a + // deny-list hit — cat is outside the destructive-command contract. return BLOCKED_COMMANDS.some(pattern => pattern.test(command) || pattern.test(normalized)); } @@ -363,11 +375,82 @@ export interface ToolResult { * key once MAX_READ_CACHE_ENTRIES is exceeded. */ const MAX_READ_CACHE_ENTRIES = 64; +/** Cap bytes scanned from disk so a multi-GB file cannot inflate process memory. */ +const MAX_READ_SOURCE_BYTES = 2 * 1024 * 1024; +/** Cap tool-result payload returned into agent context. */ +const MAX_READ_OUTPUT_BYTES = 256 * 1024; +/** Cap a single source line so one huge line cannot defeat the line/limit budget. */ +const MAX_READ_LINE_CHARS = 16_384; export interface ReadCache { store: Map; } +/** + * Stream a line range from disk without retaining the whole file. Stops once + * the selected range is filled, the source-byte budget is exhausted, or the + * rendered output would exceed MAX_READ_OUTPUT_BYTES. + */ +async function readFileRangeBounded( + filePath: string, + offset: number, + limit: number, +): Promise<{ content: string; notes: string[] }> { + const start = Math.max(0, Math.floor(Number(offset) || 0)); + const maxLines = Math.max(1, Math.min(Math.floor(Number(limit) || 500), 2000)); + const notes: string[] = []; + const out: string[] = []; + let outputBytes = 0; + let sourceBytes = 0; + let lineIdx = 0; + let rangeFilled = false; + + const rl = createInterface({ + input: createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 }), + crlfDelay: Infinity, + }); + + try { + for await (const rawLine of rl) { + if (rangeFilled) { + notes.push('more lines available — raise offset to continue'); + break; + } + + const lineBytes = Buffer.byteLength(rawLine, 'utf8') + 1; + sourceBytes += lineBytes; + if (sourceBytes > MAX_READ_SOURCE_BYTES) { + notes.push(`source truncated after ~${MAX_READ_SOURCE_BYTES} bytes`); + break; + } + + if (lineIdx >= start && out.length < maxLines) { + const display = rawLine.length > MAX_READ_LINE_CHARS + ? `${rawLine.slice(0, MAX_READ_LINE_CHARS)}…` + : rawLine; + const numbered = `${lineIdx + 1}\t${display}`; + const numberedBytes = Buffer.byteLength(numbered, 'utf8') + 1; + if (outputBytes + numberedBytes > MAX_READ_OUTPUT_BYTES) { + notes.push(`output truncated at ${MAX_READ_OUTPUT_BYTES} bytes`); + break; + } + out.push(numbered); + outputBytes += numberedBytes; + } + + lineIdx++; + if (lineIdx >= start + maxLines) rangeFilled = true; + } + } finally { + rl.close(); + } + + return { + content: out.join('\n'), + notes, + }; +} + export function createReadCache(): ReadCache { return { store: new Map() }; } @@ -655,14 +738,9 @@ export async function executeTool( }; } - const content = await fs.readFile(filePath, 'utf-8'); - const lines = content.split('\n'); - const slice = lines.slice(offset, offset + limit); - const numbered = slice.map((line, i) => `${offset + i + 1}\t${line}`).join('\n'); - const truncated = lines.length > offset + limit - ? `\n... (${lines.length - offset - limit} more lines)` - : ''; - const result = numbered + truncated; + const { content, notes } = await readFileRangeBounded(filePath, offset, limit); + const suffix = notes.length ? `\n... (${notes.join('; ')})` : ''; + const result = content + suffix; if (cache) cacheSet(cache, cacheKey, result); return { tool_call_id: callId, content: result, is_error: false }; } diff --git a/src/agents/auditor.test.ts b/src/agents/auditor.test.ts index 9d149039..13e49e5c 100644 --- a/src/agents/auditor.test.ts +++ b/src/agents/auditor.test.ts @@ -3,10 +3,22 @@ // Test Status: Complete import { describe, it, expect } from 'vitest'; -import { formatAuditReport, type AuditorOptions, type AuditorResult } from './auditor.js'; +import { formatAuditReport, parseAuditorOutput, type AuditorOptions, type AuditorResult } from './auditor.js'; import type { WorkerResult } from './agentPair.js'; describe('auditor', () => { + describe('parseAuditorOutput string-guard', () => { + it('ignores non-string result payloads instead of throwing on .match', () => { + const output = [ + JSON.stringify({ type: 'result', result: { success: true, nested: true } }), + JSON.stringify({ type: 'result', result: '```json\n{"success":true,"bsScore":1,"criticalCount":0,"warningCount":0,"minorCount":0,"issues":[],"summary":"ok"}\n```' }), + ].join('\n'); + const parsed = parseAuditorOutput(output); + expect(parsed.success).toBe(true); + expect(parsed.summary).toBe('ok'); + }); + }); + describe('formatAuditReport', () => { it('should format successful audit result', () => { const result: AuditorResult = { diff --git a/src/agents/auditor.ts b/src/agents/auditor.ts index 8798d7ed..bf68c38f 100644 --- a/src/agents/auditor.ts +++ b/src/agents/auditor.ts @@ -116,7 +116,8 @@ export async function runAuditor(options: AuditorOptions): Promise { + describe('parseDocumenterOutput string-guard', () => { + it('ignores non-string result payloads instead of throwing on .match', () => { + const output = [ + JSON.stringify({ type: 'result', result: { success: true, nested: true } }), + JSON.stringify({ + type: 'result', + result: '```json\n{"success":true,"updatedFiles":["README.md"],"summary":"documented"}\n```', + }), + ].join('\n'); + const parsed = parseDocumenterOutput(output); + expect(parsed.success).toBe(true); + expect(parsed.summary).toBe('documented'); + }); + }); + describe('formatDocReport', () => { it('should format successful documentation result', () => { const result: DocumenterResult = { diff --git a/src/agents/documenter.ts b/src/agents/documenter.ts index 70d62aac..3d3aa192 100644 --- a/src/agents/documenter.ts +++ b/src/agents/documenter.ts @@ -137,9 +137,10 @@ export async function runDocumenter(options: DocumenterOptions): Promise { expect(rendered).toContain('…truncated…'); expect(rendered).toContain('TAIL-MARKER'); }); + + it('bounds huge command names before interpolating into the summary', () => { + const rendered = renderVerifyEvidence([evidence({ + command: { + name: 'n'.repeat(10_000), + run: 'true', + kind: 'typecheck', + timeoutMs: 1_000, + }, + baseStatus: 'pass', + headStatus: 'fail', + newFailure: true, + rawOutputTail: 'boom', + })]); + expect(Buffer.byteLength(rendered)).toBeLessThanOrEqual(6 * 1024); + expect(rendered).not.toContain('n'.repeat(500)); + }); }); diff --git a/src/agents/verificationEvidence.ts b/src/agents/verificationEvidence.ts index 6c0ef0c5..5e41ac25 100644 --- a/src/agents/verificationEvidence.ts +++ b/src/agents/verificationEvidence.ts @@ -1,29 +1,45 @@ import type { VerifyEvidence } from '../verify/runner.js'; const MAX_EVIDENCE_BYTES = 6 * 1024; +const MAX_COMMAND_NAME_CHARS = 200; +/** Bound intermediates before UTF-8 conversion / Array-from so huge tails cannot OOM. */ +const MAX_RAW_TAIL_CHARS = 32 * 1024; function escapeUntrustedFence(value: string): string { return value.replaceAll('```', '``\u200b`'); } +function clampChars(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + function tailWithinBytes(value: string, maxBytes: number): string { if (maxBytes <= 0) return ''; - const bytes = Buffer.from(value, 'utf8'); - if (bytes.length <= maxBytes) return value; + // Cap code units first so Buffer.from / toString never allocate proportional + // to an unbounded verification payload (audit 2026-08-09). + const capped = clampChars(value, MAX_RAW_TAIL_CHARS); + const bytes = Buffer.from(capped, 'utf8'); + if (bytes.length <= maxBytes) return capped; return `…truncated…\n${bytes.subarray(bytes.length - Math.max(0, maxBytes - 16)).toString('utf8')}`; } export function renderVerifyEvidence(evidence: VerifyEvidence[]): string { if (evidence.length === 0) return ''; - const summaries = evidence.map((item) => - `- ${item.command.name} (${item.command.kind}): head=${item.headStatus}, base=${item.baseStatus}, newFailure=${item.newFailure ? 'yes' : 'no'}, ${(item.durationMs / 1000).toFixed(1)}s` - ).join('\n'); + const summaries = evidence.map((item) => { + const name = clampChars(item.command.name, MAX_COMMAND_NAME_CHARS); + return `- ${name} (${item.command.kind}): head=${item.headStatus}, base=${item.baseStatus}, newFailure=${item.newFailure ? 'yes' : 'no'}, ${(item.durationMs / 1000).toFixed(1)}s`; + }).join('\n'); const prefix = `## Verification Evidence (deterministic, harness-run)\n${summaries}`; const failureOutput = evidence .filter((item) => item.newFailure) - .map((item) => `\n### ${item.command.name} output (untrusted data)\n\`\`\`text\n${escapeUntrustedFence(item.rawOutputTail)}\n\`\`\``) + .map((item) => { + const name = clampChars(item.command.name, MAX_COMMAND_NAME_CHARS); + const tail = clampChars(item.rawOutputTail, MAX_RAW_TAIL_CHARS); + return `\n### ${name} output (untrusted data)\n\`\`\`text\n${escapeUntrustedFence(tail)}\n\`\`\``; + }) .join('\n'); if (!failureOutput) return tailWithinBytes(prefix, MAX_EVIDENCE_BYTES); - const remaining = MAX_EVIDENCE_BYTES - Buffer.byteLength(prefix) - 1; + const remaining = MAX_EVIDENCE_BYTES - Buffer.byteLength(prefix, 'utf8') - 1; return `${prefix}\n${tailWithinBytes(failureOutput, remaining)}`; } diff --git a/src/automation/dailyReporter.test.ts b/src/automation/dailyReporter.test.ts new file mode 100644 index 00000000..ba79f2a9 --- /dev/null +++ b/src/automation/dailyReporter.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { unlinkSync } from 'node:fs'; +import { DAILY_REPORT_PROGRESS_FILE, generateDailyReports, setLinearClient, setTeamId } from './dailyReporter.js'; + +const postStatusUpdate = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock('../linear/index.js', () => ({ + postStatusUpdate, +})); + +describe('generateDailyReports progress', () => { + beforeEach(() => { + postStatusUpdate.mockClear(); + setTeamId('team-1'); + try { unlinkSync(DAILY_REPORT_PROGRESS_FILE); } catch { /* missing is fine */ } + try { unlinkSync(`${DAILY_REPORT_PROGRESS_FILE}.lock`); } catch { /* missing is fine */ } + }); + + afterEach(() => { + setLinearClient(null as never); + setTeamId(''); + try { unlinkSync(DAILY_REPORT_PROGRESS_FILE); } catch { /* ignore */ } + try { unlinkSync(`${DAILY_REPORT_PROGRESS_FILE}.lock`); } catch { /* ignore */ } + }); + + it('retries only projects that failed on the previous run', async () => { + postStatusUpdate + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('linear down')) + .mockResolvedValueOnce(undefined); + + const projects = { + nodes: [ + { id: 'p-ok', name: 'Ok', state: 'started' }, + { id: 'p-fail', name: 'Fail', state: 'started' }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + setLinearClient({ + team: async () => ({ + projects: async () => projects, + }), + } as never); + + await generateDailyReports(); + expect(postStatusUpdate).toHaveBeenCalledTimes(2); + expect(postStatusUpdate.mock.calls.map((c) => c[0])).toEqual(['p-ok', 'p-fail']); + + postStatusUpdate.mockClear(); + postStatusUpdate.mockResolvedValueOnce(undefined); + + await generateDailyReports(); + expect(postStatusUpdate).toHaveBeenCalledTimes(1); + expect(postStatusUpdate.mock.calls[0]?.[0]).toBe('p-fail'); + }); +}); diff --git a/src/automation/dailyReporter.ts b/src/automation/dailyReporter.ts index df9b0ccc..1aa5ce64 100644 --- a/src/automation/dailyReporter.ts +++ b/src/automation/dailyReporter.ts @@ -5,7 +5,12 @@ import { Cron } from 'croner'; import { LinearClient, type Project } from '@linear/sdk'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readFile } from 'node:fs/promises'; import { postStatusUpdate } from '../linear/index.js'; +import { atomicWriteFile } from '../support/atomicFile.js'; +import { withFileLock } from '../support/fileLock.js'; let cronJob: Cron | null = null; let linearClient: LinearClient | null = null; @@ -15,6 +20,20 @@ let reportInFlight: Promise | null = null; // Project path mapping (projectId → projectPath) for knowledge graph metrics let projectPathMapping = new Map(); +/** Per-day progress so a partial failure retries only unfinished projects. */ +export const DAILY_REPORT_PROGRESS_FILE = process.env.OPENSWARM_DAILY_REPORT_PROGRESS_FILE + || join( + process.env.VITEST ? tmpdir() : homedir(), + process.env.VITEST + ? `openswarm-daily-report-progress-${process.pid}.json` + : '.openswarm/daily-report-progress.json', + ); + +type DailyReportProgress = { + date: string; + completedProjectIds: string[]; +}; + export interface DailyReporterConfig { schedule: string; // Cron expression (default: "0 18 * * *" for 6 PM daily) enabled: boolean; @@ -78,6 +97,54 @@ export function stopDailyReporter(): void { } } +function todayKey(): string { + return new Date().toISOString().slice(0, 10); +} + +async function loadProgress(): Promise { + const today = todayKey(); + return withFileLock(`${DAILY_REPORT_PROGRESS_FILE}.lock`, async () => { + try { + const parsed = JSON.parse(await readFile(DAILY_REPORT_PROGRESS_FILE, 'utf8')) as Partial; + if ( + parsed.date === today + && Array.isArray(parsed.completedProjectIds) + && parsed.completedProjectIds.every((id) => typeof id === 'string') + ) { + return { date: today, completedProjectIds: [...new Set(parsed.completedProjectIds)] }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + return { date: today, completedProjectIds: [] }; + }); +} + +async function markProjectPublished(projectId: string): Promise { + const today = todayKey(); + await withFileLock(`${DAILY_REPORT_PROGRESS_FILE}.lock`, async () => { + let completedProjectIds: string[] = []; + try { + const parsed = JSON.parse(await readFile(DAILY_REPORT_PROGRESS_FILE, 'utf8')) as Partial; + if ( + parsed.date === today + && Array.isArray(parsed.completedProjectIds) + && parsed.completedProjectIds.every((id) => typeof id === 'string') + ) { + completedProjectIds = parsed.completedProjectIds; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (!completedProjectIds.includes(projectId)) completedProjectIds.push(projectId); + await atomicWriteFile( + DAILY_REPORT_PROGRESS_FILE, + JSON.stringify({ date: today, completedProjectIds }), + 0o600, + ); + }); +} + /** * Manually trigger daily reports (for testing) */ @@ -117,14 +184,25 @@ export async function generateDailyReports(): Promise { console.log(`[DailyReporter] Found ${activeProjects.length} active projects`); - // Generate status update for each project + const progress = await loadProgress(); + const alreadyDone = new Set(progress.completedProjectIds); + + // Generate status update for each project — skip ones already published today. let successCount = 0; let failCount = 0; + let skippedCount = 0; for (const project of activeProjects) { + if (alreadyDone.has(project.id)) { + skippedCount++; + successCount++; + continue; + } try { const projectPath = projectPathMapping.get(project.id); await postStatusUpdate(project.id, project.name, projectPath); + await markProjectPublished(project.id); + alreadyDone.add(project.id); successCount++; } catch (err) { console.error(`[DailyReporter] Failed to post update for "${project.name}":`, err); @@ -132,7 +210,11 @@ export async function generateDailyReports(): Promise { } } - console.log(`[DailyReporter] Reports completed: ${successCount} success, ${failCount} failed`); + console.log( + `[DailyReporter] Reports completed: ${successCount} success` + + (skippedCount > 0 ? ` (${skippedCount} already published today)` : '') + + `, ${failCount} failed`, + ); // Send summary to Discord if (discordReporter && successCount > 0) { diff --git a/src/cli/checkHandler.test.ts b/src/cli/checkHandler.test.ts index c31ac460..3d88bca2 100644 --- a/src/cli/checkHandler.test.ts +++ b/src/cli/checkHandler.test.ts @@ -115,6 +115,8 @@ const scanResult = (over: Partial = {}): ScanResult => ({ errors: [], durationMs: 42, languageBreakdown: {}, + scanComplete: true, + skippedPaths: [], ...over, }); diff --git a/src/cli/checkHandler.ts b/src/cli/checkHandler.ts index f455bf3c..18991e1a 100644 --- a/src/cli/checkHandler.ts +++ b/src/cli/checkHandler.ts @@ -181,6 +181,16 @@ export async function handleCheck( } } + if (!result.scanComplete) { + console.log(`\n ${c.yellow('Scan incomplete')} — depth/size/timeout limits excluded some sources.`); + for (const path of result.skippedPaths.slice(0, 10)) { + console.log(` ${c.dim(path)}`); + } + if (result.skippedPaths.length > 10) { + console.log(` ${c.dim(`...and ${result.skippedPaths.length - 10} more`)}`); + } + } + // 스캔 후 통계 표시 const stats = store.getStats(projectId); console.log(`\n${c.bold('Registry Status')}`); diff --git a/src/cli/projectHandler.coverage.test.ts b/src/cli/projectHandler.coverage.test.ts index 1297256b..4430d2c7 100644 --- a/src/cli/projectHandler.coverage.test.ts +++ b/src/cli/projectHandler.coverage.test.ts @@ -222,6 +222,15 @@ describe('loadRepos malformed-JSON recovery (via handleProjectList)', () => { expect(() => handleProjectList()).toThrow(/preserved as/); expect(renameSyncMock).toHaveBeenCalledOnce(); }); + + it('reports quarantine failure accurately when renameSync fails', () => { + readFileSyncMock.mockReturnValue('{ not valid json ,, }'); + existsSyncMock.mockImplementation((p: string) => typeof p === 'string' && p.endsWith('openswarm-repos.json')); + renameSyncMock.mockImplementationOnce(() => { + throw new Error('EACCES'); + }); + expect(() => handleProjectList()).toThrow(/quarantine .* failed — original left in place/); + }); }); describe('loadRepos defaults missing fields (via handleProjectList)', () => { diff --git a/src/cli/projectHandler.ts b/src/cli/projectHandler.ts index 1234bad3..5b54b198 100644 --- a/src/cli/projectHandler.ts +++ b/src/cli/projectHandler.ts @@ -48,8 +48,18 @@ export function loadRepos(file: string = REPOS_FILE): ReposConfig { }; } catch (error) { const recoveryPath = `${file}.corrupt-${Date.now()}`; - try { renameSync(file, recoveryPath); } catch { /* preserve original error below */ } - throw new Error(`Repository registry is malformed at ${file}; preserved as ${recoveryPath}: ${error instanceof Error ? error.message : String(error)}`); + let quarantined = false; + try { + renameSync(file, recoveryPath); + quarantined = true; + } catch { /* leave original in place; report accurately below */ } + const detail = error instanceof Error ? error.message : String(error); + if (quarantined) { + throw new Error(`Repository registry is malformed at ${file}; preserved as ${recoveryPath}: ${detail}`); + } + throw new Error( + `Repository registry is malformed at ${file}; quarantine to ${recoveryPath} failed — original left in place: ${detail}`, + ); } } diff --git a/src/core/taskLogStore.test.ts b/src/core/taskLogStore.test.ts index 8cfbf80c..f3d41dfc 100644 --- a/src/core/taskLogStore.test.ts +++ b/src/core/taskLogStore.test.ts @@ -6,6 +6,8 @@ import { scheduleTaskLogCleanup, TASK_LOG_MAX_BUFFERS, TASK_LOG_MAX_LINE_CHARS, + TASK_LOG_MAX_STAGE_CHARS, + TASK_LOG_MAX_TASK_ID_CHARS, TASK_LOG_RETENTION_MS, TASK_LOG_RING_SIZE, __resetTaskLogsForTests, @@ -77,6 +79,17 @@ describe('taskLogStore', () => { expect(snapshot.truncated).toBe(true); }); + it('bounds oversized stage and task-id fields', () => { + const longStage = 's'.repeat(TASK_LOG_MAX_STAGE_CHARS + 40); + const longId = 'i'.repeat(TASK_LOG_MAX_TASK_ID_CHARS + 40); + appendTaskLog(longId, longStage, 'ok'); + const snapshot = getTaskLog(longId)!; + expect(snapshot).not.toBeNull(); + expect(snapshot.taskId.length).toBe(TASK_LOG_MAX_TASK_ID_CHARS + 1); + expect(snapshot.lines[0].stage.length).toBe(TASK_LOG_MAX_STAGE_CHARS + 1); + expect(snapshot.truncated).toBe(true); + }); + it('deletes the buffer after the retention window post-completion', () => { appendTaskLog('t1', 'worker', 'done soon'); scheduleTaskLogCleanup('t1'); diff --git a/src/core/taskLogStore.ts b/src/core/taskLogStore.ts index 53c42ecf..2da3dcfe 100644 --- a/src/core/taskLogStore.ts +++ b/src/core/taskLogStore.ts @@ -10,9 +10,10 @@ // Deliberately import-free: eventHub imports this module, and route modules // import both — any import from here would be a cycle waiting to happen. // -// Memory bound is threefold: per-line truncation x per-task ring x LRU buffer -// cap. Worst case 24 x 1000 x (~400 UTF-16 chars + object overhead) ≈ 22 MB; -// observed agent lines average ~80 chars, so typical usage is 1–3 MB. +// Memory bound is fourfold: per-line truncation x per-stage/id caps x per-task +// ring x LRU buffer cap. Worst case 24 x 1000 x (~400+64 UTF-16 chars + object +// overhead) ≈ 22 MB; observed agent lines average ~80 chars, so typical usage +// is 1–3 MB. export interface TaskLogLine { stage: string; @@ -36,6 +37,10 @@ export interface TaskLogSnapshot { export const TASK_LOG_RING_SIZE = 1000; export const TASK_LOG_MAX_LINE_CHARS = 400; +/** Stage labels are retained on every ring entry — bound them like line text. */ +export const TASK_LOG_MAX_STAGE_CHARS = 64; +/** Task ids are Map keys; an unbounded caller-supplied id would defeat the ~22 MB bound. */ +export const TASK_LOG_MAX_TASK_ID_CHARS = 128; export const TASK_LOG_MAX_BUFFERS = 24; export const TASK_LOG_RETENTION_MS = 10 * 60_000; @@ -72,20 +77,35 @@ function evictIfNeeded(): void { } } +function boundTaskId(taskId: string): string { + return taskId.length > TASK_LOG_MAX_TASK_ID_CHARS + ? `${taskId.slice(0, TASK_LOG_MAX_TASK_ID_CHARS)}…` + : taskId; +} + +function boundStage(stage: string): string { + const label = typeof stage === 'string' && stage.length > 0 ? stage : 'unknown'; + return label.length > TASK_LOG_MAX_STAGE_CHARS + ? `${label.slice(0, TASK_LOG_MAX_STAGE_CHARS)}…` + : label; +} + /** Returns the sequence assigned to the line (0 when nothing was stored). */ export function appendTaskLog(taskId: string, stage: string, line: string, now = Date.now()): number { if (!taskId || typeof line !== 'string') return 0; - let buffer = buffers.get(taskId); + const id = boundTaskId(taskId); + const boundedStage = boundStage(stage); + let buffer = buffers.get(id); if (!buffer) { evictIfNeeded(); buffer = { lines: [], truncated: false, completed: false, lastAppendAt: now }; - buffers.set(taskId, buffer); + buffers.set(id, buffer); } else { // Refresh LRU position and mark live again — a task id that logs after // completion (retry reusing the id) must not be reaped by a stale timer. - buffers.delete(taskId); - buffers.set(taskId, buffer); - cancelTaskLogCleanup(taskId); + buffers.delete(id); + buffers.set(id, buffer); + cancelTaskLogCleanup(id); buffer.lastAppendAt = now; } @@ -94,8 +114,11 @@ export function appendTaskLog(taskId: string, stage: string, line: string, now = text = `${text.slice(0, TASK_LOG_MAX_LINE_CHARS)}…`; buffer.truncated = true; } + if (id !== taskId || boundedStage !== (typeof stage === 'string' ? stage : 'unknown')) { + buffer.truncated = true; + } const seq = nextSeq++; - buffer.lines.push({ stage, line: text, ts: now, seq }); + buffer.lines.push({ stage: boundedStage, line: text, ts: now, seq }); if (buffer.lines.length > TASK_LOG_RING_SIZE) { buffer.lines.shift(); buffer.truncated = true; @@ -105,38 +128,41 @@ export function appendTaskLog(taskId: string, stage: string, line: string, now = /** Snapshot copy (safe to serialize while appends continue). Null → 404. */ export function getTaskLog(taskId: string): TaskLogSnapshot | null { - const buffer = buffers.get(taskId); + const id = boundTaskId(taskId); + const buffer = buffers.get(id); if (!buffer) return null; - return { taskId, lines: buffer.lines.slice(), truncated: buffer.truncated }; + return { taskId: id, lines: buffer.lines.slice(), truncated: buffer.truncated }; } /** Called on task:completed — keep the transcript readable for a grace window. */ export function scheduleTaskLogCleanup(taskId: string, delayMs = TASK_LOG_RETENTION_MS): void { - const buffer = buffers.get(taskId); + const id = boundTaskId(taskId); + const buffer = buffers.get(id); if (!buffer) return; // Replace any prior timer directly — cancelTaskLogCleanup would also clear // the completed flag this function is about to set. - const prior = cleanupTimers.get(taskId); + const prior = cleanupTimers.get(id); if (prior) clearTimeout(prior); buffer.completed = true; const timer = setTimeout(() => { - cleanupTimers.delete(taskId); + cleanupTimers.delete(id); // The completed flag is cleared whenever the task id came back to life — // never delete a buffer that has gone live again. - if (buffers.get(taskId)?.completed) buffers.delete(taskId); + if (buffers.get(id)?.completed) buffers.delete(id); }, delayMs); timer.unref?.(); - cleanupTimers.set(taskId, timer); + cleanupTimers.set(id, timer); } /** Called on task:started / new log lines — the task id is live (again). */ export function cancelTaskLogCleanup(taskId: string): void { - const timer = cleanupTimers.get(taskId); + const id = boundTaskId(taskId); + const timer = cleanupTimers.get(id); if (timer) { clearTimeout(timer); - cleanupTimers.delete(taskId); + cleanupTimers.delete(id); } - const buffer = buffers.get(taskId); + const buffer = buffers.get(id); if (buffer) buffer.completed = false; } diff --git a/src/core/traceCollector.test.ts b/src/core/traceCollector.test.ts index 6ef1131f..edfa6387 100644 --- a/src/core/traceCollector.test.ts +++ b/src/core/traceCollector.test.ts @@ -36,6 +36,15 @@ describe('TraceCollector', () => { expect(trace!.metadata.issueId).toBe('INT-100'); }); + it('oversized names and metadata are bounded before retention', () => { + const hugeName = 'n'.repeat(10_000); + const hugeMeta = { blob: 'x'.repeat(100_000), nested: { deep: 'y'.repeat(5_000) } }; + const traceId = collector.startTrace(hugeName, hugeMeta); + const trace = collector.getTrace(traceId)!; + expect(trace.name.length).toBeLessThanOrEqual(257); + expect(JSON.stringify(trace.metadata).length).toBeLessThan(20_000); + }); + it('trace를 종료하면 completed 상태가 되어야 한다', () => { const traceId = collector.startTrace('test-session'); const trace = collector.endTrace(traceId); diff --git a/src/core/traceCollector.ts b/src/core/traceCollector.ts index 2ae68d43..46574b6d 100644 --- a/src/core/traceCollector.ts +++ b/src/core/traceCollector.ts @@ -9,6 +9,73 @@ import { randomUUID } from 'node:crypto'; export type SpanStatus = 'running' | 'completed' | 'failed'; +const MAX_TRACE_NAME_CHARS = 256; +const MAX_TRACE_METADATA_BYTES = 8 * 1024; +const MAX_TRACE_METADATA_KEYS = 32; +const MAX_TRACE_METADATA_DEPTH = 4; +const MAX_ERROR_MESSAGE_CHARS = 2_048; +const MAX_ERROR_STACK_CHARS = 4_096; + +function boundName(name: string): string { + const label = typeof name === 'string' && name.length > 0 ? name : 'unnamed'; + return label.length > MAX_TRACE_NAME_CHARS + ? `${label.slice(0, MAX_TRACE_NAME_CHARS)}…` + : label; +} + +function boundMetadata(value: unknown, depth = 0): unknown { + if (depth >= MAX_TRACE_METADATA_DEPTH) return '[max-depth]'; + if (value == null) return value; + if (typeof value === 'string') { + return value.length > 1_024 ? `${value.slice(0, 1_024)}…` : value; + } + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (Array.isArray(value)) { + return value.slice(0, 32).map((item) => boundMetadata(item, depth + 1)); + } + if (typeof value === 'object') { + const out: Record = {}; + let keys = 0; + for (const [key, child] of Object.entries(value as Record)) { + if (keys >= MAX_TRACE_METADATA_KEYS) break; + const safeKey = key.length > 128 ? `${key.slice(0, 128)}…` : key; + out[safeKey] = boundMetadata(child, depth + 1); + keys++; + } + return out; + } + return String(value).slice(0, 256); +} + +function retainMetadata(metadata: Record): Record { + const bounded = boundMetadata(metadata) as Record; + const encoded = JSON.stringify(bounded); + if (Buffer.byteLength(encoded, 'utf8') <= MAX_TRACE_METADATA_BYTES) return bounded; + return { truncated: true, preview: encoded.slice(0, 512) }; +} + +function boundErrorInfo(error: { message: string; stack?: string; code?: string }): { + message: string; + stack?: string; + code?: string; +} { + return { + message: typeof error.message === 'string' + ? (error.message.length > MAX_ERROR_MESSAGE_CHARS + ? `${error.message.slice(0, MAX_ERROR_MESSAGE_CHARS)}…` + : error.message) + : 'error', + ...(typeof error.stack === 'string' + ? { + stack: error.stack.length > MAX_ERROR_STACK_CHARS + ? `${error.stack.slice(0, MAX_ERROR_STACK_CHARS)}…` + : error.stack, + } + : {}), + ...(typeof error.code === 'string' ? { code: error.code.slice(0, 64) } : {}), + }; +} + /** * 개별 작업 단위 (도구 호출, 에이전트 실행 등) */ @@ -95,11 +162,11 @@ export class TraceCollector { const traceId = randomUUID(); const trace: Trace = { traceId, - name, + name: boundName(name), startTime: Date.now(), status: 'running', spans: [], - metadata, + metadata: retainMetadata(metadata), }; this.traces.set(traceId, trace); return traceId; @@ -150,10 +217,10 @@ export class TraceCollector { spanId, traceId, parentSpanId, - name, + name: boundName(name), status: 'running', startTime: Date.now(), - metadata, + metadata: retainMetadata(metadata), }; trace.spans.push(span); return spanId; @@ -190,7 +257,7 @@ export class TraceCollector { const span = trace.spans.find((s) => s.spanId === spanId); if (!span) return false; - span.errorInfo = error; + span.errorInfo = boundErrorInfo(error); span.status = 'failed'; span.endTime = span.endTime ?? Date.now(); return true; diff --git a/src/issues/sqliteStore.test.ts b/src/issues/sqliteStore.test.ts index 120d3a85..6ee9f205 100644 --- a/src/issues/sqliteStore.test.ts +++ b/src/issues/sqliteStore.test.ts @@ -103,6 +103,23 @@ describe('getStats scoping', () => { }); }); +describe('status transition oldValue', () => { + it('records status_changed oldValue from the in-transaction status', () => { + const store = new SqliteIssueStore(path()); + const issue = store.createIssue({ projectId: 'p', title: 'status race', status: 'todo' }); + + store.changeStatus(issue.id, 'in_progress'); + store.updateIssue(issue.id, { status: 'done' }); + + const events = store.getEvents(issue.id).filter((e) => e.type === 'status_changed'); + // Newest first — the done transition must cite in_progress, not the + // pre-transaction todo that updateIssue would have captured outside the txn. + expect(events[0]?.oldValue).toBe('in_progress'); + expect(events[0]?.newValue).toBe('done'); + store.close(); + }); +}); + describe('store permissions (INT-2961 audit)', () => { it('keeps the database and its WAL sidecars owner-only', async () => { // The store holds issue titles, descriptions and task history for every diff --git a/src/issues/sqliteStore.ts b/src/issues/sqliteStore.ts index 91b12c3a..b7df2c65 100644 --- a/src/issues/sqliteStore.ts +++ b/src/issues/sqliteStore.ts @@ -440,7 +440,13 @@ export class SqliteIssueStore implements IIssueStore { } if (patch.status !== undefined) { - this.applyStatusChange(id, existing.status, patch.status, 'system'); + // Read the live status inside the write transaction so concurrent + // updaters do not each stamp the same pre-txn oldValue onto the event log. + const row = this.db.prepare('SELECT status FROM issues WHERE id = ?').get(id) as + | { status: IssueStatus } + | undefined; + if (!row) return; + this.applyStatusChange(id, row.status, patch.status, 'system'); } }); @@ -532,11 +538,14 @@ export class SqliteIssueStore implements IIssueStore { // ============ 상태 전이 ============ changeStatus(id: string, status: IssueStatus, actor?: string): Issue | null { - const existing = this.getIssue(id); - if (!existing) return null; - - this.applyStatusChange(id, existing.status, status, actor ?? 'system'); - return this.getIssue(id); + return this.db.transaction(() => { + const row = this.db.prepare('SELECT status FROM issues WHERE id = ?').get(id) as + | { status: IssueStatus } + | undefined; + if (!row) return null; + this.applyStatusChange(id, row.status, status, actor ?? 'system'); + return this.getIssue(id); + })(); } private applyStatusChange(id: string, oldStatus: IssueStatus, status: IssueStatus, actor: string): void { diff --git a/src/knowledge/gitInfo.test.ts b/src/knowledge/gitInfo.test.ts new file mode 100644 index 00000000..8f73400b --- /dev/null +++ b/src/knowledge/gitInfo.test.ts @@ -0,0 +1,29 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getRecentlyChangedFiles } from './gitInfo.js'; + +describe('getRecentlyChangedFiles', () => { + let dir: string; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('includes newly created untracked source files', async () => { + dir = mkdtempSync(join(tmpdir(), 'osw-gitinfo-')); + execFileSync('git', ['init'], { cwd: dir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }); + execFileSync('git', ['config', 'user.name', 'test'], { cwd: dir }); + writeFileSync(join(dir, 'tracked.ts'), 'export const a = 1;\n'); + execFileSync('git', ['add', 'tracked.ts'], { cwd: dir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: dir }); + + writeFileSync(join(dir, 'src-foo.ts'), 'export const b = 2;\n'); + + const changed = await getRecentlyChangedFiles(dir, Date.now() - 60_000); + expect(changed).toContain('src-foo.ts'); + }); +}); diff --git a/src/knowledge/gitInfo.ts b/src/knowledge/gitInfo.ts index e3e23708..a35bcd97 100644 --- a/src/knowledge/gitInfo.ts +++ b/src/knowledge/gitInfo.ts @@ -139,7 +139,10 @@ export async function enrichWithGitInfo( } /** - * List of recently changed files (for incremental update trigger) + * List of recently changed files (for incremental update trigger). + * Includes commits since `sinceTimestamp`, plus staged/unstaged tracked + * changes and untracked source paths — otherwise a brand-new file that has + * never been committed never refreshes the knowledge graph. */ export async function getRecentlyChangedFiles( projectPath: string, @@ -147,17 +150,43 @@ export async function getRecentlyChangedFiles( ): Promise { try { const sinceDate = new Date(sinceTimestamp).toISOString(); - const output = await runGitCommand(projectPath, [ + const files = new Set(); + const absorb = (output: string): void => { + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (trimmed) files.add(trimmed); + } + }; + + absorb(await runGitCommand(projectPath, [ 'log', `--since=${sinceDate}`, '--name-only', '--format=', - ]); + ])); + + // Tracked working-tree / index changes (may predate or postdate last scan). + for (const args of [ + ['diff', '--name-only', 'HEAD'], + ['diff', '--name-only', '--cached'], + ] as string[][]) { + try { + absorb(await runGitCommand(projectPath, args)); + } catch { + /* empty tree / no HEAD — skip */ + } + } - const files = new Set(); - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (trimmed) files.add(trimmed); + // Untracked files: a newly created src/foo.ts is invisible to git log/diff + // until `git add`, which left refreshGraph returning a stale cached graph. + try { + absorb(await runGitCommand(projectPath, [ + 'ls-files', + '--others', + '--exclude-standard', + ])); + } catch { + /* ignore */ } return Array.from(files); diff --git a/src/linear/projectUpdater.test.ts b/src/linear/projectUpdater.test.ts new file mode 100644 index 00000000..77bedf69 --- /dev/null +++ b/src/linear/projectUpdater.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { buildOverviewDescription } from './projectUpdater.js'; + +describe('buildOverviewDescription', () => { + it('reserves capacity so the compact summary survives a long base description', () => { + const base = 'x'.repeat(300); + const desc = buildOverviewDescription(base, { done: 3, inProgress: 1, todo: 7 }); + expect(desc.length).toBeLessThanOrEqual(255); + expect(desc).toMatch(/\[Done:3 InProgress:1 Todo:7\]$/); + }); + + it('returns only the summary when the base is empty', () => { + expect(buildOverviewDescription('', { done: 0, inProgress: 0, todo: 1 })) + .toBe('Done:0 InProgress:0 Todo:1'); + }); + + it('keeps a short base description intact', () => { + const desc = buildOverviewDescription('Hello project', { done: 1, inProgress: 0, todo: 0 }); + expect(desc).toBe('Hello project\n\n[Done:1 InProgress:0 Todo:0]'); + }); +}); diff --git a/src/linear/projectUpdater.ts b/src/linear/projectUpdater.ts index 10fd4ca5..8f7bd925 100644 --- a/src/linear/projectUpdater.ts +++ b/src/linear/projectUpdater.ts @@ -484,19 +484,17 @@ async function refreshProjectOverview(projectId: string, projectPath?: string): // Strip any previously-appended compact summary so it isn't doubled on each call. const baseDesc = stripped.replace(/\s*\[Done:\d+ InProgress:\d+ Todo:\d+\]$/, '').trimEnd(); - // Build a compact summary line for description (fits within 255 chars) + // Build a compact summary line for description (fits within 255 chars). + // Reserve capacity for the summary first — truncating after append can + // silently drop the entire status line when baseDesc already fills 255. const doneCount = stateCounts.get('Done') ?? 0; const inProgressCount = stateCounts.get('In Progress') ?? 0; const todoCount = stateCounts.get('Todo') ?? 0; - const compactSummary = `Done:${doneCount} InProgress:${inProgressCount} Todo:${todoCount}`; - const descWithSummary = baseDesc - ? `${baseDesc}\n\n[${compactSummary}]` - : compactSummary; - - // Truncate to 255 chars (Linear hard limit) - const finalDesc = descWithSummary.length > 255 - ? descWithSummary.slice(0, 252) + '...' - : descWithSummary; + const finalDesc = buildOverviewDescription(baseDesc, { + done: doneCount, + inProgress: inProgressCount, + todo: todoCount, + }); await linear.updateProject(projectId, { description: finalDesc }); console.log(`[ProjectUpdater] Project overview updated for "${project.name}"`); @@ -507,6 +505,30 @@ async function refreshProjectOverview(projectId: string, projectPath?: string): // Helpers +/** Linear project description hard limit. */ +const LINEAR_DESC_LIMIT = 255; + +/** + * Append a compact Done/InProgress/Todo summary, reserving suffix capacity so + * a long base description cannot truncate the status line away. + * Exported for unit tests. + */ +export function buildOverviewDescription( + baseDesc: string, + counts: { done: number; inProgress: number; todo: number }, +): string { + const compactSummary = `Done:${counts.done} InProgress:${counts.inProgress} Todo:${counts.todo}`; + const bracketed = `[${compactSummary}]`; + if (!baseDesc) return compactSummary.slice(0, LINEAR_DESC_LIMIT); + const suffix = `\n\n${bracketed}`; + const room = Math.max(0, LINEAR_DESC_LIMIT - suffix.length); + if (room === 0) return bracketed.slice(0, LINEAR_DESC_LIMIT); + const head = baseDesc.length <= room + ? baseDesc + : `${baseDesc.slice(0, Math.max(0, room - 1))}…`; + return `${head}${suffix}`; +} + function formatDuration(ms: number): string { const sec = Math.floor(ms / 1000); if (sec < 60) return `${sec}s`; diff --git a/src/memory/memoryOps.ts b/src/memory/memoryOps.ts index 26020a2c..950b8e67 100644 --- a/src/memory/memoryOps.ts +++ b/src/memory/memoryOps.ts @@ -574,7 +574,10 @@ export async function getMemoryStats(): Promise<{ const table = getTable(); if (!table) return { total: 0, byType: { ...DEFAULT_BY_TYPE }, byRepo: {}, avgImportance: 0 }; - const results = await table.search(Array.from({ length: EMBEDDING_DIM }, () => 0)).limit(10000).toArray(); + // Full-table scalar scan — vector search with a hard 10k cap silently + // under-counted large stores and returned incomplete type/repo averages. + const rowCount = await table.countRows(); + const results = await table.query().limit(Math.max(rowCount, 1)).toArray(); const byType: Record = { ...DEFAULT_BY_TYPE }; const byRepo: Record = {}; @@ -639,8 +642,10 @@ export async function getRecentConversations( if (!table) return []; // Scalar scan is intentional: vector similarity must not decide which - // messages count as recent. The final ordering uses the source timestamp. - const results = await table.query().limit(100_000).toArray(); + // messages count as recent. Size the query from the live row count so + // newer conversations outside an arbitrary 100k window are not omitted. + const rowCount = await table.countRows(); + const results = await table.query().limit(Math.max(rowCount, 1)).toArray(); // Filter: journal + chat (channelId matching is loose for legacy data compat) const filtered = results diff --git a/src/orchestration/workflow.coverage.test.ts b/src/orchestration/workflow.coverage.test.ts index 14a0d85a..1980880f 100644 --- a/src/orchestration/workflow.coverage.test.ts +++ b/src/orchestration/workflow.coverage.test.ts @@ -202,10 +202,19 @@ describe('workflow storage round trips', () => { it('saves and loads an execution by ID', async () => { const executionId = uniqueId('cov-execution'); + const workflowId = uniqueId('cov-wf-for-exec'); cleanupExecutionIds.push(executionId); + cleanupWorkflowIds.push(workflowId); + + await saveWorkflow({ + id: workflowId, + name: 'Exec Host', + projectPath: '/tmp/project', + steps: [{ id: 'step', name: 'Step', prompt: 'run' }], + }); const execution: WorkflowExecution = { - workflowId: 'wf-1', + workflowId, executionId, status: 'running', startedAt: Date.now(), @@ -220,6 +229,19 @@ describe('workflow storage round trips', () => { expect(loaded).toEqual(execution); }); + it('refuses to persist an execution when its workflow definition is missing', async () => { + const executionId = uniqueId('cov-execution-orphan'); + cleanupExecutionIds.push(executionId); + + await expect(saveExecution({ + workflowId: uniqueId('missing-wf'), + executionId, + status: 'running', + startedAt: Date.now(), + stepResults: {}, + })).rejects.toThrow(/workflow .+ is missing/); + }); + it('returns null when loading a well-formed but nonexistent execution ID', async () => { const loaded = await loadExecution(uniqueId('cov-execution-missing')); expect(loaded).toBeNull(); diff --git a/src/orchestration/workflow.ts b/src/orchestration/workflow.ts index 021bdaca..8be5e17e 100644 --- a/src/orchestration/workflow.ts +++ b/src/orchestration/workflow.ts @@ -7,6 +7,8 @@ import { basename, isAbsolute, relative, resolve } from 'path'; import { homedir } from 'os'; import * as fs from 'fs/promises'; import * as yaml from 'yaml'; +import { withFileLock } from '../support/fileLock.js'; +import { atomicWriteFile } from '../support/atomicFile.js'; // Types & Interfaces @@ -271,13 +273,21 @@ function storageFilePath(rootDir: string, id: string, extension: string): string return filePath; } +function workflowDefLockPath(workflowId: string): string { + return storageFilePath(WORKFLOW_DIR, workflowId, '.lock'); +} + /** * Save workflow */ export async function saveWorkflow(workflow: WorkflowConfig): Promise { const filePath = storageFilePath(WORKFLOW_DIR, workflow.id, '.yaml'); await fs.mkdir(WORKFLOW_DIR, { recursive: true }); - await fs.writeFile(filePath, yaml.stringify(workflow), 'utf-8'); + // Share a lock with saveExecution so a definition rewrite cannot race an + // execution persist that already validated against the prior definition. + await withFileLock(workflowDefLockPath(workflow.id), async () => { + await atomicWriteFile(filePath, yaml.stringify(workflow)); + }); console.log(`[Workflow] Saved: ${workflow.name} (${workflow.id})`); } @@ -328,7 +338,17 @@ export async function listWorkflows(): Promise { export async function saveExecution(execution: WorkflowExecution): Promise { const filePath = storageFilePath(EXECUTION_DIR, execution.executionId, '.json'); await fs.mkdir(EXECUTION_DIR, { recursive: true }); - await fs.writeFile(filePath, JSON.stringify(execution, null, 2), 'utf-8'); + // Validate the workflow definition under the same lock saveWorkflow holds so + // a concurrent definition replacement cannot leave an orphaned execution. + await withFileLock(workflowDefLockPath(execution.workflowId), async () => { + const workflow = await loadWorkflow(execution.workflowId); + if (!workflow) { + throw new Error( + `Cannot save execution ${execution.executionId}: workflow ${execution.workflowId} is missing`, + ); + } + await atomicWriteFile(filePath, JSON.stringify(execution, null, 2)); + }); } /** diff --git a/src/registry/entityScanner.test.ts b/src/registry/entityScanner.test.ts index 06d48f95..1ce9502e 100644 --- a/src/registry/entityScanner.test.ts +++ b/src/registry/entityScanner.test.ts @@ -71,3 +71,31 @@ describe('scanRepository non-repo guard (INT-2507)', () => { await expect(scanRepository(dir, 'junk')).rejects.toThrow(/non-git directory/); }); }); + +describe('scanRepository completeness signals (audit 2026-08-09)', () => { + it('marks scanComplete=false when maxDepth excludes nested sources', async () => { + const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = mkdtempSync(join(tmpdir(), 'osw-depth-')); + mkdirSync(join(dir, 'a', 'b'), { recursive: true }); + writeFileSync(join(dir, 'a', 'b', 'deep.ts'), 'export const x = 1;\n'); + const { scanRepository } = await import('./entityScanner.js'); + const result = await scanRepository(dir, 'depth-project', { allowNonRepo: true, maxDepth: 1 }); + expect(result.scanComplete).toBe(false); + expect(result.skippedPaths.some((p) => p.includes('maxDepth'))).toBe(true); + }); + + it('marks scanComplete=false and records oversized source paths', async () => { + const { mkdtempSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = mkdtempSync(join(tmpdir(), 'osw-oversize-')); + writeFileSync(join(dir, 'big.ts'), 'x'.repeat(600 * 1024)); + const { scanRepository } = await import('./entityScanner.js'); + const result = await scanRepository(dir, 'oversize-project', { allowNonRepo: true }); + expect(result.scanComplete).toBe(false); + expect(result.skippedPaths.some((p) => p.includes('oversized'))).toBe(true); + expect(result.errors.some((e) => /exceeds \d+ bytes/.test(e))).toBe(true); + }); +}); diff --git a/src/registry/entityScanner.ts b/src/registry/entityScanner.ts index 7d39bfc0..687edd3a 100644 --- a/src/registry/entityScanner.ts +++ b/src/registry/entityScanner.ts @@ -647,6 +647,10 @@ export interface ScanResult { errors: string[]; durationMs: number; languageBreakdown: Record; + /** False when depth, size, or timeout limits excluded source that may still exist. */ + scanComplete: boolean; + /** Paths skipped for size / depth (bounded list for diagnostics). */ + skippedPaths: string[]; } // ============ 메인 스캔 함수 ============ @@ -679,13 +683,24 @@ export async function scanRepository( const allExtracted: ExtractedEntity[] = []; const testFiles: TestFileInfo[] = []; const errors: string[] = []; + const skippedPaths: string[] = []; + let scanComplete = true; const languageBreakdown: Record = {}; const scannedSourceFiles = new Set(); let scannedFiles = 0; async function walk(dirPath: string, relPath: string, depth: number): Promise { - if (depth > maxDepth) return; - if (Date.now() - startTime > timeoutMs) return; + if (depth > maxDepth) { + scanComplete = false; + if (skippedPaths.length < 50) { + skippedPaths.push(`${relPath || '.'} (maxDepth ${maxDepth})`); + } + return; + } + if (Date.now() - startTime > timeoutMs) { + scanComplete = false; + return; + } let entries; try { @@ -729,7 +744,12 @@ export async function scanRepository( } } } catch (err) { - errors.push(`${entryRelPath}: ${err instanceof Error ? err.message : String(err)}`); + const message = err instanceof Error ? err.message : String(err); + errors.push(`${entryRelPath}: ${message}`); + if (/exceeds \d+ bytes/.test(message)) { + scanComplete = false; + if (skippedPaths.length < 50) skippedPaths.push(`${entryRelPath} (oversized)`); + } } } } @@ -858,5 +878,7 @@ export async function scanRepository( errors, durationMs: Date.now() - startTime, languageBreakdown, + scanComplete, + skippedPaths, }; } diff --git a/src/support/dashboardHtml.test.ts b/src/support/dashboardHtml.test.ts index 8bf86d4f..3b72d80e 100644 --- a/src/support/dashboardHtml.test.ts +++ b/src/support/dashboardHtml.test.ts @@ -35,6 +35,17 @@ describe('stage row escaping (AGT-3476)', () => { expect(html).toContain('"
" + escapeHtml(r.status || "") + "
"'); }); + it('escapes knowledge-graph hot module names before innerHTML insert', () => { + expect(html).toContain('escapeHtml(m.split("/").pop() || "")'); + expect(html).not.toContain('map(function(m){return m.split("/").pop()})'); + }); + + it('allowlists pipeline decision values before using them as CSS classes', () => { + expect(html).toContain('/^(approve|revise|reject)$/i.test'); + expect(html).toContain('sd-decision-" + safe'); + expect(html).not.toContain('sd-decision-" + r.decision'); + }); + it('leaves no unescaped status interpolation behind', () => { // The exact shape the fix replaced. Catches a partial revert of either site. expect(html).not.toContain('"sdot " + (r.status || "")'); diff --git a/src/support/dashboardHtml.ts b/src/support/dashboardHtml.ts index 37c2b6ee..a3082e5f 100644 --- a/src/support/dashboardHtml.ts +++ b/src/support/dashboardHtml.ts @@ -764,7 +764,7 @@ const DASHBOARD_HTML = ` "modules:" + s.totalModules + " tests:" + s.totalTestFiles + " untested:" + s.untestedModules.length + " churn:" + (s.avgChurnScore || 0).toFixed(2) + - (s.hotModules.length ? " hot:" + s.hotModules.slice(0,3).map(function(m){return m.split("/").pop()}).join(",") : "") + + (s.hotModules.length ? " hot:" + s.hotModules.slice(0,3).map(function(m){return escapeHtml(m.split("/").pop() || "")}).join(",") : "") + "" ); } @@ -931,8 +931,9 @@ const DASHBOARD_HTML = ` if (r.summary) addLine("Summary", escapeHtml(r.summary)); if (r.decision) { - const cls = "sd-decision-" + r.decision; - addLine("Decision", "" + escapeHtml(r.decision.toUpperCase()) + ""); + const safe = /^(approve|revise|reject)$/i.test(String(r.decision)) ? String(r.decision).toLowerCase() : "unknown"; + const cls = "sd-decision-" + safe; + addLine("Decision", "" + escapeHtml(String(r.decision).toUpperCase()) + ""); } if (r.feedback) addLine("Feedback", escapeHtml(r.feedback)); if (Array.isArray(r.filesChanged) && r.filesChanged.length > 0) { @@ -1008,8 +1009,9 @@ const DASHBOARD_HTML = ` // without having to expand. let inlineSummary = ""; if (r.decision) { - const cls = "sd-decision-" + r.decision; - inlineSummary = "" + escapeHtml(r.decision.toUpperCase()) + "" + + const safe = /^(approve|revise|reject)$/i.test(String(r.decision)) ? String(r.decision).toLowerCase() : "unknown"; + const cls = "sd-decision-" + safe; + inlineSummary = "" + escapeHtml(String(r.decision).toUpperCase()) + "" + (r.feedback ? " · " + escapeHtml(r.feedback.slice(0, 80)) : ""); } else if (r.summary) { inlineSummary = escapeHtml(r.summary); diff --git a/src/support/gitTracker.test.ts b/src/support/gitTracker.test.ts index dbe73a07..39f29bd7 100644 --- a/src/support/gitTracker.test.ts +++ b/src/support/gitTracker.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -211,4 +211,13 @@ describe('gitTracker', () => { } }); }); + + it('decodes git subprocess output with a streaming TextDecoder', async () => { + // Source pin: Buffer#toString() on each chunk corrupts multi-byte UTF-8 + // sequences split across stream events. + const source = readFileSync(new URL('./gitTracker.ts', import.meta.url), 'utf-8'); + expect(source).toContain("new TextDecoder('utf8')"); + expect(source).toContain('decode(data, { stream: true })'); + expect(source).not.toMatch(/stdout \+= data\.toString\(\)/); + }); }); diff --git a/src/support/gitTracker.ts b/src/support/gitTracker.ts index 12a9e599..1a825bc1 100644 --- a/src/support/gitTracker.ts +++ b/src/support/gitTracker.ts @@ -7,6 +7,7 @@ import { spawn } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { TextDecoder } from 'node:util'; /** * Extract changed file list via git diff @@ -302,6 +303,8 @@ function runGitCommand(cwd: string, args: string[], env?: NodeJS.ProcessEnv): Pr let stdout = ''; let stderr = ''; + const stdoutDec = new TextDecoder('utf8'); + const stderrDec = new TextDecoder('utf8'); let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -310,12 +313,20 @@ function runGitCommand(cwd: string, args: string[], env?: NodeJS.ProcessEnv): Pr reject(new Error(`git ${args.join(' ')} timed out after ${GIT_CMD_TIMEOUT_MS}ms`)); }, GIT_CMD_TIMEOUT_MS); - proc.stdout.on('data', (data) => { stdout += data.toString(); }); - proc.stderr.on('data', (data) => { stderr += data.toString(); }); + // Stream-aware decode so a multi-byte UTF-8 character split across chunks + // is not corrupted the way naive Buffer#toString() would. + proc.stdout.on('data', (data: Buffer) => { + stdout += stdoutDec.decode(data, { stream: true }); + }); + proc.stderr.on('data', (data: Buffer) => { + stderr += stderrDec.decode(data, { stream: true }); + }); proc.on('close', (code) => { settled = true; clearTimeout(timer); + stdout += stdoutDec.decode(); + stderr += stderrDec.decode(); if (code === 0) { resolve(stdout); } else { diff --git a/src/support/tailscaleNetwork.ts b/src/support/tailscaleNetwork.ts index 454c685f..7cf4d07f 100644 --- a/src/support/tailscaleNetwork.ts +++ b/src/support/tailscaleNetwork.ts @@ -25,3 +25,19 @@ export function detectTailscaleIP(): string | undefined { } return undefined; } + +/** + * Whether a remote socket address may be treated as a Tailscale peer for + * authorization. CGNAT 100.64/10 is shared RFC 6598 space — only trust it when + * THIS host actually has a Tailscale interface. The product-specific ULA prefix + * is accepted on its own. + */ +export function isAuthorizedTailscaleRemote(address: string | undefined): boolean { + if (!address) return false; + const normalized = address.startsWith('::ffff:') ? address.slice(7) : address; + if (normalized.toLowerCase().startsWith('fd7a:115c:a1e0:')) return true; + // Shared CGNAT: require a local Tailscale iface so non-Tailscale CGNAT hosts + // cannot satisfy OPENSWARM_TRUST_TAILSCALE alone. + if (!detectTailscaleIP()) return false; + return isTailscaleAddress(address); +} diff --git a/src/support/web.tailscale.test.ts b/src/support/web.tailscale.test.ts index f7dc4133..92115451 100644 --- a/src/support/web.tailscale.test.ts +++ b/src/support/web.tailscale.test.ts @@ -86,3 +86,49 @@ describe('isTailscaleAddress', () => { expect(isTailscaleAddress('fd00::1')).toBe(false); }); }); + +describe('isAuthorizedTailscaleRemote', () => { + it('accepts Tailscale ULA without requiring a local iface', async () => { + vi.resetModules(); + vi.doMock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, networkInterfaces: () => ({ en0: [iface('192.168.1.20')] }) }, + networkInterfaces: () => ({ en0: [iface('192.168.1.20')] }), + }; + }); + const { isAuthorizedTailscaleRemote } = await import('./web.js'); + expect(isAuthorizedTailscaleRemote('fd7a:115c:a1e0::b601:f469')).toBe(true); + }); + + it('rejects CGNAT remotes when this host has no Tailscale interface', async () => { + vi.resetModules(); + vi.doMock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, networkInterfaces: () => ({ en0: [iface('192.168.1.20')] }) }, + networkInterfaces: () => ({ en0: [iface('192.168.1.20')] }), + }; + }); + const { isAuthorizedTailscaleRemote, isTailscaleAddress } = await import('./web.js'); + expect(isTailscaleAddress('100.95.1.2')).toBe(true); + expect(isAuthorizedTailscaleRemote('100.95.1.2')).toBe(false); + }); + + it('accepts CGNAT remotes only when this host is on Tailscale', async () => { + vi.resetModules(); + vi.doMock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, networkInterfaces: () => ({ utun3: [iface('100.95.200.28')] }) }, + networkInterfaces: () => ({ utun3: [iface('100.95.200.28')] }), + }; + }); + const { isAuthorizedTailscaleRemote } = await import('./web.js'); + expect(isAuthorizedTailscaleRemote('100.95.1.2')).toBe(true); + expect(isAuthorizedTailscaleRemote('::ffff:100.64.0.1')).toBe(true); + }); +}); diff --git a/src/support/web.ts b/src/support/web.ts index 4fe8573b..491973bb 100644 --- a/src/support/web.ts +++ b/src/support/web.ts @@ -28,8 +28,8 @@ import * as memory from '../memory/index.js'; import { PairPipeline, type PipelineResult } from '../agents/pairPipeline.js'; import type { TaskItem } from '../orchestration/decisionEngine.js'; import type { PipelineStage, RoleConfig } from '../core/types.js'; -import { detectTailscaleIP, isLoopbackAddress, isTailscaleAddress } from './tailscaleNetwork.js'; -export { detectTailscaleIP, isTailscaleAddress } from './tailscaleNetwork.js'; +import { detectTailscaleIP, isLoopbackAddress, isAuthorizedTailscaleRemote } from './tailscaleNetwork.js'; +export { detectTailscaleIP, isAuthorizedTailscaleRemote, isTailscaleAddress } from './tailscaleNetwork.js'; import { runChatCompletion, getDefaultChatModel } from './chatBackend.js'; import { handleGraphQL, isGraphQLRequest } from '../issues/graphql/server.js'; import { ISSUE_BOARD_HTML } from '../issues/issueBoardHtml.js'; @@ -60,11 +60,12 @@ function isAllowedOrigin(origin: string): boolean { if (hostname === 'localhost' || hostname === '127.0.0.1') return true; if (hostname === 'tauri.localhost') return true; - // Tailscale CGNAT range: 100.64.0.0/10 → first octet 100, second 64–127 + // Tailscale CGNAT range: 100.64.0.0/10 → first octet 100, second 64–127. + // Only allow when this host is on Tailscale — CGNAT is shared RFC 6598 space. const tailscaleMatch = hostname.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/); if (tailscaleMatch) { const second = Number(tailscaleMatch[1]); - if (second >= 64 && second <= 127) return true; + if (second >= 64 && second <= 127 && detectTailscaleIP()) return true; } return false; } @@ -85,7 +86,7 @@ function safeErrorMessage(err: unknown): string { function isTrustedTailscaleRequest(req: IncomingMessage): boolean { return process.env.OPENSWARM_TRUST_TAILSCALE === 'true' - && isTailscaleAddress(req.socket.remoteAddress) + && isAuthorizedTailscaleRemote(req.socket.remoteAddress) && isTrustedLocalOrigin(req); }