Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/adapters/shellCommandGuard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ const rewriteBypasses = [
];

describe('destructive-command guard sees what the shell will run (AGT-3436)', () => {
it.each([
'cat <(rm -rf /foo)',
'diff <(git reset --hard) out.txt',
'tee >(rm -rf /foo)',
])('blocks destructive process substitution: %s', (command) => {
expect(isCommandBlocked(command)).toBe(true);
});

it.each(rewriteBypasses)('blocks the shell-rewritten form: %s', (command) => {
expect(isCommandBlocked(command)).toBe(true);
});
Expand Down
14 changes: 13 additions & 1 deletion src/adapters/shellCommandGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,9 @@ interface SubstitutionSpan {
*/
function substitutionSpanAt(command: string, i: number): SubstitutionSpan | 'none' | 'unclosed' {
const char = command[i];
const processSubstitution = (char === '<' || char === '>') && command[i + 1] === '(';
const dollar = char === '$' && (command[i + 1] === '(' || command[i + 1] === '{');
if (char !== '`' && !dollar) return 'none';
if (char !== '`' && !dollar && !processSubstitution) return 'none';
const end = endOfSubstitution(command, i);
if (end < 0) return 'unclosed';
const runsCommand = char === '`' || command[i + 1] === '(';
Expand Down Expand Up @@ -299,6 +300,17 @@ function resolveCommands(command: string): ResolvedCommand[] | null {
continue;
}
if (char === '>' || char === '<') {
const span = substitutionSpanAt(command, i);
if (span === 'unclosed') return null;
if (span !== 'none') {
if (isWordChar(word[word.length - 1]) || isWordChar(command[span.end])) return null;
word += span.text;
wordStarted = true;
atWordStart = false;
if (span.runsCommand) nested.push(span.body);
i = span.end - 1;
continue;
}
endWord();
atWordStart = true;
redirectTarget = false;
Expand Down
26 changes: 24 additions & 2 deletions src/core/traceCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ describe('TraceCollector', () => {
collector = new TraceCollector();
});

it('bounds retained names, metadata, and error payloads by UTF-8 bytes', () => {
const bounded = new TraceCollector();
const traceId = bounded.startTrace('😀'.repeat(100_000), { kept: 'yes', huge: 'x'.repeat(100_000) });
const spanId = bounded.startSpan(traceId, 'é'.repeat(100_000), undefined, { kept: 'yes', huge: 'y'.repeat(100_000) })!;
bounded.recordError(traceId, spanId, {
message: 'm'.repeat(100_000), stack: 's'.repeat(100_000), code: 'c'.repeat(100_000),
});

const trace = bounded.getTrace(traceId)!;
const span = trace.spans[0];
expect(Buffer.byteLength(trace.name, 'utf8')).toBeLessThanOrEqual(1024);
expect(Buffer.byteLength(span.name, 'utf8')).toBeLessThanOrEqual(1024);
expect(Buffer.byteLength(JSON.stringify(trace.metadata), 'utf8')).toBeLessThanOrEqual(4096);
expect(Buffer.byteLength(JSON.stringify(span.metadata), 'utf8')).toBeLessThanOrEqual(4096);
expect(Buffer.byteLength(JSON.stringify(span.errorInfo), 'utf8')).toBeLessThanOrEqual(4096);
expect(trace.metadata.kept).toBe('yes');
expect(span.metadata.kept).toBe('yes');
expect(trace.name.endsWith('…')).toBe(true);
expect(span.name.endsWith('…')).toBe(true);
});

// ============================================
// Trace 생성/종료
// ============================================
Expand Down Expand Up @@ -321,7 +342,7 @@ describe('TraceCollector', () => {
expect(span.metadata.array).toHaveLength(3);
});

it('should handle very large metadata values', () => {
it('should bound very large metadata values', () => {
const traceId = collector.startTrace('session');
const largeString = 'A'.repeat(100000);
collector.startSpan(traceId, 'worker', undefined, {
Expand All @@ -330,7 +351,8 @@ describe('TraceCollector', () => {

const trace = collector.getTrace(traceId);
const span = trace!.spans[0];
expect((span.metadata.largeData as string).length).toBe(100000);
expect(Buffer.byteLength(span.metadata.largeData as string, 'utf8')).toBeLessThanOrEqual(4096);
expect((span.metadata.largeData as string).endsWith('…')).toBe(true);
});

it('should handle special characters in span names and traces', () => {
Expand Down
54 changes: 49 additions & 5 deletions src/core/traceCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@

import { randomUUID } from 'node:crypto';

const MAX_TRACE_NAME_BYTES = 1024;
const MAX_METADATA_BYTES = 4096;
const MAX_ERROR_BYTES = 4096;

function boundText(value: string, maxBytes: number): string {
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
let result = '';
for (const char of value) {
if (Buffer.byteLength(result + char + '…', 'utf8') > maxBytes) break;
result += char;
}
return `${result}…`;
}

function boundMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const bounded: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
try {
const encoded = JSON.stringify({ ...bounded, [key]: value });
if (encoded !== undefined && Buffer.byteLength(encoded, 'utf8') <= MAX_METADATA_BYTES) bounded[key] = value;
else if (typeof value === 'string') {
const room = MAX_METADATA_BYTES - Buffer.byteLength(JSON.stringify(bounded), 'utf8') - Buffer.byteLength(JSON.stringify(key), 'utf8') - 8;
const shortened = boundText(value, Math.max(0, room));
const candidate = JSON.stringify({ ...bounded, [key]: shortened });
if (candidate && Buffer.byteLength(candidate, 'utf8') <= MAX_METADATA_BYTES) bounded[key] = shortened;
}
} catch {
// Ignore unserializable values rather than retaining them unchecked.
}
}
return bounded;
}

function boundError(error: { message: string; stack?: string; code?: string }): { message: string; stack?: string; code?: string } {
const result = {
message: boundText(error.message, 1024),
...(error.stack === undefined ? {} : { stack: boundText(error.stack, 2048) }),
...(error.code === undefined ? {} : { code: boundText(error.code, 256) }),
};
return Buffer.byteLength(JSON.stringify(result), 'utf8') <= MAX_ERROR_BYTES
? result
: { message: boundText(result.message, MAX_ERROR_BYTES - 16) };
}

// Types

export type SpanStatus = 'running' | 'completed' | 'failed';
Expand Down Expand Up @@ -95,11 +139,11 @@ export class TraceCollector {
const traceId = randomUUID();
const trace: Trace = {
traceId,
name,
name: boundText(name, MAX_TRACE_NAME_BYTES),
startTime: Date.now(),
status: 'running',
spans: [],
metadata,
metadata: boundMetadata(metadata),
};
this.traces.set(traceId, trace);
return traceId;
Expand Down Expand Up @@ -150,10 +194,10 @@ export class TraceCollector {
spanId,
traceId,
parentSpanId,
name,
name: boundText(name, MAX_TRACE_NAME_BYTES),
status: 'running',
startTime: Date.now(),
metadata,
metadata: boundMetadata(metadata),
};
trace.spans.push(span);
return spanId;
Expand Down Expand Up @@ -190,7 +234,7 @@ export class TraceCollector {
const span = trace.spans.find((s) => s.spanId === spanId);
if (!span) return false;

span.errorInfo = error;
span.errorInfo = boundError(error);
span.status = 'failed';
span.endTime = span.endTime ?? Date.now();
return true;
Expand Down
Loading