App-server: add safe thread-scoped registration for host-provided hooks
What variant of Codex are you using?
App Server
What feature would you like to see?
Summary
What we are trying to achieve
Context: The microsoft/vscode team is integrating the Codex App Server into VS Code's agent host (see our docs 1 and docs 2)
An application embedding Codex app-server can collect hooks from workspace files, plugins, or extension contributions. We want to attach the selected hooks to one Codex thread and have only those exact hooks run.
For example, a workspace may define this hook:
After Codex edits a file, run the project formatter.
The host can send that hook through thread/start.config.hooks, but Codex treats the injected hook as untrusted and does not run it.
The only available workaround is bypass_hook_trust: true. That trusts every hook affecting the thread, not only the hook supplied by the host, so it is too broad for a production integration.
What is missing
We need a safe way for an embedding application to say:
Run these exact host-selected hooks for this thread. Do not change the trust status of any other hooks.
This could be a typed thread-level hooks field, a scoped approval API, or another mechanism. The important requirement is that trust applies only to the supplied hook definitions and only to the target thread.
Current behavior
thread/start.config.hooksaccepts the hook configuration.- The hook is classified as a session-provided, untrusted hook.
- The hook does not execute.
hooks/listcannot inspect the effective hooks injected into a particular thread.bypass_hook_trust: truemakes the hook run, but disables trust checking for all hooks affecting that thread.
Minimal reproduction
This script requires Node.js and macOS or Linux. It uses a local mock model endpoint, so it does not require a Codex login or make an external model request.
Save it as repro.mjs, then run node repro.mjs.
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { createInterface } from "node:readline";
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const tmp = await mkdtemp(join(tmpdir(), "codex-hook-repro-"));
const mockModel = createServer((_request, response) => {
response.writeHead(400, { "content-type": "application/json" });
response.end(JSON.stringify({ error: { message: "intentional repro stop" } }));
});
await new Promise(resolve => mockModel.listen(0, "127.0.0.1", resolve));
const port = mockModel.address().port;
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function run(label, bypassHookTrust) {
const home = join(tmp, `home-${label}`);
const cwd = join(tmp, `cwd-${label}`);
const marker = join(tmp, `${label}.txt`);
await Promise.all([mkdir(home), mkdir(cwd)]);
const child = spawn(
"npx",
["-y", "@openai/codex@0.147.0", "app-server", "--stdio"],
{
env: { ...process.env, CODEX_HOME: home },
stdio: ["pipe", "pipe", "pipe"]
}
);
let nextId = 0;
const pending = new Map();
createInterface({ input: child.stdout }).on("line", line => {
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
const request = pending.get(message.id);
if (!request) {
return;
}
pending.delete(message.id);
if (message.error) {
request.reject(new Error(JSON.stringify(message.error)));
} else {
request.resolve(message.result);
}
});
child.stderr.on("data", () => {});
function request(method, params) {
const id = ++nextId;
child.stdin.write(JSON.stringify({ method, id, params }) + "\n");
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
});
}
await request("initialize", {
clientInfo: { name: "hook-repro", title: "Hook repro", version: "1" }
});
child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
const config = {
model_provider: "repro",
model_providers: {
repro: {
name: "repro",
base_url: `http://127.0.0.1:${port}/v1`,
wire_api: "responses",
requires_openai_auth: false
}
},
hooks: {
SessionStart: [{
hooks: [{
type: "command",
command: `printf ${label} > '${marker}'`
}]
}]
},
...(bypassHookTrust ? { bypass_hook_trust: true } : {})
};
const started = await request("thread/start", {
cwd,
model: "repro-model",
config,
ephemeral: true
});
await request("turn/start", {
threadId: started.thread.id,
input: [{ type: "text", text: "hello" }]
});
for (let i = 0; i < 30 && !(await exists(marker)); i++) {
await new Promise(resolve => setTimeout(resolve, 50));
}
const ran = await exists(marker);
child.kill();
return ran;
}
try {
console.log(JSON.stringify({
withoutBypass: await run("without-bypass", false),
withBypass: await run("with-bypass", true)
}, null, 2));
} finally {
mockModel.close();
await rm(tmp, { recursive: true, force: true });
}
Observed with @openai/codex 0.146.0 and 0.147.0:
{
"withoutBypass": false,
"withBypass": true
}
Expected behavior
The host should be able to register and trust only the hook definitions it selected for this thread. Unrelated project, user, or plugin hooks should keep their existing trust state.
Requested capability
Please add a typed, thread-scoped registration surface for host-provided hooks, or equivalent scoped trust support. The exact API shape is flexible, but we need:
- Scoped trust for the exact host-provided hook definitions.
- Stable hook IDs and source/provenance information.
- Thread-aware listing that includes trust status and validation errors.
- Clear behavior when hooks are updated on a loaded thread.
- Support for event, matcher, command,
cwd, environment, timeout, and platform-specific command fields. - Consistent behavior for thread start, resume, and fork.
Native untrusted project, user, and plugin hooks must remain untrusted. Managed-hook restrictions must still take precedence.
The solution should not require an embedding host to reproduce Codex's private hook hash algorithm or set bypass_hook_trust.
Additional information
_No response_
cc @DonJayamanne
1 Comment
Could you assign this to me