mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(codex): --effort <level> sets GSTACK_CODEX_EFFORT for one run /codex review --effort medium (or --effort=medium) prefixes each run-with-timeout dispatch with GSTACK_CODEX_EFFORT=<level>, which outranks codex_effort and the per-mode default; --xhigh becomes shorthand for --effort xhigh. The probe keeps owning validation. A missing value, a value that is not one lowercase word, or conflicting flags stop instead of falling back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(codex): per-run effort prefix reaches the rendered /codex review dispatch Runs the rendered review fence against the fake codex: no prefix uses codex_effort, GSTACK_CODEX_EFFORT=medium|xhigh wins over it, an unknown level never reaches codex. Step 1 check pins --effort tokens and the stop rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(codex): document codex_effort and --effort; Challenge runs at high, not xhigh Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): [Unreleased] entry for /codex --effort Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(codex): --effort reaches the probe as an allowlisted request level, parsed before the mode and checked before the paid probe The level now travels as run-with-timeout --request-effort '<effort-level>' (placeholder grammar ^[a-z]*$) instead of an agent-built env prefix, and the probe names /codex --effort when it rejects one. Effort flags count only right after /codex or its mode word and are removed before mode detection. Step 0.5 runs check-effort before probe-model, so a bad level makes no Codex call even on a cold cache. Tests cover all five dispatch fences and the zero-call cases. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(codex): per-request --effort in model-policy, CHANGELOG behavior changes, TODOS follow-ups Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.93.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(codex): effort flags sit among /codex's leading flags; the probe names an unfilled '<effort-level>' placeholder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(codex): per-request row in the effort precedence table, in-tool help names /codex --effort, late-flag notice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(codex): binding tripwire sees the request-effort dispatch; valid and unfilled levels through Step 0.5 and every fence; doc accuracy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: John Nunemaker <nunemaker@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
84 lines
4.9 KiB
TypeScript
84 lines
4.9 KiB
TypeScript
/**
|
|
* #3102 / D5: consent is enforced by the binaries that send, so no generated
|
|
* skill may start an outside CLI any other way. In every rendered bash fence
|
|
* (Claude and Codex hosts), `codex exec|review` at a command position must run
|
|
* behind `"$_CODEX_PROBE" run-with-timeout`, and the Claude CLI is never run
|
|
* directly: Claude Code reviews go through bin/gstack-claude-code. Prose and
|
|
* quoted strings are ignored; only command positions count.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-send-boundary-'));
|
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
|
|
const POSITION = String.raw`(?:^|[;&|]\s*|\$\(\s*|\b(?:then|do|else|if|!)\s+)`;
|
|
const CODEX = new RegExp(`${POSITION}(?:g?timeout\\s+\\S+\\s+)?((?:"\\$_CODEX_PROBE"\\s+run-with-timeout\\s+(?:--user-invoked\\s+)?(?:--effort\\s+\\w+\\s+(?:--request-effort\\s+'[^']*'\\s+)?)?\\d+\\s+)?)codex\\s+(?:exec|review)\\b`);
|
|
/** A headless Claude prompt run (`claude -p`, `--print`, or a prompt on stdin), or the resolved CLAUDE_BIN; `claude mcp|auth|config` manage the CLI and send no repository content. */
|
|
const CLAUDE = new RegExp(`${POSITION}(?:command\\s+)?(?:"\\$\\{?CLAUDE_BIN\\}?"|claude(?=\\s)(?![^|;&]*\\s(?:mcp|auth|config|update|doctor)\\b)[^|;&]*(?:\\s(?:-p|--print)\\b|\\s<\\s*\\S))`);
|
|
|
|
/** Lines in bash fences that start an outside CLI without its consent-enforcing wrapper. */
|
|
function directLaunches(markdown: string): string[] {
|
|
const bad: string[] = [];
|
|
for (const match of markdown.matchAll(/```bash\n([\s\S]*?)```/g)) {
|
|
for (const raw of match[1]!.split('\n')) {
|
|
const line = raw.trim().replace(/'[^']*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, m => (m.startsWith('"$') ? m : '""'));
|
|
if (line.startsWith('#')) continue;
|
|
const codex = CODEX.exec(line);
|
|
if (codex && !codex[1]) bad.push(raw.trim());
|
|
if (CLAUDE.test(line)) bad.push(raw.trim());
|
|
}
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
function render(host: string): string[] {
|
|
const out = path.join(TMP, host);
|
|
const r = spawnSync('bun', ['run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', out], { cwd: ROOT, encoding: 'utf8', timeout: 120_000 });
|
|
if (r.status !== 0) throw new Error(`render ${host} failed: ${r.stderr}`);
|
|
const files: string[] = [];
|
|
const walk = (dir: string) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const p = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(p);
|
|
else if (entry.name.endsWith('.md')) files.push(p);
|
|
}
|
|
};
|
|
walk(out);
|
|
return files;
|
|
}
|
|
|
|
describe('generated skills start outside CLIs only through the consent-enforcing binaries', () => {
|
|
for (const host of ['claude', 'codex']) {
|
|
test(`${host}: no direct codex exec/review or Claude CLI launch`, () => {
|
|
const files = render(host);
|
|
expect(files.length).toBeGreaterThan(20);
|
|
const bad = files.flatMap(f => directLaunches(fs.readFileSync(f, 'utf8')).map(l => `${path.relative(TMP, f)}: ${l}`));
|
|
expect(bad).toEqual([]);
|
|
const wrapped = files.filter(f => /run-with-timeout (?:--user-invoked )?(?:--effort \w+ (?:--request-effort '[^']*' )?)?\d+ codex|gstack-claude-code/.test(fs.readFileSync(f, 'utf8')));
|
|
expect(wrapped.length).toBeGreaterThan(5);
|
|
});
|
|
}
|
|
|
|
test('negative controls: direct launches are flagged, wrapped ones and prose are not', () => {
|
|
const fence = (body: string) => '```bash\n' + body + '\n```';
|
|
expect(directLaunches(fence('codex exec - -C "$_REPO_ROOT" < prompt'))).toHaveLength(1);
|
|
expect(directLaunches(fence('timeout 300 codex review --base main'))).toHaveLength(1);
|
|
expect(directLaunches(fence('OUT=$(codex exec "review this")'))).toHaveLength(1);
|
|
expect(directLaunches(fence('if true; then codex review --base main; fi'))).toHaveLength(1);
|
|
expect(directLaunches(fence('claude -p "review" < prompt'))).toHaveLength(1);
|
|
expect(directLaunches(fence('"$CLAUDE_BIN" -p < prompt'))).toHaveLength(1);
|
|
expect(directLaunches(fence('claude < prompt > out'))).toHaveLength(1);
|
|
expect(directLaunches(fence('claude mcp add --scope user gbrain -- "$GBRAIN_BIN" serve'))).toHaveLength(0);
|
|
expect(directLaunches(fence('"$_CODEX_PROBE" run-with-timeout 540 codex exec - -s read-only'))).toHaveLength(0);
|
|
expect(directLaunches(fence('"$_CODEX_PROBE" run-with-timeout --user-invoked 330 codex review --base main'))).toHaveLength(0);
|
|
expect(directLaunches(fence('"$CLAUDE_RUNNER" --cwd "$_REPO_ROOT" --access none --timeout-ms 600000 < prompt'))).toHaveLength(0);
|
|
expect(directLaunches(fence('echo "run by hand: codex exec - -C $_REPO_ROOT < $PROMPT_FILE" >&2'))).toHaveLength(0);
|
|
expect(directLaunches('Run `codex exec` yourself.\n')).toHaveLength(0);
|
|
});
|
|
});
|