import * as fs from 'node:fs'; import { createHash } from 'node:crypto'; import { dirname, isAbsolute, join, resolve, relative, sep } from 'node:path'; import { CsoError, SnapshotManifest, SnapshotEntry, SnapshotPathIdentity, canonical, sha256, relativePath, snapshotOriginalIdentity, snapshotPathHandle, snapshotPathId, MAX_OUTPUT } from './contracts'; import { childEnvironment, executable, git, redact, runProcess } from './process'; import { secureDirectory, writeHelperJson, writeJson } from './state'; import { scan } from '../redact-engine'; import { atomicWriteSync } from '../fs-atomic'; const NO_READ_COMPONENTS=new Set(['.git','.hg','.svn','node_modules','.venv','venv','__pycache__','.bundle','.cache','.context','.gstack']); const OMIT_COMPONENTS=new Set([...NO_READ_COMPONENTS,'.claude','.agents','.codex','.cursor']); function containsDirectory(path:string,components:Set,sequences:string[][]=[]):boolean{ const parts=path.split('/');if(parts.slice(0,-1).some(part=>components.has(part)))return true; return sequences.some(sequence=>parts.slice(0,-1).some((_,index)=>sequence.every((part,offset)=>parts[index+offset]===part))); } function noReadPath(path:string):boolean{return containsDirectory(path,NO_READ_COMPONENTS,[['vendor','bundle']]);} function omittedPath(path:string):boolean{return containsDirectory(path,OMIT_COMPONENTS,[['vendor','bundle'],['.github','agents']]);} const SECRET_FILE = /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i; const SOURCE_LIMIT=64*1024*1024; const SNAPSHOT_ENTRY_LIMIT=100_000; const GIT_POINTER_LIMIT=8192; export interface SnapshotCaptureLimits { deadlineMs?:number; maxEntries?:number } type BoundPathIdentity={path:string;kind:'directory'|'file';dev:number;ino:number;mode:number;size:number;mtimeMs:number;ctimeMs:number;contentHash?:string}; type RepositoryIdentity={root:BoundPathIdentity;metadata:BoundPathIdentity[]}; function boundPath(path:string,label:string,maxBytes=GIT_POINTER_LIMIT):{identity:BoundPathIdentity;content?:string}{ let named:fs.Stats;try{named=fs.lstatSync(path);}catch{throw new CsoError('SNAPSHOT_RACE',`${label} disappeared during snapshot capture`);} if(named.isSymbolicLink())throw new CsoError('UNSAFE_PATH',`${label} cannot be a symlink`); if(named.isDirectory())return{identity:{path,kind:'directory',dev:named.dev,ino:named.ino,mode:named.mode,size:named.size,mtimeMs:named.mtimeMs,ctimeMs:named.ctimeMs}}; if(!named.isFile()||named.nlink!==1||named.size>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} must be a bounded regular file or directory`); let fd:number|undefined;try{ fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|(fs.constants.O_NONBLOCK??0));const opened=fs.fstatSync(fd); if(!opened.isFile()||opened.nlink!==1||opened.dev!==named.dev||opened.ino!==named.ino||opened.mode!==named.mode||opened.size!==named.size)throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be read`); const buffer=Buffer.alloc(maxBytes+1);let bytes=0,count=0;while(bytes0)bytes+=count; const after=fs.fstatSync(fd),current=fs.lstatSync(path);if(bytes>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} exceeds its bounded size limit`); if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||after.size!==opened.size||after.mtimeMs!==opened.mtimeMs||after.ctimeMs!==opened.ctimeMs)throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was read`); const body=buffer.subarray(0,bytes);return{identity:{path,kind:'file',dev:after.dev,ino:after.ino,mode:after.mode,size:after.size,mtimeMs:after.mtimeMs,ctimeMs:after.ctimeMs,contentHash:sha256(body)},content:body.toString('utf8')}; }catch(error){if(error instanceof CsoError)throw error;const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be opened`);throw new CsoError('UNSAFE_PATH',`${label} could not be read as a bounded regular file`);}finally{if(fd!==undefined)fs.closeSync(fd);} } function sameIdentity(expected:BoundPathIdentity,current:BoundPathIdentity):boolean{return expected.path===current.path&&expected.kind===current.kind&&expected.dev===current.dev&&expected.ino===current.ino&&expected.mode===current.mode&&expected.size===current.size&&expected.mtimeMs===current.mtimeMs&&expected.ctimeMs===current.ctimeMs&&expected.contentHash===current.contentHash;} function repositoryIdentity(repo:string):RepositoryIdentity{ const root=boundPath(repo,'Audited repository root').identity;if(root.kind!=='directory')throw new CsoError('MISSING_INPUT','Audited repository root is not a directory'); const markerPath=join(repo,'.git'),marker=boundPath(markerPath,'Repository .git marker'),metadata=[marker.identity];let gitDir:string; if(marker.identity.kind==='directory')gitDir=fs.realpathSync(markerPath); else{const value=marker.content??'',match=value.match(/^gitdir:\s*(.+?)\s*$/);if(!match||value.includes('\0')||value.split(/\r?\n/).filter(Boolean).length!==1)throw new CsoError('UNSAFE_PATH','Repository .git pointer is invalid');gitDir=fs.realpathSync(resolve(dirname(markerPath),match[1]));} const gitDirIdentity=boundPath(gitDir,'Repository Git directory').identity;if(gitDirIdentity.kind!=='directory')throw new CsoError('UNSAFE_PATH','Repository Git directory is not a directory');metadata.push(gitDirIdentity); const commonMarker=join(gitDir,'commondir');let commonDir=gitDir; if(fs.existsSync(commonMarker)){const marker=boundPath(commonMarker,'Repository common Git directory pointer');if(marker.identity.kind!=='file')throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid');metadata.push(marker.identity);const value=(marker.content??'').trim();if(!value||value.includes('\0')||value.includes('\n')||value.includes('\r'))throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid');commonDir=fs.realpathSync(resolve(gitDir,value));} const commonIdentity=boundPath(commonDir,'Repository common Git directory').identity;if(commonIdentity.kind!=='directory')throw new CsoError('UNSAFE_PATH','Repository common Git directory is not a directory');metadata.push(commonIdentity); const unique=[...new Map(metadata.map(item=>[item.path,item])).values()];const identity={root,metadata:unique};assertRepositoryIdentity(identity);return identity; } function assertRepositoryIdentity(expected:RepositoryIdentity):void{ const compare=(item:BoundPathIdentity,label:string)=>{const current=boundPath(item.path,label,item.kind==='file'?Math.max(GIT_POINTER_LIMIT,item.size):GIT_POINTER_LIMIT).identity;if(!sameIdentity(item,current))throw new CsoError('SNAPSHOT_RACE',`${label} changed during snapshot capture`);}; compare(expected.root,'Audited repository root');for(const item of expected.metadata)compare(item,'Repository Git metadata identity');compare(expected.root,'Audited repository root'); } function snapshotAdmission(limits:SnapshotCaptureLimits={}){ const deadlineMs=limits.deadlineMs??Date.now()+9*60_000,maxEntries=Math.min(limits.maxEntries??SNAPSHOT_ENTRY_LIMIT,SNAPSHOT_ENTRY_LIMIT); if(!Number.isSafeInteger(deadlineMs)||!Number.isSafeInteger(maxEntries)||maxEntries<1)throw new CsoError('INVALID_ARGUMENT','Invalid snapshot admission limits'); const time=()=>{if(Date.now()>=deadlineMs)throw new CsoError('DEADLINE','Snapshot capture exhausted the investigation budget before a report could be created');}; const count=(entries:number)=>{if(entries>maxEntries)throw new CsoError('MISSING_INPUT',`Source tree exceeds the ${maxEntries}-entry snapshot admission limit`);}; return{time,count}; } export function exclusion(path: string): string | undefined { if (omittedPath(path)) return 'host dependencies, metadata, state, or agent configuration'; if (SECRET_FILE.test(path)) return 'credential or execution configuration'; } export function containedFile(root: string, path: string): string { const rel = relativePath(path), full = join(root,rel); let cursor = root; for (const part of rel.split('/')) { cursor = join(cursor,part); try { if (fs.lstatSync(cursor).isSymbolicLink()) throw new CsoError('UNSAFE_PATH',`Symlink is not an execution input: ${rel}`); } catch (error) { if (error instanceof CsoError) throw error; if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } if (!full.startsWith(root + sep)) throw new CsoError('UNSAFE_PATH','Path escaped snapshot'); return full; } type DirectoryIdentity={path:string;dev:number;ino:number;mode:number}; function inside(root:string,candidate:string):boolean{const relation=relative(root,candidate);return relation===''||(relation!=='..'&&!relation.startsWith(`..${sep}`)&&!isAbsolute(relation));} function directoryIdentities(root:string,path:string):DirectoryIdentity[]{ const rel=relativePath(path),parts=rel.split('/'),identities:DirectoryIdentity[]=[];let cursor=root; for(const part of ['',...parts.slice(0,-1)]){ if(part)cursor=join(cursor,part); let stat:fs.Stats;try{stat=fs.lstatSync(cursor);}catch{throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while opening: ${rel}`);} if(stat.isSymbolicLink()||!stat.isDirectory())throw new CsoError('UNSAFE_PATH',`Symlink or non-directory source ancestor: ${rel}`); identities.push({path:cursor,dev:stat.dev,ino:stat.ino,mode:stat.mode}); } return identities; } function assertDirectoryIdentities(identities:DirectoryIdentity[],path:string):void{ for(const expected of identities){let current:fs.Stats;try{current=fs.lstatSync(expected.path);}catch{throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while reading: ${path}`);} if(current.isSymbolicLink()||!current.isDirectory()||current.dev!==expected.dev||current.ino!==expected.ino||current.mode!==expected.mode)throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while reading: ${path}`); } } /** Validate the resolved inode after open so an ancestor-symlink swap cannot escape root. */ export function assertOpenedFileContained(root:string,full:string,fd:number,opened:fs.Stats):void{ if(process.platform==='linux'){ let actual:string,current:fs.Stats;try{actual=fs.readlinkSync(`/proc/self/fd/${fd}`);current=fs.fstatSync(fd);}catch{throw new CsoError('SNAPSHOT_RACE','Opened source identity could not be resolved');} if(current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode)throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation'); if(!isAbsolute(actual)||!inside(root,actual))throw new CsoError('UNSAFE_PATH','Opened source escaped the audited root'); return; } let resolved:string,current:fs.Stats;try{resolved=fs.realpathSync(full);current=fs.lstatSync(resolved);}catch{throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation');} if(!inside(root,resolved))throw new CsoError('UNSAFE_PATH','Opened source escaped the audited root'); if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||current.size!==opened.size)throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation'); } function readStable(root: string, path: string, maxBytes=MAX_OUTPUT): {data:Buffer; mode:number} { const ancestors=directoryIdentities(root,path),full = containedFile(root,path), named=fs.lstatSync(full); // Prove the pathname is a regular single-link file before open. Opening a // FIFO or device merely to discover its type can block or trigger host I/O. if(named.isSymbolicLink()||!named.isFile()||named.nlink!==1)throw new CsoError('UNSAFE_PATH',`Special or hard-linked source file: ${path}`); let fd:number;try{fd=fs.openSync(full,fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW??0) | (fs.constants.O_NONBLOCK??0));}catch(error){const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`Source changed before it could be opened: ${path}`);throw new CsoError('UNSAFE_PATH',`Source could not be opened as a regular file: ${path}`);} try { const before = fs.fstatSync(fd); if (!before.isFile() || before.nlink !== 1 || before.dev!==named.dev || before.ino!==named.ino || before.mode!==named.mode || before.size!==named.size) throw new CsoError('UNSAFE_PATH',`Special or hard-linked source file: ${path}`); assertOpenedFileContained(root,full,fd,before);assertDirectoryIdentities(ancestors,path); if (before.size > maxBytes) throw new CsoError('MISSING_INPUT',`Source file exceeds the ${maxBytes}-byte snapshot admission limit: ${path}`); const buffer=Buffer.alloc(Math.min(maxBytes+1,before.size+1));let bytes=0,count=0;while(bytes0)bytes+=count;const data=buffer.subarray(0,bytes),after = fs.fstatSync(fd), current = fs.lstatSync(full); if (!current.isFile()||current.nlink!==1||before.ino !== current.ino || before.dev !== current.dev || before.mode!==current.mode || before.size !== after.size || before.size!==bytes || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new CsoError('SNAPSHOT_RACE',`Source changed while reading: ${path}`); assertOpenedFileContained(root,full,fd,after);assertDirectoryIdentities(ancestors,path); return {data,mode:before.mode & 0o777}; } finally { fs.closeSync(fd); } } async function resolveHeadCommit(repo:string,home:string):Promise{ try{return(await git(repo,['rev-parse','--verify','HEAD^{commit}'],home)).trim();} catch(error){ // A symbolic HEAD whose target does not exist is the normal unborn-branch // state. A detached/malformed HEAD or a ref to a non-commit remains an // input error instead of being silently treated as an empty history. try{await git(repo,['symbolic-ref','--quiet','HEAD'],home);}catch{throw error;} try{await git(repo,['rev-parse','--verify','HEAD'],home);}catch{return undefined;} throw error; } } async function paths(repo: string, home: string,headCommit:string|undefined,admission:ReturnType): Promise { // The index omits staged deletions. Union the pinned HEAD tree so every // tracked deletion is represented even when no comparison base was asked // for, while still collecting nonignored untracked source. admission.time();const [working,head]=await Promise.all([ git(repo,['ls-files','--cached','--others','--exclude-standard','-z'],home), headCommit?git(repo,['ls-tree','-r','-z','--name-only','--full-tree',headCommit,'--'],home):Promise.resolve(''), ]),seen=new Set();admission.time(); for(const data of [working,head])for(const value of data.split('\0')){admission.time();if(!value)continue;seen.add(relativePath(value));admission.count(seen.size);} const result=[...seen].sort();admission.time();return result; } async function rejectSpecialFiles(repo:string,home:string,admission:ReturnType):Promise{ // Git intentionally omits untracked FIFOs and devices from ls-files. Walk // pathnames without opening payloads, then ask Git which special names are // ignored so a nonignored FIFO cannot silently disappear from the snapshot. admission.time();const ignoredRaw=await git(repo,['ls-files','--others','--ignored','--exclude-standard','--directory','-z'],home),ignoredDirectories=new Set();admission.time(); for(const value of ignoredRaw.split('\0')){admission.time();if(!value)continue;ignoredDirectories.add(relativePath(value.replace(/\/$/,'')));admission.count(ignoredDirectories.size);} const ignoredDirectory=(path:string)=>{let candidate=path;for(;;){if(ignoredDirectories.has(candidate))return true;const slash=candidate.lastIndexOf('/');if(slash<0)return false;candidate=candidate.slice(0,slash);}}; const special:string[]=[];let visited=0; const walk=(at:string,prefix='')=>{const directory=fs.opendirSync(at);try{let item:fs.Dirent|null;while((item=directory.readSync())!==null){ admission.time();const path=relativePath(prefix?`${prefix}/${item.name}`:item.name);if(noReadPath(path)||ignoredDirectory(path))continue;admission.count(++visited); const full=join(at,item.name),stat=fs.lstatSync(full);if(stat.isDirectory()){walk(full,path);continue;}if(!stat.isFile())special.push(path); }}finally{directory.closeSync();}};walk(repo); if(!special.length)return; const nullPath=process.platform==='win32'?'NUL':'/dev/null',result=await runProcess(executable('git'),['--no-optional-locks','-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`,'-c','core.pager=cat','-C',repo,'check-ignore','--no-index','-z','--stdin'],{cwd:home,env:childEnvironment(home),raw:true,input:`${special.join('\0')}\0`,timeoutMs:15_000}); if(![0,1].includes(result.code)||result.timedOut||result.truncated)throw new CsoError('MISSING_INPUT','Could not determine whether special source paths are ignored'); admission.time();const ignored=new Set(result.stdout.split('\0').filter(Boolean).map(relativePath)),unsafe=special.find(path=>!ignored.has(path)); if(unsafe)throw new CsoError('UNSAFE_PATH',`Symlink or special source file: ${unsafe}`); } export async function capture(repo: string, runDir: string, base?: string, requiredAncestor?:string, limits:SnapshotCaptureLimits={}): Promise { repo = fs.realpathSync(repo); const state=fs.realpathSync(runDir),relation=relative(repo,state);if(relation===''||(!relation.startsWith(`..${sep}`)&&relation!=='..'&&!isAbsolute(relation)))throw new CsoError('UNSAFE_PATH','Security state must be outside the audited repository'); const repository=repositoryIdentity(repo),home = secureDirectory(join(runDir,'home')), snapshot = secureDirectory(join(runDir,'snapshot')), readable = secureDirectory(join(runDir,'readable')),admission=snapshotAdmission(limits),guard=()=>{admission.time();assertRepositoryIdentity(repository);}; try { const entries: SnapshotEntry[] = [], gitHashes=new Map(), gitModes=new Map(), sensitiveEvidence:any[]=[], absentPaths=new Set(); let total = 0;guard(); const objectFormat=(await git(repo,['rev-parse','--show-object-format'],home)).trim();guard(); if(!['sha1','sha256'].includes(objectFormat))throw new CsoError('INCOMPATIBLE_INPUT','Unsupported Git object format'); const headCommit = await resolveHeadCommit(repo,home);guard(); const list = await paths(repo,home,headCommit,admission);guard();await rejectSpecialFiles(repo,home,admission);guard(); const manifest: SnapshotManifest = {version:3,root:repo,createdAt:new Date().toISOString(),expiresAt:new Date(Date.now()+7*86400_000).toISOString(),entries,...(headCommit?{headCommit}:{}),originalHash:'',executionHash:''}; if (base) { if (!/^[A-Za-z0-9_.\/-]+$/.test(base) || base.startsWith('-')) throw new CsoError('INVALID_ARGUMENT','Invalid comparison base'); manifest.baseCommit = (await git(repo,['rev-parse','--verify',`${base}^{commit}`],home)).trim();guard(); } for (const path of list) { guard(); try{fs.lstatSync(join(repo,path));}catch(error:any){if(error?.code==='ENOENT'){absentPaths.add(path);continue;}throw error;} // tracked deletions are represented by absence and the diff manifest // Host dependency trees aren't copied or read. Their omission is still explicit. if (noReadPath(path)) { const stat = fs.lstatSync(containedFile(repo,path)); if (stat.isSymbolicLink() || !stat.isFile()) throw new CsoError('UNSAFE_PATH',`Special source input: ${path}`); gitModes.set(path,(stat.mode&0o111)?'100755':'100644'); const reason=exclusion(path)??'host dependency input'; entries.push({path,pathId:snapshotPathId(repo,path),originalHash:'not-read',bytes:stat.size,mode:stat.mode & 0o777,transformation:`excluded: ${reason}`});guard();continue; } const {data,mode} = readStable(repo,path,SOURCE_LIMIT); total += data.length; guard(); if (total > SOURCE_LIMIT) throw new CsoError('MISSING_INPUT','Source exceeds the 64 MiB snapshot admission limit'); const entry: SnapshotEntry = {path,pathId:snapshotPathId(repo,path),originalHash:sha256(data),bytes:data.length,mode}; entries.push(entry); gitHashes.set(path,createHash(objectFormat).update(`blob ${data.length}\0`).update(data).digest('hex')); gitModes.set(path,(mode&0o111)?'100755':'100644'); if(data.length>MAX_OUTPUT){entry.transformation='withheld: exceeds the 1 MiB redacting-reader limit';continue;} let sanitized: string; try { sanitized = new TextDecoder('utf-8',{fatal:true}).decode(data); if (sanitized.includes('\0')) throw new Error('binary'); const findings=scan(sanitized,{maxBytes:MAX_OUTPUT}).findings; if(findings.length)sensitiveEvidence.push({path:snapshotPathHandle(entry.pathId),findings:findings.map(f=>({id:f.id,tier:f.tier,line:f.line,col:f.col}))}); sanitized = redact(sanitized); } catch { entry.transformation = exclusion(path)?`excluded: ${exclusion(path)}; payload withheld because redaction could not safely preserve it`:'withheld: binary or redaction failed'; continue; } const out = containedFile(readable,path); secureDirectory(dirname(out)); fs.writeFileSync(out,sanitized,{mode:0o600}); const reason = exclusion(path); if (reason) { entry.transformation = `excluded: ${reason}`; continue; } if (sha256(sanitized) !== entry.originalHash) entry.transformation = 'secret spans redacted'; const target = containedFile(snapshot,path); secureDirectory(dirname(target)); fs.writeFileSync(target,sanitized,{mode}); // writeFile's creation mode is filtered through the caller's umask. The // skill deliberately starts with umask 077, while the manifest binds the // original mode because executable bits are part of the application // input. Restore the exact recorded mode after creation; the snapshot's // owned 0700 ancestors still keep every retained source file private. fs.chmodSync(target,mode); entry.executionHash = sha256(sanitized); } const deletedPaths:SnapshotPathIdentity[]=[...absentPaths].sort().map(path=>({path,pathId:snapshotPathId(repo,path)}));if(deletedPaths.length)manifest.deletedPaths=deletedPaths; const assertAbsent=()=>{for(const path of absentPaths){admission.time();try{fs.lstatSync(containedFile(repo,path));}catch(error:any){if(error?.code==='ENOENT')continue;throw error;}throw new CsoError('SNAPSHOT_RACE',`Deleted source path reappeared during snapshot capture: ${path}`);}}; const assertEntriesStable=(message:string)=>{for(const e of entries){guard();if(e.originalHash==='not-read'){const current=fs.lstatSync(containedFile(repo,e.path));if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.size!==e.bytes||(current.mode&0o777)!==e.mode)throw new CsoError('SNAPSHOT_RACE',`${message}: ${e.path}`);}else{const current=readStable(repo,e.path,SOURCE_LIMIT);if(sha256(current.data)!==e.originalHash||current.mode!==e.mode)throw new CsoError('SNAPSHOT_RACE',`${message}: ${e.path}`);}guard();}}; if (canonical(list) !== canonical(await paths(repo,home,headCommit,admission))) throw new CsoError('SNAPSHOT_RACE','Source file membership changed during snapshot');guard();assertAbsent();assertEntriesStable('Source changed during capture'); manifest.originalHash = snapshotOriginalIdentity(entries,deletedPaths); manifest.executionHash = sha256(canonical(entries.filter(e => e.executionHash).map(e => [e.path,e.executionHash,e.mode]))); if(manifest.baseCommit){ const tree=await git(repo,['ls-tree','-r','-z','--full-tree',manifest.baseCommit,'--'],home),baseFiles=new Map();guard(); for(const row of tree.split('\0').filter(Boolean)){admission.time();const match=row.match(/^(\d+) (?:blob|commit) ([a-f0-9]+)\t(.+)$/s);if(match)baseFiles.set(relativePath(match[3]),{mode:match[1],hash:match[2]});admission.count(baseFiles.size);} const differs=(path:string):boolean=>{const baseEntry=baseFiles.get(path),hash=gitHashes.get(path),mode=gitModes.get(path);return !baseEntry||hash!==baseEntry.hash||mode!==baseEntry.mode;}; manifest.changedPaths=[...new Set([...list.filter(differs),...baseFiles.keys()].filter(path=>differs(path)||!gitModes.has(path)))].sort(); } try{ if(!headCommit){atomicWriteSync(join(runDir,'history.txt'),'',{mode:0o600});writeJson(join(runDir,'history-status.json'),{status:'captured',range:'unborn HEAD',commits:0,bytes:0});} else{ const range=manifest.baseCommit?`${manifest.baseCommit}..${headCommit}`:headCommit; admission.time();const raw=await git(repo,['-c','core.quotePath=false','log','--no-ext-diff','--no-textconv','--max-count=100','--format=commit %H%nAuthor: %an%nDate: %aI%nSubject: %s','--unified=3','-p',range,'--'],home);guard();const safe=redact(raw); atomicWriteSync(join(runDir,'history.txt'),safe,{mode:0o600});writeJson(join(runDir,'history-status.json'),{status:'captured',range,commits:'at most 100',bytes:Buffer.byteLength(safe)}); } }catch(error){if(error instanceof CsoError&&['DEADLINE','SNAPSHOT_RACE','UNSAFE_PATH'].includes(error.code))throw error;writeJson(join(runDir,'history-status.json'),{status:'not_assessed',gap:error instanceof CsoError?error.message:'Historical evidence could not be safely retained'});} if((await resolveHeadCommit(repo,home))!==headCommit)throw new CsoError('SNAPSHOT_RACE','HEAD changed during snapshot capture');guard(); if(base&&manifest.baseCommit&&(await git(repo,['rev-parse','--verify',`${base}^{commit}`],home)).trim()!==manifest.baseCommit)throw new CsoError('SNAPSHOT_RACE','Comparison base changed during snapshot capture');guard(); if(requiredAncestor){ if(!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(requiredAncestor))throw new CsoError('INCOMPATIBLE_INPUT','Original audit commit identity is invalid'); if(!headCommit)throw new CsoError('INCOMPATIBLE_INPUT','Captured current source has no commit descended from the original audit'); try{await git(repo,['merge-base','--is-ancestor',requiredAncestor,headCommit],home);} catch{throw new CsoError('INCOMPATIBLE_INPUT','Captured current source is not a descendant of the original audited commit');} guard(); } // Finish with a complete source check. Nothing below this block reads the // audited repository, so a late nonignored file or restored deletion cannot // fall between the final inventory and manifest publication. await rejectSpecialFiles(repo,home,admission);guard();assertEntriesStable('Source changed before snapshot persistence'); if(canonical(list)!==canonical(await paths(repo,home,headCommit,admission)))throw new CsoError('SNAPSHOT_RACE','Source file membership changed before snapshot persistence');guard();assertAbsent(); admission.time();writeHelperJson(join(runDir,'sensitive-evidence.json'),sensitiveEvidence); // The manifest contains helper-computed identities and source pathnames but // never source payloads. Persist it exactly in private state: generic // content redaction would silently break the path/hash identity relation. const serialized=JSON.stringify(manifest,null,2);if(Buffer.byteLength(serialized)+1>MAX_OUTPUT)throw new CsoError('MISSING_INPUT','Snapshot manifest exceeds the 1 MiB private-state admission limit');atomicWriteSync(join(runDir,'snapshot.json'),serialized+'\n',{mode:0o600}); return manifest; } catch(e) { fs.rmSync(snapshot,{recursive:true,force:true}); fs.rmSync(readable,{recursive:true,force:true});fs.rmSync(home,{recursive:true,force:true}); throw e; } } export function assertSnapshot(runDir: string, manifest: SnapshotManifest): void { const root=join(runDir,'snapshot'); if (manifest.version!==3||typeof manifest.root!=='string'||!isAbsolute(manifest.root)||!Array.isArray(manifest.entries)||(manifest.deletedPaths!==undefined&&!Array.isArray(manifest.deletedPaths))||(manifest.headCommit!==undefined&&!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.headCommit))||(manifest.baseCommit!==undefined&&!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.baseCommit))||!/^\d{4}-\d\d-\d\dT/.test(manifest.expiresAt)||Date.parse(manifest.expiresAt) <= Date.now() || !fs.existsSync(root)) throw new CsoError('MISSING_INPUT','Retained source expired or invalid; supply source with exactly matching required hashes'); const rootStat=fs.lstatSync(root);if(rootStat.isSymbolicLink()||!rootStat.isDirectory()||(process.getuid&&rootStat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Retained snapshot root is not a private owned directory'); const listed=():string[]=>{const out:string[]=[];const walk=(at:string,relativeRoot='')=>{for(const item of fs.readdirSync(at,{withFileTypes:true})){const rel=relativeRoot?`${relativeRoot}/${item.name}`:item.name,full=join(at,item.name),stat=fs.lstatSync(full);if(stat.isSymbolicLink()||(!stat.isDirectory()&&!stat.isFile()))throw new CsoError('UNSAFE_PATH',`Special file entered retained snapshot: ${rel}`);if(stat.isDirectory())walk(full,rel);else out.push(relativePath(rel));}};walk(root);return out.sort();}; const entries=manifest.entries.map(e=>{if(!e||typeof e!=='object')throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid entry');const path=relativePath(e.path);if(!/^[a-f0-9]{32}$/.test(e.pathId)||e.pathId!==snapshotPathId(manifest.root,path)||!(/^[a-f0-9]{64}$/.test(e.originalHash)||e.originalHash==='not-read')||!Number.isSafeInteger(e.bytes)||e.bytes<0||!Number.isInteger(e.mode)||e.mode<0||e.mode>0o777||(e.transformation!==undefined&&typeof e.transformation!=='string'))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid source entry');if(e.executionHash!==undefined&&!/^[a-f0-9]{64}$/.test(e.executionHash))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid execution entry');if(e.originalHash==='not-read'&&(e.executionHash!==undefined||!e.transformation))throw new CsoError('INCOMPATIBLE_INPUT','Unread source cannot be represented as an execution input');return{...e,path};}); const deleted=(manifest.deletedPaths??[]).map(item=>{if(!item||typeof item!=='object'||Object.keys(item).some(key=>!['path','pathId'].includes(key)))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid deleted path');const path=relativePath(item.path);if(!/^[a-f0-9]{32}$/.test(item.pathId)||item.pathId!==snapshotPathId(manifest.root,path))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid deleted path');return{path,pathId:item.pathId};}); const presentPaths=new Set(entries.map(e=>e.path)),presentIds=new Set(entries.map(e=>e.pathId)),deletedPaths=new Set(deleted.map(item=>item.path)),deletedIds=new Set(deleted.map(item=>item.pathId)); if(presentPaths.size!==entries.length||presentIds.size!==entries.length||deletedPaths.size!==deleted.length||deletedIds.size!==deleted.length||deleted.some(item=>presentPaths.has(item.path)||presentIds.has(item.pathId))||canonical(deleted.map(item=>item.path))!==canonical([...deletedPaths].sort())||!/^([a-f0-9]{64})$/.test(manifest.originalHash)||snapshotOriginalIdentity(entries,deleted)!==manifest.originalHash)throw new CsoError('INCOMPATIBLE_INPUT','Snapshot original identity is inconsistent'); if(manifest.changedPaths!==undefined){if(!Array.isArray(manifest.changedPaths))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot changed paths are invalid');const changed=manifest.changedPaths.map(relativePath);if(new Set(changed).size!==changed.length||canonical(changed)!==canonical([...changed].sort()))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot changed paths are invalid');} // Capture already records entries in Git's deterministic code-unit path // order. Preserve that manifest order here: localeCompare can reorder an // uppercase path such as README.md after lowercase source files, producing // a different execution identity from the one written at capture time. const expected=entries.filter(e=>e.executionHash); if(!/^([a-f0-9]{64})$/.test(manifest.executionHash)||sha256(canonical(expected.map(e=>[e.path,e.executionHash,e.mode])))!==manifest.executionHash)throw new CsoError('INCOMPATIBLE_INPUT','Snapshot execution identity is inconsistent'); const before=listed();if(canonical(before)!==canonical(expected.map(e=>e.path)))throw new CsoError('INCOMPATIBLE_INPUT','Retained snapshot membership changed'); for (const e of expected) { const current=readStable(root,e.path); if (sha256(current.data) !== e.executionHash || current.mode!==e.mode) throw new CsoError('INCOMPATIBLE_INPUT',`Retained snapshot changed: ${e.path}`); } if(canonical(before)!==canonical(listed()))throw new CsoError('SNAPSHOT_RACE','Retained snapshot membership changed during validation'); }