mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
* feat: add conversation cache and session persistence - ConversationCache: LRU cache for conversation history with TTL - Session persistence with encrypted save/load - Cross-device sync support - Integrated into sessionHistory * fix: address PR review feedback - Remove broken XOR encryption - store sessions as plain JSON - Fix key not being persisted issue - Integrate cacheSession into fetchLatestEvents for actual use - Remove dead code: no more unused integration functions - Use proper config directory path * test: add unit tests for conversationCache and sessionPersistence - conversationCache.test.ts: 8 tests (LRU, TTL, get/set, delete/clear) - sessionPersistence.test.ts: 7 tests (create, save/load, list, delete) * fix: use getClaudeConfigHomeDir for consistent config path - Replace custom path logic with getClaudeConfigHomeDir() from envUtils - Ensures consistency with rest of codebase (122 other usages) * fix: address PR #705 blockers * fix: fully address PR #705 blockers 1. Remove dead listPersistedSessions (no consumer) 2. Integrate loadCachedSession + cacheSession into fetchLatestEvents - fetchLatestEvents now checks cache first (loadCachedSession) - fetchLatestEvents now saves to cache + disk (cacheSession) 3. Add extractSessionId() function for session ID extraction 4. Proper serialization/deserialization with CacheMessage type * fix: address all non-blocking issues for PR #705 1. Fix O(n) accessOrder - use Map instead of array filtering (O(1)) 2. Remove maxMemoryMb - add deprecated function, memory limit not enforced 3. Add test override for session dir - OPENCLAUDE_TEST_SESSIONS_DIR env var All blockers and non-blockers now addressed. * fix: address PR #705 remaining blocker - Add timestamp to CacheMessage for SessionMessage compatibility - Replace as any with explicit cast for SessionMessage compatibility - Use serializeToCacheMessage consistently for both cache and persist * chore: remove PR705 review comment file * fix: preserve full SDKMessage fields in cache round-trip - Extend CacheMessage interface with id, type, model, created_at, stop_reason, usage, is_development, index - serializeToCacheMessage: preserve all relevant fields with type guards - deserializeFromCacheMessage: restore all preserved fields - Prevents data corruption on structured message history * fix: resolve PR 705 blocking issues - Fix cache-hit returns hasMore:true/firstId:null - now always fetch latest - Fix deserialize reconstructs structured content from JSON - Fix extractSessionId uses regex for robustness - Fix debounce saveSession - only persist on meaningful change (new count) Fixes reviewer feedback from gnanam1990 and Vasanthdev2004 * fix: use temp test directory in sessionPersistence test Non-blocking fix: use /tmp/openclaude-test-sessions instead of default to avoid touching real local state outside CI * fix: resolve PR 705 remaining blockers - fetchLatestEvents returns cached immediately for offline/restart support - Background fetch after returning cached - cacheSession checks message IDs not just count - Test uses temp directory * fix: resolve PR 705 remaining blockers - fetchLatestEvents returns fresh data, fixes firstId * fix: PR 705 - round-trip content type safety and pagination metadata Blocking: - Add contentIsArray flag to track whether content was originally string vs array - Serializer stores the flag; deserializer uses it instead of heuristic (startsWith '[') - Prevents corruption of string content like '[]' or '[1,2]' being parsed as JSON Non-blocking: - Wire OPENCLAUDE_TEST_SESSIONS_DIR in sessionPersistence.test.ts beforeEach - Add afterEach to clean up env var - Store hasMore/lastId metadata in cache, use real values on fallback instead of fabricating hasMore: true * fix: PR 705 - persist pagination metadata across restarts - Add pagination field to Session interface for hasMore/lastId - cacheSession() now saves pagination to persisted session - loadCachedSession() reconstructs sessionMetadataCache from persisted session - After restart/offline resume, fetchLatestEvents() returns correct hasMore from saved metadata * fix: preserve full SDKMessage shape in cache serializer Add missing type-specific payload fields to serialization/deserialization: - message (assistant/user/system payload) - uuid, session_id, parent_tool_use_id, tool_use_result (user messages) - subtype, result (result/system messages) - event (stream events) Previously only role/content were stored, dropping type-specific payloads needed by convertSDKMessage(). * fix: add error handling to PR intent scan entry point * fix: persist all SDKMessage variant fields through cache round-trip - Add error field for SDKAssistantMessage errors (was silently dropping) - Add errors field for SDKResultMessage error variant (was degrading to 'Unknown error') - Add status field for SDKStatusMessage ('compacting' was being dropped) - Add compact_metadata field for SDKCompactBoundaryMessage - Add tool_name and elapsed_time_seconds fields for SDKToolProgressMessage (was rendering undefined) - Add 11 regression tests verifying every variant round-trips correctly Fixes P1: Persisted history still does not round-trip the full SDKMessage union * fix: persist pagination cursor and use uuid for cache-dirty detection (PR review)
471 lines
12 KiB
TypeScript
471 lines
12 KiB
TypeScript
import { spawnSync } from 'node:child_process'
|
|
|
|
export type FindingSeverity = 'high' | 'medium'
|
|
|
|
export type DiffLine = {
|
|
file: string
|
|
line: number
|
|
content: string
|
|
}
|
|
|
|
export type Finding = {
|
|
severity: FindingSeverity
|
|
code: string
|
|
file: string
|
|
line: number
|
|
detail: string
|
|
excerpt: string
|
|
}
|
|
|
|
type CliOptions = {
|
|
baseRef: string
|
|
headRef: string
|
|
json: boolean
|
|
failOn: FindingSeverity
|
|
}
|
|
|
|
const SELF_EXCLUDED_FILES = new Set([
|
|
'scripts/pr-intent-scan.ts',
|
|
'scripts/pr-intent-scan.test.ts',
|
|
])
|
|
|
|
const SHORTENER_DOMAINS = [
|
|
'bit.ly',
|
|
'tinyurl.com',
|
|
'goo.gl',
|
|
't.co',
|
|
'is.gd',
|
|
'rb.gy',
|
|
'cutt.ly',
|
|
]
|
|
|
|
const SUSPICIOUS_DOWNLOAD_DOMAINS = [
|
|
'dropbox.com',
|
|
'dl.dropboxusercontent.com',
|
|
'drive.google.com',
|
|
'docs.google.com',
|
|
'mega.nz',
|
|
'mediafire.com',
|
|
'transfer.sh',
|
|
'anonfiles.com',
|
|
'catbox.moe',
|
|
]
|
|
|
|
const URL_REGEX = /\bhttps?:\/\/[^\s)>"']+/gi
|
|
const LONG_BASE64_REGEX = /\b(?:[A-Za-z0-9+/]{80,}={0,2}|[A-Za-z0-9_-]{80,})\b/
|
|
const EXECUTABLE_PATH_REGEX =
|
|
/\.(?:sh|bash|zsh|ps1|exe|msi|pkg|deb|rpm|zip|tar|tgz|gz|xz|dmg|appimage)(?:$|[?#])/i
|
|
const SENSITIVE_PATH_REGEX =
|
|
/^(?:\.github\/workflows\/|scripts\/|bin\/|install(?:\/|\.|$)|.*(?:Dockerfile|docker-compose|compose\.ya?ml)$)/i
|
|
|
|
function parseOptions(argv: string[]): CliOptions {
|
|
const options: CliOptions = {
|
|
baseRef: 'origin/main',
|
|
headRef: 'HEAD',
|
|
json: false,
|
|
failOn: 'high',
|
|
}
|
|
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const arg = argv[index]
|
|
if (arg === '--json') {
|
|
options.json = true
|
|
continue
|
|
}
|
|
if (arg === '--base') {
|
|
const next = argv[index + 1]
|
|
if (next && !next.startsWith('--')) {
|
|
options.baseRef = next
|
|
index++
|
|
}
|
|
continue
|
|
}
|
|
if (arg === '--head') {
|
|
const next = argv[index + 1]
|
|
if (next && !next.startsWith('--')) {
|
|
options.headRef = next
|
|
index++
|
|
}
|
|
continue
|
|
}
|
|
if (arg === '--fail-on') {
|
|
const next = argv[index + 1]
|
|
if (next === 'high' || next === 'medium') {
|
|
options.failOn = next
|
|
index++
|
|
}
|
|
}
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
function trimExcerpt(content: string): string {
|
|
const compact = content.trim().replace(/\s+/g, ' ')
|
|
return compact.length > 140 ? `${compact.slice(0, 137)}...` : compact
|
|
}
|
|
|
|
function uniqueFindings(findings: Finding[]): Finding[] {
|
|
const seen = new Set<string>()
|
|
return findings.filter(finding => {
|
|
const key = `${finding.code}:${finding.file}:${finding.line}:${finding.detail}`
|
|
if (seen.has(key)) {
|
|
return false
|
|
}
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
}
|
|
|
|
function parseAddedLines(diffText: string): DiffLine[] {
|
|
const lines = diffText.split('\n')
|
|
const added: DiffLine[] = []
|
|
let currentFile: string | null = null
|
|
let currentLine = 0
|
|
|
|
for (const rawLine of lines) {
|
|
if (rawLine.startsWith('+++ b/')) {
|
|
currentFile = rawLine.slice('+++ b/'.length)
|
|
continue
|
|
}
|
|
|
|
if (rawLine.startsWith('@@')) {
|
|
const match = /\+(\d+)(?:,(\d+))?/.exec(rawLine)
|
|
if (match) {
|
|
currentLine = Number(match[1])
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!currentFile) {
|
|
continue
|
|
}
|
|
|
|
if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) {
|
|
added.push({
|
|
file: currentFile,
|
|
line: currentLine,
|
|
content: rawLine.slice(1),
|
|
})
|
|
currentLine += 1
|
|
continue
|
|
}
|
|
|
|
if (rawLine.startsWith('-') && !rawLine.startsWith('---')) {
|
|
continue
|
|
}
|
|
|
|
if (!rawLine.startsWith('\\')) {
|
|
currentLine += 1
|
|
}
|
|
}
|
|
|
|
return added
|
|
}
|
|
|
|
function tryParseUrl(value: string): URL | null {
|
|
try {
|
|
return new URL(value)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function hostMatches(hostname: string, domain: string): boolean {
|
|
return hostname === domain || hostname.endsWith(`.${domain}`)
|
|
}
|
|
|
|
function hasSuspiciousDownloadIndicators(url: URL): boolean {
|
|
const combined = `${url.pathname}${url.search}`.toLowerCase()
|
|
return (
|
|
combined.includes('dl=1') ||
|
|
combined.includes('raw=1') ||
|
|
combined.includes('export=download') ||
|
|
combined.includes('/download') ||
|
|
combined.includes('/uc?export=download')
|
|
)
|
|
}
|
|
|
|
function findUrlFindings(line: DiffLine): Finding[] {
|
|
const findings: Finding[] = []
|
|
const matches = line.content.match(URL_REGEX) ?? []
|
|
|
|
for (const match of matches) {
|
|
const parsed = tryParseUrl(match)
|
|
if (!parsed) continue
|
|
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
|
|
for (const domain of SHORTENER_DOMAINS) {
|
|
if (hostMatches(hostname, domain)) {
|
|
findings.push({
|
|
severity: 'medium',
|
|
code: 'shortened-url',
|
|
file: line.file,
|
|
line: line.line,
|
|
detail: `Added shortened URL: ${hostname}`,
|
|
excerpt: trimExcerpt(line.content),
|
|
})
|
|
}
|
|
}
|
|
|
|
const isSuspiciousHost = SUSPICIOUS_DOWNLOAD_DOMAINS.some(domain =>
|
|
hostMatches(hostname, domain),
|
|
)
|
|
const isExecutableDownload = EXECUTABLE_PATH_REGEX.test(
|
|
`${parsed.pathname}${parsed.search}`,
|
|
)
|
|
|
|
if (isSuspiciousHost) {
|
|
findings.push({
|
|
severity:
|
|
hasSuspiciousDownloadIndicators(parsed) || isExecutableDownload
|
|
? 'high'
|
|
: 'medium',
|
|
code: 'suspicious-download-link',
|
|
file: line.file,
|
|
line: line.line,
|
|
detail: `Added external file-hosting link: ${hostname}`,
|
|
excerpt: trimExcerpt(line.content),
|
|
})
|
|
} else if (isExecutableDownload) {
|
|
findings.push({
|
|
severity: 'high',
|
|
code: 'executable-download-link',
|
|
file: line.file,
|
|
line: line.line,
|
|
detail: `Added direct link to executable or archive payload: ${hostname}`,
|
|
excerpt: trimExcerpt(line.content),
|
|
})
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
function findSensitivePathFindings(line: DiffLine): Finding[] {
|
|
if (!SENSITIVE_PATH_REGEX.test(line.file)) {
|
|
return []
|
|
}
|
|
|
|
const lower = line.content.toLowerCase()
|
|
|
|
if (
|
|
/\b(curl|wget|invoke-webrequest|iwr|powershell|bash|sh|chmod\s+\+x)\b/i.test(
|
|
line.content,
|
|
) ||
|
|
URL_REGEX.test(line.content) ||
|
|
lower.includes('download')
|
|
) {
|
|
return [
|
|
{
|
|
severity: 'medium',
|
|
code: 'sensitive-automation-change',
|
|
file: line.file,
|
|
line: line.line,
|
|
detail:
|
|
'Added network, execution, or download-related content in a sensitive automation file',
|
|
excerpt: trimExcerpt(line.content),
|
|
},
|
|
]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
function findCommandFindings(line: DiffLine): Finding[] {
|
|
const findings: Finding[] = []
|
|
const lower = line.content.toLowerCase()
|
|
|
|
const highPatterns: Array<[string, RegExp, string]> = [
|
|
[
|
|
'download-exec-chain',
|
|
/\b(curl|wget|invoke-webrequest|iwr)\b.*(\|\s*(sh|bash|zsh)|;\s*chmod\s+\+x|&&\s*\.\.?\/|>\s*\/tmp\/)/i,
|
|
'Added remote download followed by execution or staging',
|
|
],
|
|
[
|
|
'powershell-encoded',
|
|
/\bpowershell(?:\.exe)?\b.*(?:-enc|-encodedcommand)\b/i,
|
|
'Added encoded PowerShell invocation',
|
|
],
|
|
[
|
|
'shell-eval-remote',
|
|
/\b(curl|wget)\b.*\|\s*(sh|bash|zsh)\b/i,
|
|
'Added shell pipe from remote content into interpreter',
|
|
],
|
|
[
|
|
'binary-lolbin',
|
|
/\b(mshta|rundll32|regsvr32|certutil)\b/i,
|
|
'Added living-off-the-land binary often used for payload staging',
|
|
],
|
|
[
|
|
'invoke-expression',
|
|
/\b(iex|invoke-expression)\b/i,
|
|
'Added PowerShell expression execution',
|
|
],
|
|
]
|
|
|
|
const mediumPatterns: Array<[string, RegExp, string]> = [
|
|
[
|
|
'download-command',
|
|
/\b(curl|wget|invoke-webrequest|iwr)\b.*https?:\/\//i,
|
|
'Added command that downloads remote content',
|
|
],
|
|
[
|
|
'archive-extract-exec',
|
|
/\b(unzip|tar|7z)\b.*(&&|;).*\b(chmod|node|python|bash|sh)\b/i,
|
|
'Added archive extraction followed by execution',
|
|
],
|
|
[
|
|
'base64-decode',
|
|
/\b(base64\s+-d|openssl\s+base64\s+-d|python .*b64decode)\b/i,
|
|
'Added explicit payload decode step',
|
|
],
|
|
]
|
|
|
|
for (const [code, pattern, detail] of highPatterns) {
|
|
if (pattern.test(line.content)) {
|
|
findings.push({
|
|
severity: 'high',
|
|
code,
|
|
file: line.file,
|
|
line: line.line,
|
|
detail,
|
|
excerpt: trimExcerpt(line.content),
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const [code, pattern, detail] of mediumPatterns) {
|
|
if (code === 'download-command' && !SENSITIVE_PATH_REGEX.test(line.file)) {
|
|
continue
|
|
}
|
|
if (pattern.test(line.content)) {
|
|
findings.push({
|
|
severity: 'medium',
|
|
code,
|
|
file: line.file,
|
|
line: line.line,
|
|
detail,
|
|
excerpt: trimExcerpt(line.content),
|
|
})
|
|
}
|
|
}
|
|
|
|
if (LONG_BASE64_REGEX.test(line.content) && !lower.includes('sha256') && !lower.includes('sha512')) {
|
|
findings.push({
|
|
severity: 'medium',
|
|
code: 'long-encoded-payload',
|
|
file: line.file,
|
|
line: line.line,
|
|
detail: 'Added long encoded blob or token-like payload',
|
|
excerpt: trimExcerpt(line.content),
|
|
})
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
export function scanAddedLines(lines: DiffLine[]): Finding[] {
|
|
const findings = lines
|
|
.filter(line => !SELF_EXCLUDED_FILES.has(line.file))
|
|
.flatMap(line => [
|
|
...findUrlFindings(line),
|
|
...findCommandFindings(line),
|
|
...findSensitivePathFindings(line),
|
|
])
|
|
return uniqueFindings(findings)
|
|
}
|
|
|
|
export function getGitDiff(baseRef: string, headRef = 'HEAD'): string {
|
|
const mergeBase = spawnSync('git', ['merge-base', baseRef, headRef], {
|
|
encoding: 'utf8',
|
|
})
|
|
|
|
if (mergeBase.status !== 0) {
|
|
throw new Error(
|
|
`Could not determine merge-base between ${baseRef} and ${headRef}: ${mergeBase.stderr.trim() || mergeBase.stdout.trim()}`,
|
|
)
|
|
}
|
|
|
|
const base = mergeBase.stdout.trim()
|
|
const diff = spawnSync(
|
|
'git',
|
|
['diff', '--unified=0', '--no-ext-diff', `${base}...${headRef}`],
|
|
{ encoding: 'utf8' },
|
|
)
|
|
|
|
if (diff.status !== 0) {
|
|
throw new Error(`git diff failed: ${diff.stderr.trim() || diff.stdout.trim()}`)
|
|
}
|
|
|
|
return diff.stdout
|
|
}
|
|
|
|
function shouldFail(findings: Finding[], failOn: FindingSeverity): boolean {
|
|
if (failOn === 'medium') {
|
|
return findings.length > 0
|
|
}
|
|
return findings.some(finding => finding.severity === 'high')
|
|
}
|
|
|
|
function renderText(findings: Finding[]): string {
|
|
if (findings.length === 0) {
|
|
return 'PR intent scan: no suspicious additions found.'
|
|
}
|
|
|
|
const high = findings.filter(f => f.severity === 'high')
|
|
const medium = findings.filter(f => f.severity === 'medium')
|
|
const lines = [
|
|
`PR intent scan: ${findings.length} finding(s)`,
|
|
`- high: ${high.length}`,
|
|
`- medium: ${medium.length}`,
|
|
'',
|
|
]
|
|
|
|
for (const finding of findings) {
|
|
lines.push(
|
|
`[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line} ${finding.detail}`,
|
|
)
|
|
lines.push(` ${finding.excerpt}`)
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export function run(options: CliOptions): number {
|
|
const diff = getGitDiff(options.baseRef, options.headRef)
|
|
const addedLines = parseAddedLines(diff)
|
|
const findings = scanAddedLines(addedLines)
|
|
|
|
if (options.json) {
|
|
process.stdout.write(
|
|
`${JSON.stringify(
|
|
{
|
|
baseRef: options.baseRef,
|
|
headRef: options.headRef,
|
|
addedLines: addedLines.length,
|
|
findings,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
)
|
|
} else {
|
|
process.stdout.write(`${renderText(findings)}\n`)
|
|
}
|
|
|
|
return shouldFail(findings, options.failOn) ? 1 : 0
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
const options = parseOptions(process.argv.slice(2))
|
|
process.exitCode = run(options)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
console.error(`PR intent scan failed: ${message}`)
|
|
process.exitCode = 1
|
|
}
|
|
}
|