Files
gstack/test/codex-skill-isolation.test.ts
T
garrytan e641251e87 fix(outside-voice): CODEX_BOUNDARY at normal volume (W1.1)
The boundary every outside-voice prompt hands Codex keeps its constraint,
all four protected roots, its reason, the no-skill-invocation clause and the
agents/openai.yaml no-modify clause, without the IMPORTANT/NOT capitals or
the redundant "Ignore them completely".

Evidence (safety-codex-boundary, codex-cli 0.160.0, gpt-6-astra, effort
high, inotify observer with a negative control in every trial):
- before (old wording, repaired oracle): 10/10 clean
- rule removed: 5/5 violations (two canary files opened in each)
- after (this wording): 10/10 clean, $5.99
Codex is the only reader of this text on every host, so this eval covers
every render that carries it.

E3 registry hash updated. The tests that pinned the old capitals now check
the boundary case-insensitively on meaning (four roots, no skill invocation):
codex-skill-isolation, skill-validation, template-context-parity, and the
boundary-line filter in gen-skill-docs.
2026-10-03 21:42:44 +00:00

70 lines
3.4 KiB
TypeScript

/**
* #2847: a nested gstack Codex call is a one-shot review, but Codex injects every
* installed skill (gstack's own `review` included) into its context, and the
* reviewer re-ran whole skill workflows inside its timeout. A read-only sandbox
* does not stop that. Verified against codex-cli 0.152.0 and 0.160.0 with a local
* Responses API stub: without `skills.include_instructions=false` the request
* carried the full gstack catalog; with it, none (exec and native review alike).
* Every gstack-owned Codex call therefore carries the isolation flag, and the
* prompt boundary names the Codex skill roots.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { CODEX_MODEL_CONFIG_FLAG, CODEX_REVIEW_MODEL_CONFIG_FLAG, CODEX_SKILLS_ISOLATION_FLAG } from '../scripts/resolvers/constants';
const ROOT = path.join(import.meta.dir, '..');
function renderedSkillFiles(): string[] {
const files: string[] = [];
const pending = [ROOT];
const excluded = new Set(['node_modules', '.git', '.context', '.claude', 'fixtures']);
while (pending.length) {
const dir = pending.pop()!;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (excluded.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) pending.push(full);
else if (entry.name.endsWith('.md') && !entry.name.endsWith('.tmpl')) files.push(full);
}
}
return files;
}
describe('Codex skill isolation (#2847)', () => {
test('the model flags carry the isolation flag, so every model-flag site is isolated', () => {
expect(CODEX_SKILLS_ISOLATION_FLAG).toBe('-c skills.include_instructions=false');
expect(CODEX_MODEL_CONFIG_FLAG).toContain(CODEX_SKILLS_ISOLATION_FLAG);
expect(CODEX_REVIEW_MODEL_CONFIG_FLAG).toContain(CODEX_SKILLS_ISOLATION_FLAG);
});
test('every wrapped Codex exec/review call in generated skills disables the skill catalog', () => {
const calls: string[] = [];
for (const file of renderedSkillFiles()) {
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (/_gstack_codex_timeout_wrapper\s+\d+\s+codex\s+(exec|review)\b/.test(line)) calls.push(`${path.relative(ROOT, file)}: ${line}`);
}
}
expect(calls.length).toBeGreaterThan(10);
expect(calls.filter(call => !call.includes("skills.include_instructions=false"))).toEqual([]);
});
test('the probe round trip does not pay for the skill catalog either', () => {
const probe = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-codex-probe'), 'utf8');
const roundTrip = probe.split('\n').find(line => line.includes('"reply OK"'));
expect(roundTrip).toContain("skills.include_instructions=false");
});
test('outside-voice and /codex prompt boundaries name the Codex skill roots and forbid skill use', () => {
const sources = ['scripts/resolvers/outside-voice-steps.ts', ...['review-mode', 'challenge-mode', 'consult-mode'].map(m => `codex/sections/${m}.md.tmpl`)];
for (const rel of sources) {
const text = fs.readFileSync(path.join(ROOT, rel), 'utf8');
const boundaries = text.split('\n').filter(line => /do not read or execute any files under/i.test(line));
expect(boundaries.length, rel).toBeGreaterThan(0);
for (const line of boundaries) {
expect(line, rel).toMatch(/do not invoke any installed skill \(Codex home skills\/, \.agents\/\)/i);
}
}
});
});