Skip to content
Merged
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
12 changes: 10 additions & 2 deletions src/domain/agent-invocation-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,27 @@ import {
*/
const objectFreeze = Object.freeze;
const objectDefineProperty = Object.defineProperty;
const objectSetPrototypeOf = Object.setPrototypeOf;
const objectHasOwn = Object.hasOwn;
const arrayIsArray = Array.isArray;
const numberIsInteger = Number.isInteger;
const stringConstructor = String;

/** Append by defining an own element, bypassing inherited index setters. */
function append<T>(list: T[], value: T): void {
objectDefineProperty(list, list.length, {
// The descriptor object inherits from `Object.prototype`, and
// `Object.defineProperty` runs ToPropertyDescriptor over it — consulting
// inherited `get`/`set` via [[HasProperty]]. A poisoned `Object.prototype.get`
// or `.set` would therefore be read and make the call throw. Detaching the
// descriptor's prototype first means only its own data attributes are visible.
const descriptor: PropertyDescriptor = {
value,
writable: true,
enumerable: true,
configurable: true,
});
};
objectSetPrototypeOf(descriptor, null);
objectDefineProperty(list, list.length, descriptor);
}

/**
Expand Down
12 changes: 10 additions & 2 deletions src/domain/agent-invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
*/
const objectFreeze = Object.freeze;
const objectDefineProperty = Object.defineProperty;
const objectSetPrototypeOf = Object.setPrototypeOf;
const objectHasOwn = Object.hasOwn;
const reflectApply = Reflect.apply;
// Captured unbound on purpose and invoked through `Reflect.apply`, so neither a
Expand All @@ -67,12 +68,19 @@ function containsValue(list: readonly string[], value: unknown): boolean {

/** Append by defining an own element, bypassing inherited index setters. */
function append<T>(list: T[], value: T): void {
objectDefineProperty(list, list.length, {
// The descriptor object inherits from `Object.prototype`, and
// `Object.defineProperty` runs ToPropertyDescriptor over it — consulting
// inherited `get`/`set` via [[HasProperty]]. A poisoned `Object.prototype.get`
// or `.set` would therefore be read and make the call throw. Detaching the
// descriptor's prototype first means only its own data attributes are visible.
const descriptor: PropertyDescriptor = {
value,
writable: true,
enumerable: true,
configurable: true,
});
};
objectSetPrototypeOf(descriptor, null);
objectDefineProperty(list, list.length, descriptor);
}

/**
Expand Down
12 changes: 10 additions & 2 deletions src/domain/review-ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,26 @@ import {
*/
const objectFreeze = Object.freeze;
const objectDefineProperty = Object.defineProperty;
const objectSetPrototypeOf = Object.setPrototypeOf;
const arrayIsArray = Array.isArray;
const numberIsInteger = Number.isInteger;
const stringConstructor = String;

/** Append by defining an own element, bypassing inherited index setters. */
function append<T>(list: T[], value: T): void {
objectDefineProperty(list, list.length, {
// The descriptor object inherits from `Object.prototype`, and
// `Object.defineProperty` runs ToPropertyDescriptor over it — consulting
// inherited `get`/`set` via [[HasProperty]]. A poisoned `Object.prototype.get`
// or `.set` would therefore be read and make the call throw. Detaching the
// descriptor's prototype first means only its own data attributes are visible.
const descriptor: PropertyDescriptor = {
value,
writable: true,
enumerable: true,
configurable: true,
});
};
objectSetPrototypeOf(descriptor, null);
objectDefineProperty(list, list.length, descriptor);
}

/** Read a positive integer line number, or `null`. */
Expand Down
214 changes: 214 additions & 0 deletions tests/domain/agent-invocation-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import {
evaluateEvidenceFreshness,
findInvalidInvocationFields,
ingestInvocationReport,
ingestReview,
INVOCATION_BOUNDS,
Expand Down Expand Up @@ -734,3 +735,216 @@ describe('PR 005 correlation convention', () => {
expect(Object.keys(result)).not.toContain('requestedAt');
});
});

/**
* C1-AI-F1 — the diagnostic append path stays total under a hostile
* `Object.prototype`.
*
* `findInvalidInvocationFields` (and its non-object `allRequiredFields` branch)
* build their result with `append`, which defines an own index via
* `Object.defineProperty`. A descriptor object literal inherits from
* `Object.prototype`, so an inherited `get`/`set` accessor was consulted by
* ToPropertyDescriptor and made the call throw a `TypeError` — turning valid
* diagnostic reporting into a crash. The repair detaches the descriptor's
* prototype before the define, so only its own data attributes are read.
*
* Poison installers use null-prototype descriptors so the harness never
* reproduces the bug itself; product code runs under poison, the realm is
* restored, and assertions run afterwards (Section 13 of the repair gate).
*/
describe('C1-AI-F1 append survives hostile Object.prototype get/set', () => {
const defineProp = Object.defineProperty;
const getOwnDesc = Object.getOwnPropertyDescriptor;

function nullProto<T extends object>(object: T): T {
Object.setPrototypeOf(object, null);
return object;
}

/** Plant inherited data-property poison; return a realm-restoring function. */
function poisonPrototype(keys: readonly string[]): () => void {
const saved: Record<string, PropertyDescriptor | undefined> = Object.create(
null,
) as Record<string, PropertyDescriptor | undefined>;
for (const key of keys) {
saved[key] = getOwnDesc(Object.prototype, key);
}
for (const key of keys) {
defineProp(
Object.prototype,
key,
nullProto({ value: 'inherited-poison', configurable: true, writable: true }),
);
}
return () => {
for (const key of keys) {
const descriptor = saved[key];
if (descriptor === undefined) {
Reflect.deleteProperty(Object.prototype, key);
} else {
defineProp(Object.prototype, key, nullProto({ ...descriptor }));
}
}
};
}

function underPoison<T>(
keys: readonly string[],
run: () => T,
): { result: T | null; thrown: unknown } {
const restore = poisonPrototype(keys);
let result: T | null = null;
let thrown: unknown = null;
try {
result = run();
} catch (error: unknown) {
thrown = error;
} finally {
restore();
}
return { result, thrown };
}

const invalidPurpose = (extra: Partial<AgentInvocation> = {}): AgentInvocation =>
({ ...buildInvocation(extra), purpose: 'nope' }) as unknown as AgentInvocation;

const POISON_SETS: readonly (readonly string[])[] = [['get'], ['set'], ['get', 'set']];

for (const keys of POISON_SETS) {
it(`reports a single invalid field under ${keys.join('+')} poison`, () => {
const { result, thrown } = underPoison(keys, () =>
findInvalidInvocationFields(invalidPurpose()),
);

expect(thrown).toBeNull();
expect(result && [...result]).toEqual(['purpose']);
});
}

for (const keys of POISON_SETS) {
it(`reports every required field for a non-object under ${keys.join('+')} poison`, () => {
const { result, thrown } = underPoison(keys, () =>
findInvalidInvocationFields(null as unknown as AgentInvocation),
);

expect(thrown).toBeNull();
expect(result && [...result]).toEqual([
'invocationId',
'repositoryId',
'targetCommitSha',
'providerId',
'agentId',
'purpose',
'requestedAt',
]);
});
}

it('preserves invalid-field declaration order under get+set poison', () => {
const invocation = invalidPurpose({ invocationId: '', requestedAt: '' });
const { result, thrown } = underPoison(['get', 'set'], () =>
findInvalidInvocationFields(invocation),
);

expect(thrown).toBeNull();
expect(result && [...result]).toEqual(['invocationId', 'purpose', 'requestedAt']);
});

it('survives a getter that installs get poison mid-evaluation', () => {
const saved = getOwnDesc(Object.prototype, 'get');
let result: readonly string[] | null = null;
let thrown: unknown = null;
try {
const invocation = { ...buildInvocation() };
defineProp(invocation, 'invocationId', {
get(): string {
defineProp(
Object.prototype,
'get',
nullProto({ value: 'planted', configurable: true, writable: true }),
);
return ''; // invalid, so append('invocationId') runs under the poison it just installed
},
configurable: true,
enumerable: true,
});
try {
result = findInvalidInvocationFields(invocation as AgentInvocation);
} catch (error: unknown) {
thrown = error;
}
} finally {
if (saved === undefined) {
Reflect.deleteProperty(Object.prototype, 'get');
} else {
defineProp(Object.prototype, 'get', nullProto({ ...saved }));
}
}

expect(thrown).toBeNull();
expect(result && [...result]).toEqual(['invocationId']);
});

it('survives a getter that installs set poison mid-evaluation', () => {
const saved = getOwnDesc(Object.prototype, 'set');
let result: readonly string[] | null = null;
let thrown: unknown = null;
try {
const invocation = { ...buildInvocation() };
defineProp(invocation, 'invocationId', {
get(): string {
defineProp(
Object.prototype,
'set',
nullProto({ value: 'planted', configurable: true, writable: true }),
);
return '';
},
configurable: true,
enumerable: true,
});
try {
result = findInvalidInvocationFields(invocation as AgentInvocation);
} catch (error: unknown) {
thrown = error;
}
} finally {
if (saved === undefined) {
Reflect.deleteProperty(Object.prototype, 'set');
} else {
defineProp(Object.prototype, 'set', nullProto({ ...saved }));
}
}

expect(thrown).toBeNull();
expect(result && [...result]).toEqual(['invocationId']);
});

it('matches the clean control under get+set poison', () => {
const invocation = invalidPurpose({ invocationId: '' });
const clean = [...findInvalidInvocationFields(invocation)];
const { result, thrown } = underPoison(['get', 'set'], () =>
findInvalidInvocationFields(invocation),
);

expect(thrown).toBeNull();
expect(result && [...result]).toEqual(clean);
expect(clean).toEqual(['invocationId', 'purpose']);
});

it('leaves a clean valid invocation reporting nothing under poison', () => {
const { result, thrown } = underPoison(['get', 'set'], () =>
findInvalidInvocationFields(buildInvocation()),
);

expect(thrown).toBeNull();
expect(result && [...result]).toEqual([]);
});

it('restores Object.prototype after every poisoned run', () => {
underPoison(['get', 'set'], () => findInvalidInvocationFields(invalidPurpose()));

expect(getOwnDesc(Object.prototype, 'get')).toBeUndefined();
expect(getOwnDesc(Object.prototype, 'set')).toBeUndefined();
});
});
Loading
Loading