feat(omo-codex): block budgeted create_goal calls
This commit is contained in:
@@ -11,6 +11,19 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "^create_goal$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "enforcing unlimited ultragoal budget"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { ultragoalCommand } from "./cli-commands.js";
|
import { ultragoalCommand } from "./cli-commands.js";
|
||||||
import { runUltragoalHookCli } from "./codex-hook.js";
|
import { runPreToolUseGoalBudgetGuardCli, runUltragoalHookCli } from "./codex-hook.js";
|
||||||
|
|
||||||
const TOP_LEVEL_HELP =
|
const TOP_LEVEL_HELP =
|
||||||
"Usage:\n omo ultragoal <subcommand> [args]\n omo hook user-prompt-submit (Codex UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ultragoal help` for ultragoal subcommands.\n";
|
"Usage:\n omo ultragoal <subcommand> [args]\n omo hook user-prompt-submit (Codex UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ultragoal help` for ultragoal subcommands.\n";
|
||||||
@@ -19,6 +19,10 @@ async function main(): Promise<number> {
|
|||||||
await runUltragoalHookCli(process.stdin, process.stdout);
|
await runUltragoalHookCli(process.stdin, process.stdout);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (sub === "pre-tool-use") {
|
||||||
|
await runPreToolUseGoalBudgetGuardCli(process.stdin, process.stdout);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
process.stderr.write(`[omo] unknown hook subcommand: ${sub ?? "(none)"}\n`);
|
process.stderr.write(`[omo] unknown hook subcommand: ${sub ?? "(none)"}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,32 @@ export interface UserPromptSubmitPayload {
|
|||||||
readonly turn_id?: string;
|
readonly turn_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreToolUsePayload {
|
||||||
|
readonly cwd: string;
|
||||||
|
readonly hook_event_name: "PreToolUse";
|
||||||
|
readonly model: string;
|
||||||
|
readonly permission_mode: string;
|
||||||
|
readonly session_id: string;
|
||||||
|
readonly tool_input: unknown;
|
||||||
|
readonly tool_name: string;
|
||||||
|
readonly tool_use_id: string;
|
||||||
|
readonly transcript_path: string | null;
|
||||||
|
readonly turn_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreToolUseHookOutput {
|
||||||
|
readonly hookSpecificOutput: {
|
||||||
|
readonly hookEventName: "PreToolUse";
|
||||||
|
readonly permissionDecision: "deny";
|
||||||
|
readonly permissionDecisionReason: string;
|
||||||
|
readonly additionalContext: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREATE_GOAL_TOOL_NAME = "create_goal";
|
||||||
|
const GOAL_BUDGET_WARNING =
|
||||||
|
"Do not set token_budget on create_goal. Omit the budget field so the goal stays unlimited; ultrawork and ultragoal runs must always use unlimited goals.";
|
||||||
|
|
||||||
export function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null {
|
export function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null {
|
||||||
if (raw.trim().length === 0) return null;
|
if (raw.trim().length === 0) return null;
|
||||||
try {
|
try {
|
||||||
@@ -22,6 +48,17 @@ export function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPaylo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parsePreToolUsePayload(raw: string): PreToolUsePayload | null {
|
||||||
|
if (raw.trim().length === 0) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isPreToolUsePayload(parsed) ? parsed : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyUserPromptUltragoalSteering(payload: UserPromptSubmitPayload): Promise<string> {
|
export async function applyUserPromptUltragoalSteering(payload: UserPromptSubmitPayload): Promise<string> {
|
||||||
try {
|
try {
|
||||||
if (payload.hook_event_name !== "UserPromptSubmit") return "";
|
if (payload.hook_event_name !== "UserPromptSubmit") return "";
|
||||||
@@ -41,6 +78,21 @@ export async function applyUserPromptUltragoalSteering(payload: UserPromptSubmit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyPreToolUseGoalBudgetGuard(payload: PreToolUsePayload): string {
|
||||||
|
if (payload.hook_event_name !== "PreToolUse") return "";
|
||||||
|
if (payload.tool_name !== CREATE_GOAL_TOOL_NAME) return "";
|
||||||
|
if (!hasGoalBudgetInput(payload.tool_input)) return "";
|
||||||
|
const output: PreToolUseHookOutput = {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: "PreToolUse",
|
||||||
|
permissionDecision: "deny",
|
||||||
|
permissionDecisionReason: GOAL_BUDGET_WARNING,
|
||||||
|
additionalContext: GOAL_BUDGET_WARNING,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return `${JSON.stringify(output)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runUltragoalHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void> {
|
export async function runUltragoalHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const payload = parseUserPromptSubmitPayload(await readAll(stdin));
|
const payload = parseUserPromptSubmitPayload(await readAll(stdin));
|
||||||
@@ -53,6 +105,21 @@ export async function runUltragoalHookCli(stdin: NodeJS.ReadableStream, stdout:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runPreToolUseGoalBudgetGuardCli(
|
||||||
|
stdin: NodeJS.ReadableStream,
|
||||||
|
stdout: NodeJS.WritableStream,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload = parsePreToolUsePayload(await readAll(stdin));
|
||||||
|
if (payload === null) return;
|
||||||
|
const output = applyPreToolUseGoalBudgetGuard(payload);
|
||||||
|
if (output.length > 0) stdout.write(output);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) return;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPayload {
|
function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPayload {
|
||||||
if (!isRecord(value)) return false;
|
if (!isRecord(value)) return false;
|
||||||
return (
|
return (
|
||||||
@@ -64,6 +131,26 @@ function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPay
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPreToolUsePayload(value: unknown): value is PreToolUsePayload {
|
||||||
|
if (!isRecord(value)) return false;
|
||||||
|
return (
|
||||||
|
value["hook_event_name"] === "PreToolUse" &&
|
||||||
|
typeof value["cwd"] === "string" &&
|
||||||
|
typeof value["model"] === "string" &&
|
||||||
|
typeof value["permission_mode"] === "string" &&
|
||||||
|
typeof value["session_id"] === "string" &&
|
||||||
|
typeof value["tool_name"] === "string" &&
|
||||||
|
typeof value["tool_use_id"] === "string" &&
|
||||||
|
(value["transcript_path"] === null || typeof value["transcript_path"] === "string") &&
|
||||||
|
typeof value["turn_id"] === "string" &&
|
||||||
|
Object.hasOwn(value, "tool_input")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGoalBudgetInput(value: unknown): boolean {
|
||||||
|
return isRecord(value) && (Object.hasOwn(value, "token_budget") || Object.hasOwn(value, "tokenBudget"));
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { Readable, Writable } from "node:stream";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyPreToolUseGoalBudgetGuard,
|
||||||
applyUserPromptUltragoalSteering,
|
applyUserPromptUltragoalSteering,
|
||||||
|
type PreToolUsePayload,
|
||||||
parseUserPromptSubmitPayload,
|
parseUserPromptSubmitPayload,
|
||||||
|
runPreToolUseGoalBudgetGuardCli,
|
||||||
runUltragoalHookCli,
|
runUltragoalHookCli,
|
||||||
type UserPromptSubmitPayload,
|
type UserPromptSubmitPayload,
|
||||||
} from "../src/codex-hook.js";
|
} from "../src/codex-hook.js";
|
||||||
@@ -50,6 +53,21 @@ function payload(prompt: string, cwd: string): UserPromptSubmitPayload {
|
|||||||
return { cwd, hook_event_name: "UserPromptSubmit", prompt, session_id: "s1" };
|
return { cwd, hook_event_name: "UserPromptSubmit", prompt, session_id: "s1" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preToolPayload(toolName: string, toolInput: unknown): PreToolUsePayload {
|
||||||
|
return {
|
||||||
|
cwd: "/repo",
|
||||||
|
hook_event_name: "PreToolUse",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
permission_mode: "default",
|
||||||
|
session_id: "s1",
|
||||||
|
tool_input: toolInput,
|
||||||
|
tool_name: toolName,
|
||||||
|
tool_use_id: "call-1",
|
||||||
|
transcript_path: null,
|
||||||
|
turn_id: "turn-1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function payloadWithRuntimeEvent(hookEventName: string): UserPromptSubmitPayload {
|
function payloadWithRuntimeEvent(hookEventName: string): UserPromptSubmitPayload {
|
||||||
const input = payload(
|
const input = payload(
|
||||||
'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}',
|
'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}',
|
||||||
@@ -185,3 +203,64 @@ describe("runUltragoalHookCli (stdin/stdout integration)", () => {
|
|||||||
expect(capture.read()).toBe("");
|
expect(capture.read()).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyPreToolUseGoalBudgetGuard", () => {
|
||||||
|
it("#given create_goal sets token_budget #when PreToolUse runs #then it blocks with unlimited-goal warning", () => {
|
||||||
|
// given
|
||||||
|
const input = preToolPayload("create_goal", { objective: "Ship the feature", token_budget: 5000 });
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = applyPreToolUseGoalBudgetGuard(input);
|
||||||
|
|
||||||
|
// then
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: "PreToolUse",
|
||||||
|
permissionDecision: "deny",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain("Do not set token_budget on create_goal");
|
||||||
|
expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain("unlimited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given create_goal omits token_budget #when PreToolUse runs #then it stays silent", () => {
|
||||||
|
// given
|
||||||
|
const input = preToolPayload("create_goal", { objective: "Ship the feature" });
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = applyPreToolUseGoalBudgetGuard(input);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given a neighboring tool includes token_budget text #when PreToolUse runs #then it stays silent", () => {
|
||||||
|
// given
|
||||||
|
const input = preToolPayload("update_goal", { status: "complete", token_budget: 5000 });
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = applyPreToolUseGoalBudgetGuard(input);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runPreToolUseGoalBudgetGuardCli", () => {
|
||||||
|
it("#given Codex PreToolUse stdin with budgeted create_goal #when CLI hook runs #then it writes blocking JSON", async () => {
|
||||||
|
// given
|
||||||
|
const stdin = Readable.from([
|
||||||
|
JSON.stringify(preToolPayload("create_goal", { objective: "Ship", token_budget: 1 })),
|
||||||
|
]);
|
||||||
|
const capture = captureStdout();
|
||||||
|
|
||||||
|
// when
|
||||||
|
await runPreToolUseGoalBudgetGuardCli(stdin, capture.stdout);
|
||||||
|
|
||||||
|
// then
|
||||||
|
const parsed = JSON.parse(capture.read());
|
||||||
|
expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny");
|
||||||
|
expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain("unlimited");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ describe("hooks/hooks.json", () => {
|
|||||||
expect(command).toContain("dist/cli.js");
|
expect(command).toContain("dist/cli.js");
|
||||||
expect(command).toContain("hook user-prompt-submit");
|
expect(command).toContain("hook user-prompt-submit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("#given ultragoal component is enabled #when hooks are inspected #then create_goal PreToolUse guard is registered", async () => {
|
||||||
|
const text = await readText("hooks/hooks.json");
|
||||||
|
|
||||||
|
expect(text).toContain('"PreToolUse"');
|
||||||
|
expect(text).toContain('"matcher": "^create_goal$"');
|
||||||
|
expect(text).toContain("hook pre-tool-use");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("src/cli.ts", () => {
|
describe("src/cli.ts", () => {
|
||||||
|
|||||||
@@ -52,6 +52,19 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "^create_goal$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${PLUGIN_ROOT}/components/ultragoal/dist/cli.js\" hook pre-tool-use",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "enforcing OMO unlimited goal budget"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$",
|
"matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$",
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ test("#given isolated components #when hooks are inspected #then commands stay i
|
|||||||
assert.doesNotMatch(text, /codex-(comment-checker|lsp|rules|telemetry|ultragoal|ultrawork)@/);
|
assert.doesNotMatch(text, /codex-(comment-checker|lsp|rules|telemetry|ultragoal|ultrawork)@/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ultragoal guards budgeted create_goal calls", async () => {
|
||||||
|
// given
|
||||||
|
const hooks = await readJson("hooks/hooks.json");
|
||||||
|
const text = JSON.stringify(hooks);
|
||||||
|
|
||||||
|
// when
|
||||||
|
const preToolUseGroups = hooks.hooks.PreToolUse;
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert.match(text, /components\/ultragoal\/dist\/cli\.js/);
|
||||||
|
assert.match(text, /hook pre-tool-use/);
|
||||||
|
assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^create_goal$"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("#given aggregate MCP config #when inspected #then lsp server stays component isolated", async () => {
|
test("#given aggregate MCP config #when inspected #then lsp server stays component isolated", async () => {
|
||||||
// given
|
// given
|
||||||
const mcp = await readJson(".mcp.json");
|
const mcp = await readJson(".mcp.json");
|
||||||
|
|||||||
Reference in New Issue
Block a user