@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/DISCLOSURE +68 -0
- package/README.md +1 -1
- package/dist/index.js +444 -41
- package/dist/index.js.map +1 -1
- package/package.json +12 -8
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
|
|
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
|
|
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/
|
|
3908
|
+
// src/commands/codex-hooks.ts
|
|
3900
3909
|
import { homedir as homedir6 } from "os";
|
|
3901
|
-
import { join as join9
|
|
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
|
-
|
|
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" ?
|
|
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
|
-
|
|
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,
|
|
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 ||
|
|
4035
|
-
detail: !broken && !installed &&
|
|
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(),
|
|
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
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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>
|
|
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
|
|
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":
|