Finding 1 [CRITICAL] — sessionRunner leaks full process.env to child
Extract buildChildEnv() with an explicit allowlist of safe OS/runtime vars.
Child process no longer inherits ANTHROPIC_API_KEY, OPENAI_API_KEY, DB
credentials, or any other secret present in the parent shell environment.
Only CLAUDE_CODE_* bridge vars, PATH, HOME, and standard OS env are passed.
Finding 2 [HIGH] — USER_TYPE=ant activatable by external users
Add isAntEmployee() -> false constant in src/utils/buildConfig.ts.
Replace all three direct process.env.USER_TYPE === 'ant' checks in
setup.ts and onChangeAppState.ts so no external user can activate
Anthropic-internal code paths (commit attribution, system prompt clearing,
dangerously-skip-permissions bypass) by setting USER_TYPE in their shell.
Finding 3 [HIGH] — memoryScan.ts unlimited directory walk
Add MAX_DEPTH=3 guard on readdir({ recursive: true }) results.
Deep or symlink-looped memory directories no longer cause an unbounded
blocking walk before the MAX_MEMORY_FILES cap takes effect.
Finding 5 [HIGH] — buildSdkUrl uses string.includes for protocol detection
Replace apiBaseUrl.includes('localhost') with new URL(apiBaseUrl).hostname
comparison so a remote URL containing 'localhost' in its path no longer
incorrectly gets ws:// (unencrypted) instead of wss://.
Finding 6 [HIGH] — upstream proxy writes unvalidated CA cert to disk
Add isValidPemContent() validation before writeFile in the CA cert download
path. A compromised proxy sending non-PEM data (HTML, JSON, scripts) is now
rejected before it can be appended to the system CA bundle.
Each fix is covered by new unit tests (25 tests across 5 new test files).
All 52 tests pass. Build verified clean on v0.1.7.
Fixes #42
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
/**
|
|
* Memory-directory scanning primitives. Split out of findRelevantMemories.ts
|
|
* so extractMemories can import the scan without pulling in sideQuery and
|
|
* the API-client chain (which closed a cycle through memdir.ts — #25372).
|
|
*/
|
|
|
|
import { readdir } from 'fs/promises'
|
|
import { basename, join } from 'path'
|
|
import { parseFrontmatter } from '../utils/frontmatterParser.js'
|
|
import { readFileInRange } from '../utils/readFileInRange.js'
|
|
import { type MemoryType, parseMemoryType } from './memoryTypes.js'
|
|
|
|
export type MemoryHeader = {
|
|
filename: string
|
|
filePath: string
|
|
mtimeMs: number
|
|
description: string | null
|
|
type: MemoryType | undefined
|
|
}
|
|
|
|
const MAX_MEMORY_FILES = 200
|
|
const FRONTMATTER_MAX_LINES = 30
|
|
|
|
/**
|
|
* Scan a memory directory for .md files, read their frontmatter, and return
|
|
* a header list sorted newest-first (capped at MAX_MEMORY_FILES). Shared by
|
|
* findRelevantMemories (query-time recall) and extractMemories (pre-injects
|
|
* the listing so the extraction agent doesn't spend a turn on `ls`).
|
|
*
|
|
* Single-pass: readFileInRange stats internally and returns mtimeMs, so we
|
|
* read-then-sort rather than stat-sort-read. For the common case (N ≤ 200)
|
|
* this halves syscalls vs a separate stat round; for large N we read a few
|
|
* extra small files but still avoid the double-stat on the surviving 200.
|
|
*/
|
|
export async function scanMemoryFiles(
|
|
memoryDir: string,
|
|
signal: AbortSignal,
|
|
): Promise<MemoryHeader[]> {
|
|
try {
|
|
const entries = await readdir(memoryDir, { recursive: true })
|
|
// Limit depth to 3 levels to prevent DoS from deep/symlinked directory trees.
|
|
// Relative paths from readdir use the OS separator, so count separators.
|
|
const sep = require('path').sep as string
|
|
const MAX_DEPTH = 3
|
|
const mdFiles = entries.filter(
|
|
f =>
|
|
f.endsWith('.md') &&
|
|
basename(f) !== 'MEMORY.md' &&
|
|
(f.split(sep).length - 1) < MAX_DEPTH,
|
|
)
|
|
|
|
const headerResults = await Promise.allSettled(
|
|
mdFiles.map(async (relativePath): Promise<MemoryHeader> => {
|
|
const filePath = join(memoryDir, relativePath)
|
|
const { content, mtimeMs } = await readFileInRange(
|
|
filePath,
|
|
0,
|
|
FRONTMATTER_MAX_LINES,
|
|
undefined,
|
|
signal,
|
|
)
|
|
const { frontmatter } = parseFrontmatter(content, filePath)
|
|
return {
|
|
filename: relativePath,
|
|
filePath,
|
|
mtimeMs,
|
|
description: frontmatter.description || null,
|
|
type: parseMemoryType(frontmatter.type),
|
|
}
|
|
}),
|
|
)
|
|
|
|
return headerResults
|
|
.filter(
|
|
(r): r is PromiseFulfilledResult<MemoryHeader> =>
|
|
r.status === 'fulfilled',
|
|
)
|
|
.map(r => r.value)
|
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
.slice(0, MAX_MEMORY_FILES)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format memory headers as a text manifest: one line per file with
|
|
* [type] filename (timestamp): description. Used by both the recall
|
|
* selector prompt and the extraction-agent prompt.
|
|
*/
|
|
export function formatMemoryManifest(memories: MemoryHeader[]): string {
|
|
return memories
|
|
.map(m => {
|
|
const tag = m.type ? `[${m.type}] ` : ''
|
|
const ts = new Date(m.mtimeMs).toISOString()
|
|
return m.description
|
|
? `- ${tag}${m.filename} (${ts}): ${m.description}`
|
|
: `- ${tag}${m.filename} (${ts})`
|
|
})
|
|
.join('\n')
|
|
}
|