@nodii/dev 0.6.24 → 0.6.26
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/README.md +46 -11
- package/dist/cli.js +62 -16
- package/dist/core.js +2 -2
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +20 -0
- package/dist/harness.js +114 -21
- package/dist/hook-doctor.js +126 -0
- package/dist/hooks.js +31 -1
- package/dist/lanes.js +57 -5
- package/dist/managed-hooks.js +166 -0
- package/dist/safety.js +112 -27
- package/dist/setup.js +18 -2
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
6
|
+
import { CliError, VERSION } from "./core.js";
|
|
7
|
+
|
|
8
|
+
export const CODEX_HOOK_EVENTS = [
|
|
9
|
+
"SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse",
|
|
10
|
+
"PreCompact", "PostCompact", "SubagentStart", "SubagentStop", "Stop", "Interrupt", "SessionEnd",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const managedHooksDir = (home = homedir()) => join(home, ".config", "nodii-dev", "codex-managed-hooks");
|
|
14
|
+
export const MANAGED_DISPATCH_MIN_VERSION = "0.6.26";
|
|
15
|
+
|
|
16
|
+
/** Only a single nodii-dev invocation is allowed; shell composition is never accepted. */
|
|
17
|
+
export function nodiiCommandArgs(command) {
|
|
18
|
+
const text = String(command ?? "").trim();
|
|
19
|
+
if (!text.startsWith("nodii-dev") || /[;&|<>`$\\\r\n]/.test(text))
|
|
20
|
+
throw new CliError("HARNESS_INVALID", `Managed hooks may run one nodii-dev command only: ${command}`, 1);
|
|
21
|
+
const words = text.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
22
|
+
if (words.shift() !== "nodii-dev") throw new CliError("HARNESS_INVALID", `Managed hooks may run nodii-dev commands only: ${command}`, 1);
|
|
23
|
+
return words.map((word) => /^(['"]).*\1$/.test(word) ? word.slice(1, -1) : word);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateManagedHooks(hooks = {}) {
|
|
27
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
28
|
+
if (!CODEX_HOOK_EVENTS.includes(event)) throw new CliError("HARNESS_INVALID", `Codex does not support managed hook event ${event}.`, 1);
|
|
29
|
+
for (const entry of entries)
|
|
30
|
+
for (const handler of entry.hooks ?? []) {
|
|
31
|
+
if (handler.type !== "command") throw new CliError("HARNESS_INVALID", `Managed ${event} hooks must be command handlers.`, 1);
|
|
32
|
+
nodiiCommandArgs(handler.command);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tomlString = (value) => JSON.stringify(String(value));
|
|
38
|
+
const shellString = (value) => `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
39
|
+
const laneConfig = (env) => /^lane-[a-z0-9_-]+\.json$/i.test(basename(env.NODII_DEV_CONFIG_PATH ?? ""));
|
|
40
|
+
const emptyResponse = (event) => ["Stop", "SubagentStop"].includes(event) ? "{}\n" : "";
|
|
41
|
+
|
|
42
|
+
/** Stable managed policy: all later hook changes live in user-owned hooks.json. */
|
|
43
|
+
export function managedRequirements(dir) {
|
|
44
|
+
const dispatcher = join(dir, "dispatch");
|
|
45
|
+
return [
|
|
46
|
+
"[features]",
|
|
47
|
+
"hooks = true",
|
|
48
|
+
"",
|
|
49
|
+
"[hooks]",
|
|
50
|
+
`managed_dir = ${tomlString(dir)}`,
|
|
51
|
+
"",
|
|
52
|
+
...CODEX_HOOK_EVENTS.flatMap((event) => [
|
|
53
|
+
`[[hooks.${event}]]`,
|
|
54
|
+
'matcher = ".*"',
|
|
55
|
+
"",
|
|
56
|
+
`[[hooks.${event}.hooks]]`,
|
|
57
|
+
'type = "command"',
|
|
58
|
+
`command = ${tomlString(`${dispatcher} ${event}`)}`,
|
|
59
|
+
"timeout = 30",
|
|
60
|
+
'statusMessage = "Applying Nodii harness policy"',
|
|
61
|
+
"",
|
|
62
|
+
]),
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function managedHookBundle(hooksText, { home, cliPath, cliVersion = VERSION } = {}) {
|
|
67
|
+
let parsed;
|
|
68
|
+
try { parsed = JSON.parse(String(hooksText ?? "")); }
|
|
69
|
+
catch { throw new CliError("HARNESS_SETTINGS_INVALID", ".codex/hooks.json is not valid JSON.", 2); }
|
|
70
|
+
validateManagedHooks(parsed.hooks ?? {});
|
|
71
|
+
if (!isAbsolute(String(cliPath ?? ""))) throw new CliError("HARNESS_INVALID", "The managed Codex dispatcher needs the absolute nodii-dev executable path.", 1);
|
|
72
|
+
const dir = managedHooksDir(home);
|
|
73
|
+
const command = shellString(cliPath);
|
|
74
|
+
const dispatch = `#!/bin/sh\nmanaged_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nmanaged_cli=${command}\nempty_response() {\n case "$1" in Stop|SubagentStop) printf '{}\\n' ;; esac\n}\ncase "\${NODII_DEV_CONFIG_PATH##*/}" in lane-*.json) ;; *) empty_response "$1"; exit 0 ;; esac\nif [ ! -x "$managed_cli" ]; then empty_response "$1"; exit 0; fi\nexport NODII_DEV_MANAGED_HOOKS_DIR="$managed_dir"\nexport NODII_DEV_MANAGED_CLI_PATH="$managed_cli"\nmanaged_output=$("$managed_cli" hook managed-dispatch "$1" 2>/dev/null)\nmanaged_status=$?\nif [ "$managed_status" -ne 0 ]; then empty_response "$1"; exit 0; fi\nif [ -n "$managed_output" ]; then printf '%s\\n' "$managed_output"; else empty_response "$1"; fi\n`;
|
|
75
|
+
const metadata = `${JSON.stringify({ cliPath, cliVersion, minimumVersion: MANAGED_DISPATCH_MIN_VERSION }, null, 2)}\n`;
|
|
76
|
+
return { dir, hooksText: `${JSON.stringify({ hooks: parsed.hooks ?? {} }, null, 2)}\n`, dispatch, metadata, requirements: managedRequirements(dir) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeManagedHookBundle(bundle) {
|
|
80
|
+
await mkdir(bundle.dir, { recursive: true, mode: 0o700 });
|
|
81
|
+
await writeFile(join(bundle.dir, "hooks.json"), bundle.hooksText, { mode: 0o600 });
|
|
82
|
+
await writeFile(join(bundle.dir, "dispatch"), bundle.dispatch, { mode: 0o700 });
|
|
83
|
+
await chmod(join(bundle.dir, "dispatch"), 0o700);
|
|
84
|
+
await writeFile(join(bundle.dir, "metadata.json"), bundle.metadata, { mode: 0o600 });
|
|
85
|
+
await writeFile(join(bundle.dir, "requirements.toml"), bundle.requirements, { mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runHandler(args, input, { spawnImpl = spawn, env = process.env } = {}) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const child = spawnImpl(env.NODII_DEV_MANAGED_CLI_PATH ?? "nodii-dev", args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
91
|
+
let stdout = "";
|
|
92
|
+
let stderr = "";
|
|
93
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
94
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
95
|
+
child.on("error", (error) => resolve({ status: 1, stdout, stderr: `${stderr}${error.message}` }));
|
|
96
|
+
child.on("exit", (status) => resolve({ status: status ?? 1, stdout, stderr }));
|
|
97
|
+
child.stdin.end(input);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const matchTarget = (event, payload) => {
|
|
102
|
+
if (["PreToolUse", "PermissionRequest", "PostToolUse"].includes(event)) return payload.tool_name ?? "";
|
|
103
|
+
if (event === "SessionStart") return payload.source ?? "";
|
|
104
|
+
return payload.agent_type ?? payload.source ?? "";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function responseOf(stdout) {
|
|
108
|
+
try { return JSON.parse(stdout.trim()); } catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Execute the current generated hook set behind the stable managed dispatcher. */
|
|
112
|
+
export async function dispatchManagedHook(event, payloadText, deps = {}) {
|
|
113
|
+
if (!CODEX_HOOK_EVENTS.includes(event)) throw new CliError("USAGE", `Unknown managed Codex hook event: ${event}`, 2);
|
|
114
|
+
const env = deps.env ?? process.env;
|
|
115
|
+
if (!laneConfig(env)) return emptyResponse(event);
|
|
116
|
+
const dir = deps.dir ?? env.NODII_DEV_MANAGED_HOOKS_DIR ?? managedHooksDir();
|
|
117
|
+
let config;
|
|
118
|
+
try { config = JSON.parse(await (deps.read ?? readFile)(join(dir, "hooks.json"), "utf8")); }
|
|
119
|
+
catch (error) { throw new CliError("HARNESS_SETTINGS_INVALID", `Cannot read managed Codex hooks: ${error.message}`, 2); }
|
|
120
|
+
validateManagedHooks(config.hooks ?? {});
|
|
121
|
+
const payload = JSON.parse(payloadText);
|
|
122
|
+
const target = matchTarget(event, payload);
|
|
123
|
+
const handlers = (config.hooks?.[event] ?? []).filter((entry) => {
|
|
124
|
+
try { return new RegExp(entry.matcher || ".*").test(target); } catch { return false; }
|
|
125
|
+
}).flatMap((entry) => entry.hooks ?? []);
|
|
126
|
+
const results = await Promise.all(handlers.map((handler) => (deps.run ?? runHandler)(nodiiCommandArgs(handler.command), payloadText, deps)));
|
|
127
|
+
const messages = results.map((result) => responseOf(result.stdout)).filter(Boolean);
|
|
128
|
+
const errors = results.filter((result) => result.status !== 0).map((result) => result.stderr.trim() || result.stdout.trim()).filter(Boolean);
|
|
129
|
+
if (["PreToolUse", "PermissionRequest"].includes(event)) {
|
|
130
|
+
const denied = messages.find((message) => message.hookSpecificOutput?.permissionDecision === "deny");
|
|
131
|
+
if (denied) return `${JSON.stringify(denied)}\n`;
|
|
132
|
+
if (errors.length) return `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, permissionDecision: "deny", permissionDecisionReason: errors.join("\n") } })}\n`;
|
|
133
|
+
const contexts = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
|
|
134
|
+
return contexts.length ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: contexts.join("\n") } })}\n` : "";
|
|
135
|
+
}
|
|
136
|
+
const structured = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
|
|
137
|
+
const plain = results.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
|
|
138
|
+
if (["Stop", "SubagentStop"].includes(event)) return "{}\n";
|
|
139
|
+
return [...structured, ...plain, ...errors].join("\n") + ([...structured, ...plain, ...errors].length ? "\n" : "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const versionParts = (value) => String(value ?? "").split(".").map((part) => Number.parseInt(part, 10));
|
|
143
|
+
const atLeast = (actual, minimum) => {
|
|
144
|
+
const a = versionParts(actual);
|
|
145
|
+
const b = versionParts(minimum);
|
|
146
|
+
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
|
|
147
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
148
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The managed dispatcher deliberately fails open; doctor makes that visible. */
|
|
154
|
+
export async function managedHookCliStatus({ home = homedir(), read = readFile, accessImpl = access } = {}) {
|
|
155
|
+
let metadata;
|
|
156
|
+
try { metadata = JSON.parse(await read(join(managedHooksDir(home), "metadata.json"), "utf8")); }
|
|
157
|
+
catch (error) {
|
|
158
|
+
return { ok: false, reason: "metadata_missing", message: `managed hook metadata is unavailable (${error.code ?? error.message})` };
|
|
159
|
+
}
|
|
160
|
+
if (!isAbsolute(String(metadata.cliPath ?? ""))) return { ...metadata, ok: false, reason: "path_invalid", message: "managed hook nodii-dev path is not absolute" };
|
|
161
|
+
try { await accessImpl(metadata.cliPath, constants.X_OK); }
|
|
162
|
+
catch { return { ...metadata, ok: false, reason: "cli_missing", message: `managed hook CLI is missing or not executable: ${metadata.cliPath}` }; }
|
|
163
|
+
const minimum = metadata.minimumVersion ?? MANAGED_DISPATCH_MIN_VERSION;
|
|
164
|
+
if (!atLeast(metadata.cliVersion, minimum)) return { ...metadata, ok: false, reason: "cli_too_old", message: `managed hook CLI ${metadata.cliVersion ?? "unknown"} is older than ${minimum}` };
|
|
165
|
+
return { ...metadata, ok: true };
|
|
166
|
+
}
|
package/dist/safety.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
6
6
|
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const ACTIVE = new Set(["queued", "building", "built", "review", "on_main"]);
|
|
9
|
-
const
|
|
9
|
+
const SHELL_TOOLS = new Set(["Bash", "shell", "exec_command"]);
|
|
10
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]);
|
|
10
11
|
const GENERATED_MARK = /(?:do not edit|auto-?generated|generated (?:file|code)|@generated)/i;
|
|
11
12
|
const REVIEW_RECEIPT = "nodii-review-receipt.json";
|
|
12
13
|
const SAFETY_STATE = "nodii-safety-state.json";
|
|
13
14
|
const SAFETY_AUDIT = "nodii-safety-audit.jsonl";
|
|
15
|
+
export const HOOK_EVENTS = "nodii-hook-events.jsonl";
|
|
14
16
|
|
|
15
17
|
async function optional(path) {
|
|
16
18
|
try { return await readFile(path, "utf8"); }
|
|
@@ -25,6 +27,23 @@ async function gitPrivatePath(cwd, name) {
|
|
|
25
27
|
return git(cwd, "rev-parse", "--path-format=absolute", "--git-path", name);
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** Minimal private receipt used by doctor to prove a runtime actually fired. */
|
|
31
|
+
export async function recordHookFire(payload) {
|
|
32
|
+
const cwd = payload?.cwd ?? process.cwd();
|
|
33
|
+
const path = await gitPrivatePath(cwd, HOOK_EVENTS);
|
|
34
|
+
const event = {
|
|
35
|
+
at: new Date().toISOString(),
|
|
36
|
+
runtime: hookRuntime(payload),
|
|
37
|
+
event: payload?.hook_event_name ?? "unknown",
|
|
38
|
+
...(payload?.source ? { source: payload.source } : {}),
|
|
39
|
+
...(payload?.tool_name ? { tool: payload.tool_name } : {}),
|
|
40
|
+
};
|
|
41
|
+
if (event.runtime === "codex") event.profile = basename(process.env.CODEX_HOME ?? ".codex") === ".codex-work" ? "codex-work" : "codex-personal";
|
|
42
|
+
await mkdir(dirname(path), { recursive: true });
|
|
43
|
+
await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
44
|
+
return event;
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
const cleanPath = (path) => String(path).split(sep).join("/").replace(/^\.\//, "");
|
|
29
48
|
const quotePattern = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
30
49
|
function globPattern(glob) {
|
|
@@ -121,12 +140,20 @@ function exactDeployGrant(context) {
|
|
|
121
140
|
return context.deployGrants.some((x) => x === context.head);
|
|
122
141
|
}
|
|
123
142
|
|
|
143
|
+
const provenLane = (context) => context.provenLane === true || (context.provenLane === undefined && Boolean(context.lane));
|
|
144
|
+
const nudgeViolation = (violation) => `nodii-dev safety: unproven lane context; ${violation.message.replace(/\bblocked\b/gi, "would be blocked")}`;
|
|
145
|
+
|
|
124
146
|
/** Data-dependent checks nudge, rather than silently passing, when their source is not available yet. */
|
|
125
147
|
export function commandNudges(command, context) {
|
|
126
148
|
const tokens = words(command);
|
|
127
149
|
const nudges = [];
|
|
128
150
|
if (deployCommand(tokens, command) && !context.deployGrantKnown)
|
|
129
151
|
nudges.push("nodii-dev safety: can't verify deploy authorization: no GO grant source is configured (follow-up AGENT-HARNESS-13).");
|
|
152
|
+
if (gitInvocations(tokens).some((x) => x.verb === "push")) {
|
|
153
|
+
if (!provenLane(context)) nudges.push("nodii-dev safety: can't verify review: this is not a proven current-lane context.");
|
|
154
|
+
else if (!context.reviewFlowKnown) nudges.push(`nodii-dev safety: can't verify review for ${context.lane?.key ?? context.branch}: Dev's lane review receipt flow is unavailable.`);
|
|
155
|
+
}
|
|
156
|
+
if (!provenLane(context)) nudges.push(...commandViolations(command, context).map(nudgeViolation));
|
|
130
157
|
return nudges;
|
|
131
158
|
}
|
|
132
159
|
|
|
@@ -135,10 +162,10 @@ export function commandViolations(command, context) {
|
|
|
135
162
|
const tokens = words(command);
|
|
136
163
|
const gitCommands = gitInvocations(tokens);
|
|
137
164
|
const violations = [];
|
|
138
|
-
const lane = context
|
|
165
|
+
const lane = provenLane(context);
|
|
139
166
|
const block = (code, message) => violations.push({ code, message });
|
|
140
167
|
|
|
141
|
-
if (gitCommands.some((x) => x.verb === "stash")
|
|
168
|
+
if (gitCommands.some((x) => x.verb === "stash"))
|
|
142
169
|
block("SAFETY_GIT_STASH", "git stash is shared by every worktree; lanes and subagents use a work-in-progress commit.");
|
|
143
170
|
|
|
144
171
|
for (const verb of ["checkout", "switch"]) {
|
|
@@ -168,7 +195,7 @@ export function commandViolations(command, context) {
|
|
|
168
195
|
else if (!context.noVerifyReason) block("SAFETY_NO_VERIFY_REASON", "A coordinator may use --no-verify only with a recorded reason.");
|
|
169
196
|
}
|
|
170
197
|
|
|
171
|
-
if (gitCommands.some((x) => x.verb === "push")) {
|
|
198
|
+
if (lane && context.reviewFlowKnown && gitCommands.some((x) => x.verb === "push")) {
|
|
172
199
|
const receipt = context.reviewReceipt;
|
|
173
200
|
if (!receipt) block("SAFETY_REVIEW_REQUIRED", `Push blocked: no review receipt for ${context.head}. Run nodii-dev lane review after the gates are green.`);
|
|
174
201
|
else if (receipt.commit !== context.head)
|
|
@@ -177,7 +204,7 @@ export function commandViolations(command, context) {
|
|
|
177
204
|
block("SAFETY_REVIEW_BASE", `Push blocked: review used base ${receipt.base}, but origin/main is ${context.base}. Re-review the current diff.`);
|
|
178
205
|
}
|
|
179
206
|
|
|
180
|
-
if (
|
|
207
|
+
if (/(?:^|[\s/])(?:nodii-dev|@nodii\/dev(?:@[^\s]+)?)\s+(?:login|logout|agent\s+register)(?:\s|$)/.test(command))
|
|
181
208
|
block("SAFETY_IDENTITY", "A lane never runs nodii-dev login, logout or agent register; ask the coordinator.");
|
|
182
209
|
if (/\bnodii\/prod(?:\/|\b)/i.test(command))
|
|
183
210
|
block("SAFETY_PROD_SECRET", "nodii/prod secrets are outside lane authority.");
|
|
@@ -207,9 +234,11 @@ export function commandViolations(command, context) {
|
|
|
207
234
|
/** Edit-tool ownership and generated-file blocks. */
|
|
208
235
|
export async function editViolations(paths, context) {
|
|
209
236
|
const violations = [];
|
|
237
|
+
const root = await realpath(context.root).catch(() => resolve(context.root));
|
|
210
238
|
for (const path of paths) {
|
|
211
|
-
const
|
|
212
|
-
const
|
|
239
|
+
const unresolved = isAbsolute(path) ? resolve(path) : resolve(context.cwd, path);
|
|
240
|
+
const absolute = await realpath(unresolved).catch(async () => join(await realpath(dirname(unresolved)).catch(() => dirname(unresolved)), basename(unresolved)));
|
|
241
|
+
const rel = cleanPath(relative(root, absolute));
|
|
213
242
|
if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
|
|
214
243
|
violations.push({ code: "SAFETY_EDIT_OUTSIDE_REPO", message: `Edit blocked outside the lane repository: ${path}.` });
|
|
215
244
|
continue;
|
|
@@ -224,8 +253,22 @@ export async function editViolations(paths, context) {
|
|
|
224
253
|
return violations;
|
|
225
254
|
}
|
|
226
255
|
|
|
256
|
+
export function hookRuntime(payload) {
|
|
257
|
+
return payload?.model || payload?.turn_id || payload?.tool_name === "apply_patch" ? "codex" : "claude";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function patchPaths(command) {
|
|
261
|
+
const paths = [];
|
|
262
|
+
for (const line of String(command ?? "").split(/\r?\n/)) {
|
|
263
|
+
const match = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) ?? /^\*\*\* Move to: (.+)$/.exec(line);
|
|
264
|
+
if (match?.[1]?.trim()) paths.push(match[1].trim());
|
|
265
|
+
}
|
|
266
|
+
return [...new Set(paths)];
|
|
267
|
+
}
|
|
268
|
+
|
|
227
269
|
function editPaths(payload) {
|
|
228
270
|
const input = payload.tool_input ?? {};
|
|
271
|
+
if (payload.tool_name === "apply_patch") return patchPaths(input.command);
|
|
229
272
|
return [input.file_path, input.path, input.notebook_path, ...(Array.isArray(input.paths) ? input.paths : [])].filter((x) => typeof x === "string" && x);
|
|
230
273
|
}
|
|
231
274
|
|
|
@@ -253,9 +296,15 @@ const laneStack = (key) => String(key ?? "").toLowerCase().replace(/[^a-z0-9_-]/
|
|
|
253
296
|
async function currentLane(cwd, request, config) {
|
|
254
297
|
if (!config?.agentId) return null;
|
|
255
298
|
const branch = await git(cwd, "branch", "--show-current");
|
|
256
|
-
const
|
|
299
|
+
const rootText = await git(cwd, "rev-parse", "--show-toplevel");
|
|
300
|
+
const root = await realpath(rootText).catch(() => resolve(rootText));
|
|
257
301
|
const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
|
|
258
|
-
|
|
302
|
+
for (const lane of lanes) {
|
|
303
|
+
if (lane.agentId !== config.agentId || lane.branch !== branch || !ACTIVE.has(lane.status)) continue;
|
|
304
|
+
const worktree = await realpath(lane.worktree ?? root).catch(() => resolve(lane.worktree ?? root));
|
|
305
|
+
if (worktree === root) return lane;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
259
308
|
}
|
|
260
309
|
|
|
261
310
|
function deployGrantsFrom(text) {
|
|
@@ -264,7 +313,7 @@ function deployGrantsFrom(text) {
|
|
|
264
313
|
|
|
265
314
|
/** Resolve the facts a hook needs without creating a second lane record. */
|
|
266
315
|
export async function safetyContext(cwd = process.cwd(), { request = devRequest, configReader = readConfig, env = process.env } = {}) {
|
|
267
|
-
const root = await git(cwd, "rev-parse", "--show-toplevel");
|
|
316
|
+
const root = await realpath(await git(cwd, "rev-parse", "--show-toplevel"));
|
|
268
317
|
const branch = await git(cwd, "branch", "--show-current");
|
|
269
318
|
const head = await git(cwd, "rev-parse", "HEAD");
|
|
270
319
|
const base = await git(cwd, "rev-parse", "origin/main");
|
|
@@ -277,6 +326,15 @@ export async function safetyContext(cwd = process.cwd(), { request = devRequest,
|
|
|
277
326
|
}
|
|
278
327
|
let lane = null;
|
|
279
328
|
try { lane = await currentLane(cwd, request, config); } catch {}
|
|
329
|
+
let reviewFlowKnown = false;
|
|
330
|
+
if (lane) {
|
|
331
|
+
if (Array.isArray(lane.reviews)) reviewFlowKnown = true;
|
|
332
|
+
else try {
|
|
333
|
+
const detailed = (await request("GET", `/api/v1/lanes/${encode(lane.key)}`)).data;
|
|
334
|
+
reviewFlowKnown = Array.isArray(detailed?.reviews);
|
|
335
|
+
if (detailed && !Array.isArray(detailed)) lane = { ...lane, ...detailed };
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
280
338
|
const receiptPath = await gitPrivatePath(cwd, REVIEW_RECEIPT);
|
|
281
339
|
const statePath = await gitPrivatePath(cwd, SAFETY_STATE);
|
|
282
340
|
const receiptText = await optional(receiptPath);
|
|
@@ -287,7 +345,7 @@ export async function safetyContext(cwd = process.cwd(), { request = devRequest,
|
|
|
287
345
|
const ownership = laneOwnership(lane, root);
|
|
288
346
|
const laneProject = laneStack(lane?.key);
|
|
289
347
|
return {
|
|
290
|
-
cwd: payloadCwd(cwd), root, branch, head, base, role, lane,
|
|
348
|
+
cwd: await realpath(cwd).catch(() => payloadCwd(cwd)), root, branch, head, base, role, lane, provenLane: Boolean(lane), reviewFlowKnown,
|
|
291
349
|
ownedPaths: ownership.paths,
|
|
292
350
|
ownershipKnown: role === "coordinator" || ownership.known,
|
|
293
351
|
generatedPaths: [...await harnessGenerated(root), ...(state.generatedPaths ?? [])],
|
|
@@ -306,30 +364,37 @@ function blocked(violation) {
|
|
|
306
364
|
throw new CliError(violation.code, violation.message, 2);
|
|
307
365
|
}
|
|
308
366
|
|
|
309
|
-
/**
|
|
367
|
+
/** Runtime-neutral PreToolUse entrypoint. Silent return means allowed. */
|
|
310
368
|
export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
|
|
311
369
|
const tool = payload.tool_name ?? "";
|
|
312
|
-
if (tool
|
|
370
|
+
if (!SHELL_TOOLS.has(tool) && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
|
|
313
371
|
const cwd = payload.cwd ?? process.cwd();
|
|
314
372
|
const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
|
|
315
|
-
context.cwd = resolve(cwd);
|
|
316
|
-
if (tool
|
|
373
|
+
context.cwd = await realpath(cwd).catch(() => resolve(cwd));
|
|
374
|
+
if (SHELL_TOOLS.has(tool)) {
|
|
317
375
|
const command = String(payload.tool_input?.command ?? "");
|
|
318
376
|
const violation = commandViolations(command, context)[0];
|
|
319
|
-
if (violation) blocked(violation);
|
|
377
|
+
if (violation && provenLane(context)) blocked(violation);
|
|
320
378
|
if (usesNoVerify(command) && context.role === "coordinator" && context.noVerifyReason)
|
|
321
379
|
await recordNoVerifyReason(cwd, context.noVerifyReason);
|
|
322
380
|
return { allowed: true, nudges: commandNudges(command, context) };
|
|
323
381
|
}
|
|
324
382
|
const paths = editPaths(payload);
|
|
325
|
-
if (!paths.length)
|
|
383
|
+
if (!paths.length) {
|
|
384
|
+
const violation = { code: "SAFETY_EDIT_PATH_MISSING", message: `Edit blocked: ${tool} supplied no file path.` };
|
|
385
|
+
if (provenLane(context)) blocked(violation);
|
|
386
|
+
return { allowed: true, nudges: [nudgeViolation(violation)] };
|
|
387
|
+
}
|
|
326
388
|
const violation = (await editViolations(paths, context))[0];
|
|
327
|
-
if (violation) blocked(violation);
|
|
389
|
+
if (violation && provenLane(context)) blocked(violation);
|
|
328
390
|
return {
|
|
329
391
|
allowed: true,
|
|
330
|
-
nudges:
|
|
331
|
-
|
|
332
|
-
|
|
392
|
+
nudges: [
|
|
393
|
+
...(!provenLane(context) && violation ? [nudgeViolation(violation)] : []),
|
|
394
|
+
...(context.role !== "coordinator" && !context.ownershipKnown
|
|
395
|
+
? ["nodii-dev safety: can't verify owned paths: the lane has no Dev ownedPaths field yet (follow-up AGENT-HARNESS-10)."]
|
|
396
|
+
: []),
|
|
397
|
+
],
|
|
333
398
|
};
|
|
334
399
|
}
|
|
335
400
|
|
|
@@ -351,7 +416,7 @@ function successful(payload) {
|
|
|
351
416
|
|
|
352
417
|
/** Record only successful resource creation; says nothing when satisfied. */
|
|
353
418
|
export async function safetyPostHook(payload) {
|
|
354
|
-
if (payload.tool_name
|
|
419
|
+
if (!SHELL_TOOLS.has(payload.tool_name) || !successful(payload)) return { recorded: false };
|
|
355
420
|
const command = String(payload.tool_input?.command ?? "");
|
|
356
421
|
const tokens = words(command);
|
|
357
422
|
const cwd = payload.cwd ?? process.cwd();
|
|
@@ -370,12 +435,19 @@ export async function safetyPostHook(payload) {
|
|
|
370
435
|
return { recorded: false };
|
|
371
436
|
}
|
|
372
437
|
|
|
373
|
-
/**
|
|
438
|
+
/** The exact commit and origin/main base a review and its local receipt cover. */
|
|
439
|
+
export async function reviewCommitContext(cwd = process.cwd()) {
|
|
440
|
+
return {
|
|
441
|
+
commit: await git(cwd, "rev-parse", "HEAD"),
|
|
442
|
+
base: await git(cwd, "rev-parse", "origin/main"),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Pin the exact commit and origin/main base Dev accepted for review. */
|
|
374
447
|
export async function recordReviewReceipt(cwd = process.cwd()) {
|
|
375
448
|
const path = await gitPrivatePath(cwd, REVIEW_RECEIPT);
|
|
376
449
|
const receipt = {
|
|
377
|
-
|
|
378
|
-
base: await git(cwd, "rev-parse", "origin/main"),
|
|
450
|
+
...(await reviewCommitContext(cwd)),
|
|
379
451
|
recordedAt: new Date().toISOString(),
|
|
380
452
|
};
|
|
381
453
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -395,3 +467,16 @@ export const safetySettingsSnippet = () => ({
|
|
|
395
467
|
PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
|
|
396
468
|
},
|
|
397
469
|
});
|
|
470
|
+
|
|
471
|
+
export const codexSafetyHooksSnippet = () => ({
|
|
472
|
+
hooks: {
|
|
473
|
+
PreToolUse: [{ matcher: "^(Bash|apply_patch)$", hooks: [{ type: "command", command: "nodii-dev hook safety-pre" }] }],
|
|
474
|
+
PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
export const regroundingHooksSnippet = () => ({
|
|
479
|
+
hooks: {
|
|
480
|
+
SessionStart: [{ matcher: "^compact$", hooks: [{ type: "command", command: "nodii-dev hook session-start" }] }],
|
|
481
|
+
},
|
|
482
|
+
});
|
package/dist/setup.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { CliError, checkLogin } from "./core.js";
|
|
4
|
-
import {
|
|
4
|
+
import { codexHooksFromClaude } from "./harness.js";
|
|
5
|
+
import { regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
|
|
5
6
|
|
|
6
7
|
export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
|
|
7
8
|
const START = "<!-- nodii-dev build workflow:start -->";
|
|
@@ -47,7 +48,10 @@ ${END}`;
|
|
|
47
48
|
export const workflowHooks = () => {
|
|
48
49
|
const safety = safetySettingsSnippet().hooks;
|
|
49
50
|
return {
|
|
50
|
-
SessionStart: [
|
|
51
|
+
SessionStart: [
|
|
52
|
+
{ hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] },
|
|
53
|
+
...regroundingHooksSnippet().hooks.SessionStart,
|
|
54
|
+
],
|
|
51
55
|
Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook lane-heartbeat" }] }],
|
|
52
56
|
PreToolUse: safety.PreToolUse,
|
|
53
57
|
PostToolUse: [
|
|
@@ -70,6 +74,12 @@ export function mergeSettings(current = {}) {
|
|
|
70
74
|
return { ...current, hooks };
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
export function mergeCodexHooks(current = {}) {
|
|
78
|
+
const hooks = { ...(current.hooks ?? {}) };
|
|
79
|
+
for (const [event, entries] of Object.entries(codexHooksFromClaude(workflowHooks()))) hooks[event] = mergeHookList(hooks[event], entries);
|
|
80
|
+
return { ...current, hooks };
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
export function updateMarkedDocument(text = "") {
|
|
74
84
|
const block = new RegExp(`${START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
75
85
|
if (block.test(text)) return `${text.replace(block, managedBlock).trimEnd()}\n`;
|
|
@@ -86,12 +96,18 @@ export async function setupPlan(root = process.cwd()) {
|
|
|
86
96
|
let parsed = {};
|
|
87
97
|
try { parsed = existingSettings ? JSON.parse(existingSettings) : {}; }
|
|
88
98
|
catch { throw new CliError("INVALID_SETTINGS", `${settingsPath} is not valid JSON; fix it before setup.`, 2); }
|
|
99
|
+
const codexHooksPath = join(root, ".codex/hooks.json");
|
|
100
|
+
const existingCodexHooks = await optional(codexHooksPath);
|
|
101
|
+
let parsedCodexHooks = {};
|
|
102
|
+
try { parsedCodexHooks = existingCodexHooks ? JSON.parse(existingCodexHooks) : {}; }
|
|
103
|
+
catch { throw new CliError("INVALID_SETTINGS", `${codexHooksPath} is not valid JSON; fix it before setup.`, 2); }
|
|
89
104
|
const claude = join(root, "CLAUDE.md");
|
|
90
105
|
const agents = join(root, "AGENTS.md");
|
|
91
106
|
const documentPath = (await optional(claude)) !== null ? claude : (await optional(agents)) !== null ? agents : claude;
|
|
92
107
|
const desired = new Map([
|
|
93
108
|
[join(root, SKILL_PATH), workflowSkill],
|
|
94
109
|
[settingsPath, `${JSON.stringify(mergeSettings(parsed), null, 2)}\n`],
|
|
110
|
+
[codexHooksPath, `${JSON.stringify(mergeCodexHooks(parsedCodexHooks), null, 2)}\n`],
|
|
95
111
|
[documentPath, updateMarkedDocument((await optional(documentPath)) ?? "")],
|
|
96
112
|
]);
|
|
97
113
|
const changes = [];
|