@stroq/cli 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1029,8 +1029,8 @@ function firstArgAfter(segment2) {
1029
1029
  }
1030
1030
 
1031
1031
  // ../core/src/actions/self-config.ts
1032
- var SELF_CONFIG_FILE = /(\.claude\/settings(\.local)?\.json|\.cursor\/hooks\.json|\.stroq(\/|\b))/;
1033
- var PROTECTED_DIRS = /\.(claude|cursor|stroq)(\/|$|\s)/;
1032
+ var SELF_CONFIG_FILE = /(\.claude\/settings(\.local)?\.json|\.cursor\/hooks\.json|\.codex\/(hooks\.json|config\.toml)|\.stroq(\/|\b))/;
1033
+ var PROTECTED_DIRS = /\.(claude|cursor|codex|stroq)(\/|$|\s)/;
1034
1034
  var SELF_CONFIG_READ_COMMANDS = /* @__PURE__ */ new Set([
1035
1035
  "cat",
1036
1036
  "less",
@@ -3617,7 +3617,7 @@ Paste this line into a .env file (or any config the agent can read). Stroq store
3617
3617
 
3618
3618
  // src/commands/doctor.ts
3619
3619
  import { existsSync as existsSync3 } from "fs";
3620
- import { homedir as homedir7 } from "os";
3620
+ import { homedir as homedir8 } from "os";
3621
3621
 
3622
3622
  // src/commands/cursor-hooks.ts
3623
3623
  import { homedir as homedir5 } from "os";
@@ -3645,6 +3645,21 @@ function mcpToolName(rawServer, rawTool) {
3645
3645
  return `mcp__${server}__${tool || "call"}`;
3646
3646
  }
3647
3647
 
3648
+ // src/adapters/tool-input.ts
3649
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3650
+ function toolInputRecord(value) {
3651
+ if (value === void 0 || value === null) return {};
3652
+ if (typeof value === "object" && !Array.isArray(value)) return value;
3653
+ if (typeof value !== "string") return { raw: JSON.stringify(value) };
3654
+ try {
3655
+ const parsed = JSON.parse(value);
3656
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
3657
+ return parsed;
3658
+ } catch {
3659
+ }
3660
+ return { raw: value };
3661
+ }
3662
+
3648
3663
  // src/adapters/cursor.ts
3649
3664
  var CURSOR_EVENTS = [
3650
3665
  "beforeShellExecution",
@@ -3706,18 +3721,6 @@ function cursorToolName(input) {
3706
3721
  return "Write";
3707
3722
  }
3708
3723
  }
3709
- function mcpToolInput(value) {
3710
- if (value === void 0 || value === null) return {};
3711
- if (typeof value === "object" && !Array.isArray(value)) return value;
3712
- if (typeof value !== "string") return { raw: JSON.stringify(value) };
3713
- try {
3714
- const parsed = JSON.parse(value);
3715
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
3716
- return parsed;
3717
- } catch {
3718
- }
3719
- return { raw: value };
3720
- }
3721
3724
  function cursorToolInput(input) {
3722
3725
  switch (input.hook_event_name) {
3723
3726
  case "beforeShellExecution":
@@ -3725,7 +3728,7 @@ function cursorToolInput(input) {
3725
3728
  return { command: input.command ?? "" };
3726
3729
  case "beforeMCPExecution":
3727
3730
  case "afterMCPExecution":
3728
- return mcpToolInput(input.tool_input);
3731
+ return toolInputRecord(input.tool_input);
3729
3732
  case "beforeReadFile":
3730
3733
  case "afterFileEdit":
3731
3734
  return { file_path: input.file_path ?? "" };
@@ -3862,6 +3865,12 @@ function writeJsonObject(file, value) {
3862
3865
  writeFileSync(file, `${JSON.stringify(value, null, 2)}
3863
3866
  `);
3864
3867
  }
3868
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3869
+ function withoutStroqGroups(groups, isOurs) {
3870
+ return groups.filter(isPlainObject).map(
3871
+ (group) => Array.isArray(group["hooks"]) ? { ...group, hooks: group["hooks"].filter((handler) => !isOurs(handler)) } : group
3872
+ ).filter((group) => !Array.isArray(group["hooks"]) || group["hooks"].length > 0);
3873
+ }
3865
3874
 
3866
3875
  // src/commands/cursor-hooks.ts
3867
3876
  var CURSOR_HOOKS_VERSION = 1;
@@ -3896,13 +3905,98 @@ function installCursorHooks(file, command) {
3896
3905
  return merged;
3897
3906
  }
3898
3907
 
3899
- // src/commands/init.ts
3908
+ // src/commands/codex-hooks.ts
3900
3909
  import { homedir as homedir6 } from "os";
3901
- import { join as join9, resolve as resolve2 } from "path";
3910
+ import { join as join9 } from "path";
3911
+ var CODEX_PRE_MATCHER = "Bash|exec_command|shell|local_shell|apply_patch|ApplyPatch|mcp__.*";
3912
+ var CODEX_POST_MATCHER = "Bash|exec_command|shell|local_shell|mcp__.*";
3913
+ var CODEX_EVENT_NAMES = [
3914
+ "SessionStart",
3915
+ "SessionEnd",
3916
+ "SubagentStart",
3917
+ "SubagentStop",
3918
+ "PreToolUse",
3919
+ "PostToolUse",
3920
+ "PermissionRequest",
3921
+ "PreCompact",
3922
+ "PostCompact",
3923
+ "UserPromptSubmit",
3924
+ "Stop",
3925
+ "Interrupt"
3926
+ ];
3927
+ var codexHandler = (command) => ({
3928
+ type: "command",
3929
+ command,
3930
+ timeout: HOOK_TIMEOUT_SECONDS,
3931
+ statusMessage: "Stroq"
3932
+ });
3933
+ var isStroqCodexHook = (handler) => typeof handler?.command === "string" && / hook codex$/.test(handler.command);
3934
+ var groupsOf = (events, event) => {
3935
+ const groups = events[event];
3936
+ return Array.isArray(groups) ? groups : [];
3937
+ };
3938
+ function nestedEvents(settings) {
3939
+ return isPlainObject(settings.hooks) ? settings.hooks : {};
3940
+ }
3941
+ function liftableGroups(value) {
3942
+ if (Array.isArray(value)) return value;
3943
+ if (isPlainObject(value)) return [value];
3944
+ return null;
3945
+ }
3946
+ function eventMapOf(settings) {
3947
+ const merged = { ...nestedEvents(settings) };
3948
+ const lifted = /* @__PURE__ */ new Set();
3949
+ for (const name of CODEX_EVENT_NAMES) {
3950
+ const groups = liftableGroups(settings[name]);
3951
+ if (groups === null) continue;
3952
+ merged[name] = [...groupsOf(merged, name), ...groups];
3953
+ lifted.add(name);
3954
+ }
3955
+ return { events: merged, lifted };
3956
+ }
3957
+ var otherKeys = (settings, lifted) => Object.fromEntries(
3958
+ Object.entries(settings).filter(([key]) => key !== "hooks" && !lifted.has(key))
3959
+ );
3960
+ var mergeGroups = (groups, matcher, command) => [
3961
+ ...withoutStroqGroups(
3962
+ groups,
3963
+ (handler) => isStroqCodexHook(handler)
3964
+ ),
3965
+ { matcher, hooks: [codexHandler(command)] }
3966
+ ];
3967
+ function mergeCodexHooks(settings, command) {
3968
+ const { events, lifted } = eventMapOf(settings);
3969
+ return {
3970
+ ...otherKeys(settings, lifted),
3971
+ hooks: {
3972
+ ...events,
3973
+ PreToolUse: mergeGroups(groupsOf(events, "PreToolUse"), CODEX_PRE_MATCHER, command),
3974
+ PostToolUse: mergeGroups(groupsOf(events, "PostToolUse"), CODEX_POST_MATCHER, command)
3975
+ }
3976
+ };
3977
+ }
3978
+ function hasStroqCodexHook(settings) {
3979
+ return Object.values(nestedEvents(settings)).flatMap((groups) => Array.isArray(groups) ? groups : []).some(
3980
+ (group) => isPlainObject(group) && Array.isArray(group["hooks"]) && group["hooks"].some((handler) => isStroqCodexHook(handler))
3981
+ );
3982
+ }
3983
+ function codexHooksPath(scope, cwd = process.cwd()) {
3984
+ return scope === "user" ? join9(homedir6(), ".codex", "hooks.json") : join9(cwd, ".codex", "hooks.json");
3985
+ }
3986
+ var readCodexHooks = (file) => readJsonObject(file);
3987
+ function installCodexHooks(file, command) {
3988
+ const merged = mergeCodexHooks(readCodexHooks(file), command);
3989
+ writeJsonObject(file, merged);
3990
+ return merged;
3991
+ }
3992
+
3993
+ // src/commands/init.ts
3994
+ import { homedir as homedir7 } from "os";
3995
+ import { join as join10, resolve as resolve2 } from "path";
3902
3996
  import { parseArgs as parseArgs3 } from "util";
3903
3997
  var PRE_MATCHER = "Bash|Write|Edit|MultiEdit|NotebookEdit|Read|WebFetch|mcp__.*";
3904
3998
  var POST_MATCHER = "Read|WebFetch|WebSearch|Bash|Grep|mcp__.*";
3905
- var HOOK_AGENTS = ["claude-code", "cursor"];
3999
+ var HOOK_AGENTS = ["claude-code", "cursor", "codex"];
3906
4000
  function hookCommand(node, entry, agent = "claude-code") {
3907
4001
  const loader = entry.endsWith(".ts") ? " --import tsx" : "";
3908
4002
  return `"${node}"${loader} "${entry}" hook ${agent}`;
@@ -3913,11 +4007,7 @@ var stroqHandler = (command) => ({
3913
4007
  timeout: HOOK_TIMEOUT_SECONDS
3914
4008
  });
3915
4009
  var isStroqHandler = (handler) => / hook claude-code$/.test(handler.command);
3916
- function withoutStroq(groups) {
3917
- return groups.map(
3918
- (g) => Array.isArray(g.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isStroqHandler(h)) } : g
3919
- ).filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0);
3920
- }
4010
+ var withoutStroq = (groups) => withoutStroqGroups(groups, (handler) => isStroqHandler(handler));
3921
4011
  function mergeHooks(settings, command) {
3922
4012
  const hooks = settings.hooks ?? {};
3923
4013
  return {
@@ -3936,7 +4026,7 @@ function mergeHooks(settings, command) {
3936
4026
  };
3937
4027
  }
3938
4028
  function settingsPath(scope, cwd = process.cwd()) {
3939
- return scope === "user" ? join9(homedir6(), ".claude", "settings.json") : join9(cwd, ".claude", "settings.json");
4029
+ return scope === "user" ? join10(homedir7(), ".claude", "settings.json") : join10(cwd, ".claude", "settings.json");
3940
4030
  }
3941
4031
  var readSettings = (file) => readJsonObject(file);
3942
4032
  function installHooks(file, command) {
@@ -3979,6 +4069,30 @@ Restart Cursor, then run "stroq doctor" to verify.
3979
4069
  );
3980
4070
  return 0;
3981
4071
  }
4072
+ var CODEX_NOTE = `On older Codex releases hooks are opt-in: set [features] hooks = true in ~/.codex/config.toml.
4073
+ Project hooks load only once you trust this project's .codex/ layer (Codex asks the first time);
4074
+ "stroq init --agent codex --user" writes ~/.codex/hooks.json instead and skips that prompt.
4075
+ Events an existing file kept at its root are migrated under the official "hooks" wrapper; nothing is dropped.
4076
+ `;
4077
+ function initCodex(scope, command, dryRun) {
4078
+ const file = codexHooksPath(scope);
4079
+ if (dryRun) {
4080
+ process.stdout.write(
4081
+ `${JSON.stringify(mergeCodexHooks(readCodexHooks(file), command), null, 2)}
4082
+ `
4083
+ );
4084
+ return 0;
4085
+ }
4086
+ installCodexHooks(file, command);
4087
+ process.stdout.write(
4088
+ `Stroq hooks installed in ${file}
4089
+ PreToolUse \u2192 ${CODEX_PRE_MATCHER}
4090
+ PostToolUse \u2192 ${CODEX_POST_MATCHER}
4091
+ ${CODEX_NOTE}Run "stroq doctor" to verify.
4092
+ `
4093
+ );
4094
+ return 0;
4095
+ }
3982
4096
  async function runInit(args) {
3983
4097
  const { values } = parseArgs3({
3984
4098
  args: [...args],
@@ -3997,7 +4111,12 @@ async function runInit(args) {
3997
4111
  const scope = values.user ? "user" : "project";
3998
4112
  const dryRun = values["dry-run"] === true;
3999
4113
  const command = hookCommand(process.execPath, resolve2(process.argv[1] ?? ""), agent);
4000
- return agent === "cursor" ? initCursor(scope, command, dryRun) : initClaudeCode(scope, command, dryRun);
4114
+ const install = {
4115
+ "claude-code": initClaudeCode,
4116
+ cursor: initCursor,
4117
+ codex: initCodex
4118
+ };
4119
+ return install[agent](scope, command, dryRun);
4001
4120
  }
4002
4121
 
4003
4122
  // src/commands/doctor.ts
@@ -4019,20 +4138,28 @@ function checkCursorHooks(file) {
4019
4138
  return { installed: false, error: err.message };
4020
4139
  }
4021
4140
  }
4141
+ function checkCodexHooks(file) {
4142
+ try {
4143
+ return { installed: hasStroqCodexHook(readCodexHooks(file)), error: null };
4144
+ } catch (err) {
4145
+ return { installed: false, error: err.message };
4146
+ }
4147
+ }
4022
4148
  function agentScopes(cwd, pathFor, check) {
4023
4149
  return ["project", "user"].map((scope) => {
4024
4150
  const file = pathFor(scope, cwd);
4025
4151
  return { scope, file, ...check(file) };
4026
4152
  });
4027
4153
  }
4028
- function hooksCheck(name, scopes, other) {
4154
+ function hooksCheck(name, scopes, others) {
4029
4155
  const broken = scopes.some((s) => s.error !== null);
4030
4156
  const installed = scopes.some((s) => s.installed);
4157
+ const carrying = others.filter((o) => o.installed).map((o) => o.name);
4031
4158
  const perScope = scopes.map((s) => s.error ?? `${s.scope}: ${s.installed ? "installed" : "missing"} (${s.file})`).join("; ");
4032
4159
  return {
4033
4160
  name,
4034
- ok: !broken && (installed || other.installed),
4035
- detail: !broken && !installed && other.installed ? `not installed (ok: ${other.name} are)` : perScope
4161
+ ok: !broken && (installed || carrying.length > 0),
4162
+ detail: !broken && !installed && carrying.length > 0 ? `not installed (ok: ${carrying.join(", ")} are)` : perScope
4036
4163
  };
4037
4164
  }
4038
4165
  function secretsDetail(stats) {
@@ -4047,7 +4174,7 @@ function secretsDetail(stats) {
4047
4174
  }
4048
4175
  async function checkSecrets() {
4049
4176
  try {
4050
- const stats = await new FileSecretIndex(secretsFile(), homedir7()).stats();
4177
+ const stats = await new FileSecretIndex(secretsFile(), homedir8()).stats();
4051
4178
  const ok = !stats.corrupt && stats.unreadable === 0 && !stats.truncated;
4052
4179
  return { name: "secrets", ok, detail: secretsDetail(stats) };
4053
4180
  } catch (err) {
@@ -4058,10 +4185,22 @@ async function doctorReport(cwd = process.cwd()) {
4058
4185
  const major = Number(process.versions.node.split(".")[0]);
4059
4186
  const rules = loadBundledRules();
4060
4187
  const detected = scanContent(rules, SAMPLE).verdict === "suspect";
4061
- const claude = agentScopes(cwd, settingsPath, checkClaudeHooks);
4062
- const cursor = agentScopes(cwd, cursorHooksPath, checkCursorHooks);
4063
- const claudeAgent = { name: "hooks", installed: claude.some((s) => s.installed) };
4064
- const cursorAgent = { name: "cursor hooks", installed: cursor.some((s) => s.installed) };
4188
+ const agents = [
4189
+ { name: "hooks", scopes: agentScopes(cwd, settingsPath, checkClaudeHooks) },
4190
+ { name: "cursor hooks", scopes: agentScopes(cwd, cursorHooksPath, checkCursorHooks) },
4191
+ { name: "codex hooks", scopes: agentScopes(cwd, codexHooksPath, checkCodexHooks) }
4192
+ ];
4193
+ const statuses = agents.map((a) => ({
4194
+ name: a.name,
4195
+ installed: a.scopes.some((s) => s.installed)
4196
+ }));
4197
+ const hookChecks = agents.map(
4198
+ (agent, i) => hooksCheck(
4199
+ agent.name,
4200
+ agent.scopes,
4201
+ statuses.filter((_, j) => j !== i)
4202
+ )
4203
+ );
4065
4204
  const home = stroqHome();
4066
4205
  const secrets = await checkSecrets();
4067
4206
  return {
@@ -4073,8 +4212,7 @@ async function doctorReport(cwd = process.cwd()) {
4073
4212
  ok: detected,
4074
4213
  detail: detected ? "injection sample detected" : "injection sample NOT detected"
4075
4214
  },
4076
- hooksCheck("hooks", claude, cursorAgent),
4077
- hooksCheck("cursor hooks", cursor, claudeAgent),
4215
+ ...hookChecks,
4078
4216
  {
4079
4217
  name: "home",
4080
4218
  ok: true,
@@ -4092,6 +4230,252 @@ async function runDoctor() {
4092
4230
  return report.checks.every((c) => c.ok) ? 0 : 1;
4093
4231
  }
4094
4232
 
4233
+ // src/adapters/codex.ts
4234
+ import { z as z5 } from "zod";
4235
+
4236
+ // src/adapters/codex-input.ts
4237
+ var BASH_TOOLS = /* @__PURE__ */ new Set(["Bash", "exec_command", "shell", "local_shell"]);
4238
+ var PATCH_TOOLS = /* @__PURE__ */ new Set(["apply_patch", "ApplyPatch"]);
4239
+ var isBashTool = (name) => BASH_TOOLS.has(name);
4240
+ var isPatchTool = (name) => PATCH_TOOLS.has(name);
4241
+ var CODEX_HIGH_IMPACT_TOOL = /^(Bash|exec_command|shell|local_shell|apply_patch|ApplyPatch|mcp__)/;
4242
+ function codexToolName(rawTool) {
4243
+ if (isPatchTool(rawTool)) return "Write";
4244
+ if (isBashTool(rawTool)) return "Bash";
4245
+ if (rawTool.startsWith("mcp__")) return mcpToolName("", rawTool);
4246
+ return rawTool;
4247
+ }
4248
+ var ARGV_SHELL = /^(?:\/\w+\/)*(?:bash|sh|zsh|dash)$/;
4249
+ var ARGV_SHELL_FLAG = /^-[eilx]{0,3}c[eilx]{0,2}$/;
4250
+ var NEEDS_QUOTING = /[\s'"$`\\|&;<>()*?[\]#~]/;
4251
+ var quoteArg = (arg) => NEEDS_QUOTING.test(arg) ? `'${arg.replace(/'/g, "'\\''")}'` : arg;
4252
+ function joinArgv(values) {
4253
+ const argv = values.filter((v) => typeof v === "string");
4254
+ const [shell, flag, ...rest] = argv;
4255
+ const isShellCall = shell !== void 0 && flag !== void 0 && rest.length > 0 && ARGV_SHELL.test(shell) && ARGV_SHELL_FLAG.test(flag);
4256
+ return isShellCall ? rest.join(" ") : argv.map(quoteArg).join(" ");
4257
+ }
4258
+ var COMMAND_FIELDS = ["command", "cmd", "input", "script", "raw"];
4259
+ var NESTED_TEXT_FIELDS = ["text", "command", "cmd"];
4260
+ function nestedText(value) {
4261
+ if (!isRecord(value)) return "";
4262
+ for (const key of NESTED_TEXT_FIELDS) {
4263
+ const inner = value[key];
4264
+ if (typeof inner === "string" && inner !== "") return inner;
4265
+ }
4266
+ return "";
4267
+ }
4268
+ var fieldCommand = (value) => {
4269
+ if (typeof value === "string") return value;
4270
+ if (Array.isArray(value)) return joinArgv(value);
4271
+ return nestedText(value);
4272
+ };
4273
+ function commandCandidates(toolInput) {
4274
+ if (Array.isArray(toolInput)) {
4275
+ const joined = joinArgv(toolInput);
4276
+ return joined === "" ? [] : [joined];
4277
+ }
4278
+ const record = toolInputRecord(toolInput);
4279
+ const found = /* @__PURE__ */ new Set();
4280
+ for (const key of COMMAND_FIELDS) {
4281
+ const text = fieldCommand(record[key]);
4282
+ if (text !== "") found.add(text);
4283
+ }
4284
+ return [...found];
4285
+ }
4286
+ var commandOf = (toolInput) => commandCandidates(toolInput)[0] ?? "";
4287
+ var PATCH_FIELDS = ["command", "input", "patch", "raw", "cmd", "script", "arguments"];
4288
+ var stripBom = (text) => text.startsWith("\uFEFF") ? text.slice(1) : text;
4289
+ var joinLines = (values) => values.filter((v) => typeof v === "string").map(stripBom).join("\n");
4290
+ function patchFieldTexts(value, depth) {
4291
+ if (typeof value === "string") return value === "" ? [] : [stripBom(value)];
4292
+ if (Array.isArray(value)) {
4293
+ const joined = joinLines(value);
4294
+ return joined === "" ? [] : [joined];
4295
+ }
4296
+ if (depth > 0 && isRecord(value))
4297
+ return PATCH_FIELDS.flatMap((key) => patchFieldTexts(value[key], depth - 1));
4298
+ return [];
4299
+ }
4300
+ function patchTextOf(toolInput) {
4301
+ if (Array.isArray(toolInput)) return joinLines(toolInput);
4302
+ const record = toolInputRecord(toolInput);
4303
+ return PATCH_FIELDS.flatMap((key) => patchFieldTexts(record[key], 1)).join("\n");
4304
+ }
4305
+ var PATCH_HEADER = /^\*\*\* (?:Add File|Update File|Delete File|Move to):[ \t]*([^\r\n]*?)[ \t\r]*$/;
4306
+ function applyPatchPaths(patchText) {
4307
+ const paths = /* @__PURE__ */ new Set();
4308
+ for (const line of patchText.split("\n")) {
4309
+ const path = PATCH_HEADER.exec(line)?.[1] ?? "";
4310
+ if (path !== "") paths.add(path);
4311
+ }
4312
+ return [...paths];
4313
+ }
4314
+ function codexToolInput(input) {
4315
+ if (isBashTool(input.tool_name)) return { command: commandOf(input.tool_input) };
4316
+ if (isPatchTool(input.tool_name)) {
4317
+ const paths = applyPatchPaths(patchTextOf(input.tool_input));
4318
+ return { file_path: paths[0] ?? "", file_paths: [...paths] };
4319
+ }
4320
+ return toolInputRecord(input.tool_input);
4321
+ }
4322
+ function isEmptyToolInput(value) {
4323
+ if (value === void 0 || value === null || value === "") return true;
4324
+ if (Array.isArray(value)) return value.length === 0;
4325
+ return isRecord(value) && Object.keys(value).length === 0;
4326
+ }
4327
+ function describeToolInput(value) {
4328
+ if (Array.isArray(value)) return "array";
4329
+ if (isRecord(value)) return Object.keys(value).sort().join(", ");
4330
+ return value === null ? "null" : typeof value;
4331
+ }
4332
+
4333
+ // src/adapters/codex.ts
4334
+ var CODEX_EVENTS = ["PreToolUse", "PostToolUse"];
4335
+ var CodexHookInputSchema = z5.looseObject({
4336
+ session_id: z5.string().min(1),
4337
+ hook_event_name: z5.enum(CODEX_EVENTS),
4338
+ tool_name: z5.string(),
4339
+ tool_input: z5.unknown().optional(),
4340
+ tool_response: z5.unknown().optional(),
4341
+ cwd: z5.string().default(""),
4342
+ // Never read; see the note above.
4343
+ model: z5.unknown().optional(),
4344
+ permission_mode: z5.unknown().optional(),
4345
+ transcript_path: z5.unknown().optional(),
4346
+ turn_id: z5.unknown().optional(),
4347
+ tool_use_id: z5.unknown().optional()
4348
+ });
4349
+ function codexResultText(response) {
4350
+ if (response && typeof response === "object" && !Array.isArray(response)) {
4351
+ const record = response;
4352
+ const output = record["output"];
4353
+ if (typeof output === "string" && output !== "") return toolResultToText(output);
4354
+ const streams = [record["stdout"], record["stderr"]].filter(
4355
+ (part) => typeof part === "string" && part.length > 0
4356
+ );
4357
+ if (streams.length > 0) return toolResultToText(streams.join("\n"));
4358
+ }
4359
+ return toolResultToText(response);
4360
+ }
4361
+ var envelope = (event, fields) => ({
4362
+ stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: event, ...fields } }),
4363
+ exitCode: 0
4364
+ });
4365
+ var codexDenyOutput = (reason) => envelope("PreToolUse", { permissionDecision: "deny", permissionDecisionReason: reason });
4366
+ var codexContextOutput = (context) => envelope("PostToolUse", { additionalContext: context });
4367
+ var codexBlockOutput = (reason) => ({
4368
+ stdout: "",
4369
+ stderr: reason,
4370
+ exitCode: 2
4371
+ });
4372
+ var askAsDeny = (decision) => `Stroq would ask before this action (${decision.ruleId}): ${decision.reason}. Codex hooks cannot prompt, so it is denied; run it yourself or relax the rule in ~/.stroq/policy.yaml.`;
4373
+ function renderDecision2(decision, provenance, secrets, now = /* @__PURE__ */ new Date()) {
4374
+ if (decision.effect === "allow") return NO_OUTPUT;
4375
+ const headline = decision.effect === "deny" ? `Stroq blocked this action (${decision.ruleId}): ${decision.reason}` : askAsDeny(decision);
4376
+ return codexDenyOutput(withEvidence(headline, provenance, now, secrets));
4377
+ }
4378
+ var MAX_PATCH_PATHS = 64;
4379
+ var CODEX_PATCH_TOO_LARGE = {
4380
+ effect: "deny",
4381
+ ruleId: "codex-patch-too-large",
4382
+ reason: `the patch declares more than ${MAX_PATCH_PATHS} files, more than Stroq can classify inside Codex's hook timeout`
4383
+ };
4384
+ var codexUnreadableInput = (shape) => ({
4385
+ effect: "deny",
4386
+ ruleId: "codex-unreadable-input",
4387
+ reason: `Stroq could not read the command or patch from Codex's tool_input (keys: ${shape}); denied fail-closed. Report the payload shape at https://github.com/AGGIB/Stroq/issues`
4388
+ });
4389
+ var asPaths = (value) => Array.isArray(value) ? value.filter((p) => typeof p === "string") : [];
4390
+ function unreadableInput(input, guards) {
4391
+ const bash = isBashTool(input.tool_name);
4392
+ const patch = isPatchTool(input.tool_name);
4393
+ if (!bash && !patch) return null;
4394
+ if (isEmptyToolInput(input.tool_input)) return null;
4395
+ const readable = bash ? guards.commands.length > 0 : guards.patchPaths.length > 0;
4396
+ return readable ? null : codexUnreadableInput(describeToolInput(input.tool_input));
4397
+ }
4398
+ function preInputs(toolInput, guards) {
4399
+ if (guards.commands.length > 1)
4400
+ return guards.commands.map((command) => ({ ...toolInput, command }));
4401
+ if (guards.patchPaths.length > 1)
4402
+ return guards.patchPaths.map((file_path) => ({ ...toolInput, file_path }));
4403
+ return [{ ...toolInput }];
4404
+ }
4405
+ var SEVERITY = { allow: 0, ask: 1, deny: 2 };
4406
+ async function decidePre(engine, event, inputs) {
4407
+ const [first, ...rest] = inputs;
4408
+ if (!first) throw new Error("decidePre: inputs must be non-empty");
4409
+ let worst = await engine.pre({ ...event, toolInput: first });
4410
+ for (const toolInput of rest) {
4411
+ const next = await engine.pre({ ...event, toolInput });
4412
+ if (SEVERITY[next.decision.effect] > SEVERITY[worst.decision.effect]) worst = next;
4413
+ }
4414
+ return worst;
4415
+ }
4416
+ async function denyDirectly(event, decision, summary) {
4417
+ await new AuditLog(auditFile()).append({
4418
+ sessionId: event.sessionId,
4419
+ phase: "pre",
4420
+ tool: event.toolName,
4421
+ summary,
4422
+ classes: [],
4423
+ decision
4424
+ });
4425
+ return renderDecision2(decision, [], []);
4426
+ }
4427
+ function preGuards(input, toolInput) {
4428
+ const found = {
4429
+ commands: isBashTool(input.tool_name) ? commandCandidates(input.tool_input) : [],
4430
+ patchPaths: isPatchTool(input.tool_name) ? asPaths(toolInput["file_paths"]) : []
4431
+ };
4432
+ return { ...found, unreadable: unreadableInput(input, found) };
4433
+ }
4434
+ async function handlePre(engine, event, guards) {
4435
+ if (guards.unreadable)
4436
+ return denyDirectly(event, guards.unreadable, "codex: unreadable tool_input");
4437
+ if (guards.patchPaths.length > MAX_PATCH_PATHS)
4438
+ return denyDirectly(
4439
+ event,
4440
+ CODEX_PATCH_TOO_LARGE,
4441
+ `apply_patch: ${guards.patchPaths.length} files`
4442
+ );
4443
+ const { decision, provenance, secrets } = await decidePre(
4444
+ engine,
4445
+ event,
4446
+ preInputs(event.toolInput, guards)
4447
+ );
4448
+ return renderDecision2(decision, provenance, secrets);
4449
+ }
4450
+ async function handlePost(engine, event, response) {
4451
+ const result = await engine.post({ ...event, toolResultText: codexResultText(response) });
4452
+ if (result.provenanceError) logError("provenance", result.provenanceError);
4453
+ if (!result.scanned || result.scan.verdict !== "suspect") return NO_OUTPUT;
4454
+ return codexContextOutput(warningFor(result.scan, event.toolName));
4455
+ }
4456
+ async function handleCodexHook(engine, raw) {
4457
+ const input = CodexHookInputSchema.parse(raw);
4458
+ const toolInput = codexToolInput(input);
4459
+ const event = {
4460
+ sessionId: input.session_id,
4461
+ toolName: codexToolName(input.tool_name),
4462
+ toolInput,
4463
+ cwd: input.cwd || process.cwd()
4464
+ };
4465
+ if (input.hook_event_name === "PostToolUse")
4466
+ return handlePost(engine, event, input.tool_response);
4467
+ return handlePre(engine, event, preGuards(input, toolInput));
4468
+ }
4469
+ function codexFailClosedOutput(raw, err) {
4470
+ const record = raw && typeof raw === "object" ? raw : {};
4471
+ const event = record["hook_event_name"];
4472
+ const tool = record["tool_name"];
4473
+ if (typeof event === "string" && event !== "PreToolUse") return NO_OUTPUT;
4474
+ if (typeof tool === "string" && !CODEX_HIGH_IMPACT_TOOL.test(tool)) return NO_OUTPUT;
4475
+ const message = err instanceof Error ? err.message : String(err);
4476
+ return codexBlockOutput(`Stroq internal error (fail-closed): ${message}`);
4477
+ }
4478
+
4095
4479
  // src/commands/hook.ts
4096
4480
  async function readStdin(stream = process.stdin) {
4097
4481
  let data = "";
@@ -4105,6 +4489,14 @@ var ADAPTERS = {
4105
4489
  handle: handleCursorHook,
4106
4490
  failClosed: cursorFailClosedOutput,
4107
4491
  badJson: cursorDenyOutput
4492
+ },
4493
+ // Codex answers a block with exit code 2 and the reason on stderr, not with JSON:
4494
+ // stdin that was not JSON at all is exactly the case where a JSON deny would be
4495
+ // dropped as an unsupported/unparseable payload, i.e. fail open.
4496
+ codex: {
4497
+ handle: handleCodexHook,
4498
+ failClosed: codexFailClosedOutput,
4499
+ badJson: codexBlockOutput
4108
4500
  }
4109
4501
  };
4110
4502
  var SUPPORTED_AGENTS = Object.keys(ADAPTERS);
@@ -4132,6 +4524,16 @@ async function runHook(agent, rawJson) {
4132
4524
  return adapter.failClosed(raw, err);
4133
4525
  }
4134
4526
  }
4527
+ async function runHookCommand(agent, read = readStdin) {
4528
+ try {
4529
+ return await runHook(agent, await read());
4530
+ } catch (err) {
4531
+ if (agent !== "codex") throw err;
4532
+ logError(`hook ${agent}`, err);
4533
+ const message = err instanceof Error ? err.message : String(err);
4534
+ return codexBlockOutput(`Stroq internal error (fail-closed): ${message}`);
4535
+ }
4536
+ }
4135
4537
 
4136
4538
  // src/commands/log.ts
4137
4539
  import { parseArgs as parseArgs4 } from "util";
@@ -4160,13 +4562,13 @@ async function runLog(args) {
4160
4562
  // src/commands/untaint.ts
4161
4563
  import { existsSync as existsSync4, readdirSync as readdirSync4 } from "fs";
4162
4564
  import { rm as rm5 } from "fs/promises";
4163
- import { join as join10 } from "path";
4565
+ import { join as join11 } from "path";
4164
4566
  import { parseArgs as parseArgs5 } from "util";
4165
4567
  var USAGE = "usage: stroq untaint --session <id> | --all\n";
4166
4568
  async function clearAll() {
4167
4569
  const dir = sessionsDir();
4168
4570
  if (!existsSync4(dir)) return;
4169
- await Promise.all(readdirSync4(dir).map((f) => rm5(join10(dir, f), { force: true })));
4571
+ await Promise.all(readdirSync4(dir).map((f) => rm5(join11(dir, f), { force: true })));
4170
4572
  }
4171
4573
  async function runUntaint(args) {
4172
4574
  const { values } = parseArgs5({
@@ -4255,8 +4657,8 @@ var USAGE2 = `stroq <command>
4255
4657
 
4256
4658
  Commands:
4257
4659
  init [--agent <name>] [--user] [--dry-run]
4258
- install hooks (--agent claude-code | cursor; project config by default)
4259
- hook <claude-code|cursor> hook entrypoint: reads the event JSON on stdin, prints a decision
4660
+ install hooks (--agent claude-code | cursor | codex; project config by default)
4661
+ hook <claude-code|cursor|codex> hook entrypoint: reads the event JSON on stdin, prints a decision
4260
4662
  doctor check the installation
4261
4663
  log [--count 20] show recent audit entries
4262
4664
  verify verify the audit hash chain
@@ -4269,8 +4671,9 @@ async function main(argv) {
4269
4671
  const [command, ...rest] = argv;
4270
4672
  switch (command) {
4271
4673
  case "hook": {
4272
- const out = await runHook(rest[0] ?? "", await readStdin());
4674
+ const out = await runHookCommand(rest[0] ?? "");
4273
4675
  if (out.stdout) process.stdout.write(out.stdout);
4676
+ if (out.stderr) process.stderr.write(out.stderr);
4274
4677
  return out.exitCode;
4275
4678
  }
4276
4679
  case "init":