Pinned pi SDK 0.85.1 bridge, Python supervisor, artifact exporter, scripted backend peer, image checks and boundary checks under agent/**. Review findings F1-F3 are recorded in docs/implementation/PI_AGENT_REVIEW.md.
114 lines
5.8 KiB
TypeScript
114 lines
5.8 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { LIMITS, type Warning } from './types.js';
|
|
import { WarningCollector } from './warnings.js';
|
|
|
|
export interface ArtifactFileCandidate { relPath: string; size: number; fd: number; stat: fs.Stats; }
|
|
export interface ArtifactScanResult { candidates: ArtifactFileCandidate[]; warnings: Warning[]; }
|
|
export function validateRelativeArtifactPath(name: string): { valid: boolean; reason?: string } {
|
|
const valid = typeof name === 'string' && !!name && !name.startsWith('/') &&
|
|
!/[\\\x00-\x1f\x7f-\x9f]/.test(name) && Buffer.byteLength(name) <= LIMITS.MAX_PATH_BYTES &&
|
|
name.split('/').every(c => !!c && c !== '.' && c !== '..');
|
|
return valid ? { valid } : { valid: false, reason: 'Invalid relative artifact name' };
|
|
}
|
|
const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
|
|
// Linux descriptor-relative traversal. /proc/self/fd is kernel-owned: following
|
|
// that magic link uses an already-open directory, never an agent symlink.
|
|
function at(fd: number, name: string): string { return `/proc/self/fd/${fd}/${name}`; }
|
|
function openRoot(root: string): number {
|
|
let fd = fs.openSync('/', directoryFlags);
|
|
try {
|
|
for (const component of path.resolve(root).split('/').filter(Boolean)) {
|
|
const next = fs.openSync(at(fd, component), directoryFlags);
|
|
fs.closeSync(fd); fd = next;
|
|
}
|
|
return fd;
|
|
} catch (err) { fs.closeSync(fd); throw err; }
|
|
}
|
|
export function closeArtifacts(candidates: ArtifactFileCandidate[]): void {
|
|
for (const c of candidates) { try { fs.closeSync(c.fd); } catch {} }
|
|
}
|
|
export function scanArtifactsDirectory(root: string): ArtifactScanResult {
|
|
const candidates: ArtifactFileCandidate[] = [];
|
|
const warnings = new WarningCollector();
|
|
const warn = (code: string) => {
|
|
warnings.add({ code, message: 'An artifact was omitted during filesystem validation.' });
|
|
};
|
|
const seen = new Set<string>();
|
|
let total = 0, visited = 0;
|
|
function walk(fd: number, prefix: string): void {
|
|
const dir = fs.opendirSync(`/proc/self/fd/${fd}`);
|
|
try {
|
|
let item: fs.Dirent | null;
|
|
while ((item = dir.readSync())) {
|
|
if (++visited > 10000) { warn('artifact_scan_limit'); return; }
|
|
const name = prefix + item.name;
|
|
if (!validateRelativeArtifactPath(name).valid) { warn('artifact_invalid_path'); continue; }
|
|
const location = at(fd, item.name);
|
|
const stat = fs.lstatSync(location);
|
|
if (stat.isDirectory()) {
|
|
let next: number;
|
|
try { next = fs.openSync(location, directoryFlags); } catch { warn('artifact_open_failed'); continue; }
|
|
try { walk(next, name + '/'); } finally { fs.closeSync(next); }
|
|
if (visited > 10000) return;
|
|
continue;
|
|
}
|
|
if (!stat.isFile() || stat.nlink !== 1) { warn('artifact_not_regular'); continue; }
|
|
const normalized = name.normalize('NFC');
|
|
if (seen.has(normalized)) { warn('artifact_duplicate_name'); continue; }
|
|
seen.add(normalized);
|
|
if (stat.size > LIMITS.ARTIFACT_MAX_FILE_BYTES) { warn('artifact_oversized'); continue; }
|
|
if (candidates.length >= LIMITS.ARTIFACT_MAX_FILES) { warn('artifact_count_exceeded'); continue; }
|
|
if (total + stat.size > LIMITS.ARTIFACT_MAX_TOTAL_BYTES) { warn('artifact_total_limit_exceeded'); continue; }
|
|
let file: number;
|
|
// O_NONBLOCK prevents a raced FIFO open from blocking the exporter.
|
|
try { file = fs.openSync(location, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
|
|
catch { warn('artifact_open_failed'); continue; }
|
|
const opened = fs.fstatSync(file);
|
|
if (!opened.isFile() || opened.nlink !== 1 || opened.ino !== stat.ino || opened.dev !== stat.dev || opened.size !== stat.size) {
|
|
fs.closeSync(file); warn('artifact_descriptor_mismatch'); continue;
|
|
}
|
|
total += opened.size;
|
|
// Preserve the verified descriptor across backend acknowledgements.
|
|
candidates.push({ relPath: name, fd: file, size: opened.size, stat: opened });
|
|
}
|
|
} finally { dir.closeSync(); }
|
|
}
|
|
let fd: number;
|
|
try { fd = openRoot(root); } catch { warn('artifact_root_rejected'); return { candidates, warnings: warnings.snapshot() }; }
|
|
try { walk(fd, ''); } catch { warn('artifact_scan_failed'); } finally { fs.closeSync(fd); }
|
|
return { candidates, warnings: warnings.snapshot() };
|
|
}
|
|
export interface ArtifactTransferCallbacks {
|
|
sendBegin: (id: string, name: string, size: number) => Promise<'accept' | 'skip'>;
|
|
sendChunk: (id: string, index: number, data: string) => Promise<void>;
|
|
sendEnd: (id: string, size: number, chunks: number) => Promise<void>;
|
|
}
|
|
function unchanged(c: ArtifactFileCandidate): void {
|
|
const s = fs.fstatSync(c.fd);
|
|
if (!s.isFile() || s.nlink !== 1 || s.ino !== c.stat.ino || s.dev !== c.stat.dev || s.size !== c.size ||
|
|
s.mtimeMs !== c.stat.mtimeMs || s.ctimeMs !== c.stat.ctimeMs) throw new Error('Artifact changed after validation');
|
|
}
|
|
export async function exportArtifacts(candidates: ArtifactFileCandidate[], callbacks: ArtifactTransferCallbacks): Promise<number> {
|
|
let stored = 0, seq = 0;
|
|
const buffer = Buffer.alloc(LIMITS.ARTIFACT_CHUNK_MAX_DECODED_BYTES);
|
|
for (const c of candidates) {
|
|
unchanged(c);
|
|
const id = `a_t_${++seq}`;
|
|
if (await callbacks.sendBegin(id, c.relPath, c.size) === 'skip') continue;
|
|
unchanged(c);
|
|
let offset = 0, chunks = 0;
|
|
while (offset < c.size) {
|
|
const n = fs.readSync(c.fd, buffer, 0, Math.min(buffer.length, c.size - offset), offset);
|
|
if (!n) throw new Error('Artifact truncated');
|
|
offset += n;
|
|
await callbacks.sendChunk(id, chunks++, buffer.subarray(0, n).toString('base64'));
|
|
}
|
|
if (fs.readSync(c.fd, buffer, 0, 1, offset) !== 0) throw new Error('Artifact grew');
|
|
unchanged(c);
|
|
await callbacks.sendEnd(id, offset, chunks);
|
|
stored++;
|
|
}
|
|
return stored;
|
|
}
|