@nodii/dev 0.6.23 → 0.6.25

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/harness.js CHANGED
@@ -9,6 +9,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
9
  import { dirname, join, normalize, sep } from "node:path";
10
10
  import { promisify } from "node:util";
11
11
  import { CliError, devRequest, query } from "./core.js";
12
+ import { safetySettingsSnippet } from "./safety.js";
12
13
 
13
14
  /** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
14
15
  export const STATE_PATH = ".nodii/harness-state.json";
@@ -141,6 +142,30 @@ export function checkHarness(harness) {
141
142
  });
142
143
  }
143
144
 
145
+ // Safety enforcement is executable CLI behaviour, not methodology prose. Add
146
+ // its nodii-dev-only hook entries to the settings fragment Dev generated so a
147
+ // normal harness install/update always installs the matching CLI hooks.
148
+ export function withSafetyHooks(harness) {
149
+ checkHarness(harness);
150
+ const copy = { ...harness, files: harness.files.map((file) => ({ ...file })) };
151
+ let settings = copy.files.find((file) => file.path === ".claude/settings.json");
152
+ if (!settings) {
153
+ settings = { path: ".claude/settings.json", mode: "merge-json", kind: "settings", content: "{}" };
154
+ copy.files.push(settings);
155
+ }
156
+ if (settings.mode !== "merge-json")
157
+ throw new CliError("HARNESS_INVALID", "Dev must generate .claude/settings.json as merge-json so nodii-dev can install safety hooks.", 1);
158
+ const parsed = JSON.parse(settings.content);
159
+ const hooks = { ...(parsed.hooks ?? {}) };
160
+ for (const [event, entries] of Object.entries(safetySettingsSnippet().hooks)) {
161
+ hooks[event] = [...(hooks[event] ?? [])];
162
+ for (const entry of entries) if (!hooks[event].some((existing) => stable(existing) === stable(entry))) hooks[event].push(entry);
163
+ }
164
+ settings.content = JSON.stringify({ ...parsed, hooks });
165
+ settings.sha256 = sha(settings.content);
166
+ return copy;
167
+ }
168
+
144
169
  /**
145
170
  * Compare Dev's harness with the repository. `adopt` names existing files the
146
171
  * operator lets nodii-dev take over (on install, or when an update adds a file). Returns the planned
@@ -358,12 +383,16 @@ export async function harnessCommand(action, flags, { request = devRequest, cont
358
383
  const serviceId = flags.service && flags.service !== true ? String(flags.service) : (saved.serviceId ?? undefined);
359
384
  const includeDrafts = flags["include-drafts"] !== undefined ? flags["include-drafts"] !== "false" : Boolean(saved.includeDrafts);
360
385
  const params = { repo, serviceId, includeDrafts: includeDrafts ? "true" : undefined };
386
+ const fetchHarness = async () => {
387
+ const response = await request("GET", `/api/v1/harness${query(params)}`);
388
+ return { ...response, data: withSafetyHooks(response.data) };
389
+ };
361
390
 
362
391
  if (action === "install" && flags.check) throw new CliError("USAGE", "--check goes with `harness update`; to preview an install use --dry-run.", 2);
363
392
  if (action === "check" && (flags["dry-run"] || flags.adopt)) throw new CliError("USAGE", "harness check writes nothing; --dry-run and --adopt go with install and update.", 2);
364
- if (action === "check" || (action === "update" && flags.check)) return checkCommand(ctx.root, repo, state, () => request("GET", `/api/v1/harness${query(params)}`));
393
+ if (action === "check" || (action === "update" && flags.check)) return checkCommand(ctx.root, repo, state, fetchHarness);
365
394
 
366
- const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
395
+ const harness = (await fetchHarness()).data;
367
396
  const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts } });
368
397
  const summary = {
369
398
  repo: harness.repo, serviceId: harness.serviceId ?? null,
package/dist/hooks.js CHANGED
@@ -2,6 +2,7 @@ import { CliError, devRequest } from "./core.js";
2
2
  import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
+ import { safetySettingsSnippet } from "./safety.js";
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
 
@@ -177,8 +178,9 @@ export function postHookFailure(error) {
177
178
  return { exitCode: 0, message: `nodii-dev post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}` };
178
179
  }
179
180
 
180
- export const settingsSnippet = () => ({
181
- hooks: {
181
+ export const settingsSnippet = () => {
182
+ const safety = safetySettingsSnippet().hooks;
183
+ return { hooks: {
182
184
  PreToolUse: [
183
185
  {
184
186
  matcher: "^mcp__(figma|claude_ai_Figma|figma-dev-mode)__.*$",
@@ -186,6 +188,7 @@ export const settingsSnippet = () => ({
186
188
  { type: "command", command: "nodii-dev hook claude-figma-pre" },
187
189
  ],
188
190
  },
191
+ ...safety.PreToolUse,
189
192
  ],
190
193
  PostToolUse: [
191
194
  {
@@ -194,9 +197,10 @@ export const settingsSnippet = () => ({
194
197
  { type: "command", command: "nodii-dev hook claude-figma-post" },
195
198
  ],
196
199
  },
200
+ ...safety.PostToolUse,
197
201
  ],
198
- },
199
- });
202
+ } };
203
+ };
200
204
 
201
205
  export async function heartbeatCurrentLane(request = devRequest) {
202
206
  let branch;
package/dist/lanes.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
5
+ import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
4
6
 
5
7
  const execFileAsync = promisify(execFile);
6
8
  const via = { via: "nodii-dev" };
@@ -52,6 +54,19 @@ function evidence(flags) {
52
54
 
53
55
  const list = (value) => String(value).split(",").map((x) => x.trim()).filter(Boolean);
54
56
 
57
+ export async function reviewBody(flags, read = (path) => readFile(path, "utf8")) {
58
+ const file = requireFlag(flags, "file");
59
+ let body;
60
+ try {
61
+ body = JSON.parse(await read(file));
62
+ } catch (error) {
63
+ throw new CliError("REVIEW_FILE_INVALID", `Cannot read review JSON from ${file}: ${error.message}`, 2);
64
+ }
65
+ if (!body || typeof body !== "object" || Array.isArray(body))
66
+ throw new CliError("REVIEW_FILE_INVALID", "The review file must contain one JSON object.", 2);
67
+ return { ...body, ...via };
68
+ }
69
+
55
70
  /**
56
71
  * DEC-0011 (MW1-28.1): the body for `lane non-issue`. A re-route needs at
57
72
  * least one issue key or an explicit `none`, and the branch work is either
@@ -98,7 +113,7 @@ export async function branchCommits(lane) {
98
113
  }
99
114
  }
100
115
 
101
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits } = {}) {
116
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt } = {}) {
102
117
  if (action === "list") {
103
118
  const c = flags.mine ? await config() : null;
104
119
  const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
@@ -168,7 +183,52 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
168
183
  return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/heartbeat`, { ...(flags.note !== undefined ? { note: flags.note } : {}), ...via }), human: `${ref}: heartbeat recorded.` };
169
184
  }
170
185
  if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
171
- if (action === "review") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/review`, via), human: `${ref}: review recorded.` };
186
+ if (action === "review") {
187
+ const body = await reviewBody(flags, read);
188
+ const current = await reviewContext();
189
+ if (body.head !== current.commit || body.base !== current.base)
190
+ throw new CliError(
191
+ "REVIEW_FILE_STALE",
192
+ `The review file covers base ${body.base ?? "(missing)"} and head ${body.head ?? "(missing)"}, but this worktree is base ${current.base} and head ${current.commit}. Review the current diff and write a new receipt.`,
193
+ 2,
194
+ );
195
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/review`, body);
196
+ try { await reviewReceipt(); }
197
+ catch (error) {
198
+ throw new CliError("REVIEW_RECEIPT_FAILED", `${ref}: Dev recorded the review, but the local push receipt could not be written (${error?.message ?? error}). Re-run nodii-dev lane review ${ref} --file REVIEW.json from its worktree.`, 1);
199
+ }
200
+ return {
201
+ payload,
202
+ human: (p) => {
203
+ const findings = p.data.findings ?? [];
204
+ const missed = findings.filter((finding) => finding.missedTrigger === true).length;
205
+ return `${ref}: review recorded for the current commit and origin/main (${p.data.exitReason}; ${findings.length} finding(s), ${missed} missed trigger(s)).`;
206
+ },
207
+ };
208
+ }
209
+ if (action === "reviews") {
210
+ const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
211
+ return {
212
+ payload,
213
+ human: (p) => {
214
+ const reviews = p.data.reviews ?? [];
215
+ return reviews.length
216
+ ? reviews
217
+ .map((run) => {
218
+ const missed = run.findings.filter((finding) => finding.missedTrigger === true).length;
219
+ return [
220
+ `${run.createdAt}\t${run.head}\t${run.exitReason}\t${run.findings.length} finding(s)\t${missed} missed trigger(s)`,
221
+ ...run.findings.map(
222
+ (finding) =>
223
+ ` ${finding.severity}\t${finding.perspective}\t${finding.clauseId ?? "-"}\t${finding.summary}${finding.missedTrigger ? "\tMISSED_TRIGGER" : ""}`,
224
+ ),
225
+ ].join("\n");
226
+ })
227
+ .join("\n")
228
+ : `No reviews recorded for ${ref}.`;
229
+ },
230
+ };
231
+ }
172
232
  if (action === "verify") {
173
233
  const value = requireFlag(flags, "dev-check");
174
234
  return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/verify`, { ...(URL.canParse(value) ? { url: value } : { ref: value }), ...via }), human: `${ref}: verified on dev.` };
package/dist/safety.js ADDED
@@ -0,0 +1,404 @@
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";
4
+ import { promisify } from "node:util";
5
+ import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const ACTIVE = new Set(["queued", "building", "built", "review", "on_main"]);
9
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
10
+ const GENERATED_MARK = /(?:do not edit|auto-?generated|generated (?:file|code)|@generated)/i;
11
+ const REVIEW_RECEIPT = "nodii-review-receipt.json";
12
+ const SAFETY_STATE = "nodii-safety-state.json";
13
+ const SAFETY_AUDIT = "nodii-safety-audit.jsonl";
14
+
15
+ async function optional(path) {
16
+ try { return await readFile(path, "utf8"); }
17
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
18
+ }
19
+
20
+ async function git(cwd, ...args) {
21
+ return (await execFileAsync("git", args, { cwd, encoding: "utf8" })).stdout.trim();
22
+ }
23
+
24
+ async function gitPrivatePath(cwd, name) {
25
+ return git(cwd, "rev-parse", "--path-format=absolute", "--git-path", name);
26
+ }
27
+
28
+ const cleanPath = (path) => String(path).split(sep).join("/").replace(/^\.\//, "");
29
+ const quotePattern = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
30
+ function globPattern(glob) {
31
+ const source = quotePattern(cleanPath(glob)).replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*");
32
+ return new RegExp(`^${source}(?:/.*)?$`);
33
+ }
34
+
35
+ function words(command) {
36
+ const out = [];
37
+ const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|([^\s]+)/g;
38
+ for (const match of command.matchAll(re)) out.push(match[1] ?? match[2] ?? match[3]);
39
+ return out;
40
+ }
41
+
42
+ function commandHas(tokens, ...sequence) {
43
+ return tokens.some((_, at) => sequence.every((part, offset) => tokens[at + offset] === part));
44
+ }
45
+
46
+ function commandAfter(tokens, ...sequence) {
47
+ const at = tokens.findIndex((_, index) => sequence.every((part, offset) => tokens[index + offset] === part));
48
+ return at === -1 ? [] : tokens.slice(at + sequence.length);
49
+ }
50
+
51
+ function composeProject(tokens, cwd) {
52
+ const after = commandAfter(tokens, "docker", "compose");
53
+ for (let i = 0; i < after.length; i += 1) {
54
+ if (["-p", "--project-name"].includes(after[i])) return after[i + 1] ?? null;
55
+ if (after[i].startsWith("--project-name=")) return after[i].slice("--project-name=".length);
56
+ }
57
+ const assigned = tokens.find((x) => x.startsWith("COMPOSE_PROJECT_NAME="));
58
+ return assigned?.slice("COMPOSE_PROJECT_NAME=".length) || basename(cwd).toLowerCase().replace(/[^a-z0-9_-]/g, "");
59
+ }
60
+
61
+ function gitInvocations(tokens) {
62
+ const commands = [];
63
+ for (let at = 0; at < tokens.length; at += 1) {
64
+ if (tokens[at] !== "git" && !tokens[at].endsWith("/git")) continue;
65
+ let cursor = at + 1;
66
+ while (cursor < tokens.length && tokens[cursor].startsWith("-")) {
67
+ const option = tokens[cursor];
68
+ cursor += 1;
69
+ if (["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(option)) cursor += 1;
70
+ }
71
+ if (tokens[cursor]) commands.push({ verb: tokens[cursor], args: tokens.slice(cursor + 1) });
72
+ }
73
+ return commands;
74
+ }
75
+
76
+ function commandRef(after, verb) {
77
+ if (!after.length) return null;
78
+ if (["checkout", "reset"].includes(verb) && after[0] === "--") return null;
79
+ let skip = false;
80
+ for (const item of after) {
81
+ if (skip) { skip = false; continue; }
82
+ if (["-b", "-B", "--orphan", "--pathspec-from-file"].includes(item)) { skip = true; continue; }
83
+ if (item.startsWith("-")) continue;
84
+ return item;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function createsBranch(after) {
90
+ return after.some((item) => ["-b", "-B", "-c", "-C", "--orphan", "--create", "--force-create"].includes(item));
91
+ }
92
+
93
+ const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify"));
94
+
95
+ function deployCommand(tokens, command) {
96
+ if (commandHas(tokens, "tofu", "apply")) return true;
97
+ if (commandHas(tokens, "terraform", "apply")) return true;
98
+ if (commandHas(tokens, "gh", "workflow", "run")) return /deploy|release|promote/i.test(command);
99
+ return /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?[^\s]*deploy[^\s]*\b/i.test(command) || /\b(?:make|just)\s+[^\s]*deploy[^\s]*\b/i.test(command);
100
+ }
101
+
102
+ function volumeNames(tokens) {
103
+ return commandAfter(tokens, "docker", "volume", "rm").filter((x) => !x.startsWith("-"));
104
+ }
105
+
106
+ function generatedByPath(path) {
107
+ const p = cleanPath(path);
108
+ return /(?:^|\/)(?:components\/ui|__generated__|generated)(?:\/|$)/i.test(p) ||
109
+ /(?:^|\/)(?:devops-)?openapi\.json$/i.test(p) ||
110
+ /(?:^|\/)[^/]*(?:\.gen|\.generated)\.[^/]+$/i.test(p);
111
+ }
112
+
113
+ async function generatedFile(path, context) {
114
+ const p = cleanPath(path);
115
+ if (generatedByPath(p) || context.generatedPaths.some((x) => globPattern(x).test(p))) return true;
116
+ const text = await optional(resolve(context.root, p));
117
+ return text !== null && GENERATED_MARK.test(text.slice(0, 4096));
118
+ }
119
+
120
+ function exactDeployGrant(context) {
121
+ return context.deployGrants.some((x) => x === context.head);
122
+ }
123
+
124
+ /** Data-dependent checks nudge, rather than silently passing, when their source is not available yet. */
125
+ export function commandNudges(command, context) {
126
+ const tokens = words(command);
127
+ const nudges = [];
128
+ if (deployCommand(tokens, command) && !context.deployGrantKnown)
129
+ nudges.push("nodii-dev safety: can't verify deploy authorization: no GO grant source is configured (follow-up AGENT-HARNESS-13).");
130
+ return nudges;
131
+ }
132
+
133
+ /** Every costly or irreversible shell mistake AGENT-HARNESS-15 blocks. */
134
+ export function commandViolations(command, context) {
135
+ const tokens = words(command);
136
+ const gitCommands = gitInvocations(tokens);
137
+ const violations = [];
138
+ const lane = context.role !== "coordinator";
139
+ const block = (code, message) => violations.push({ code, message });
140
+
141
+ if (gitCommands.some((x) => x.verb === "stash") && lane)
142
+ block("SAFETY_GIT_STASH", "git stash is shared by every worktree; lanes and subagents use a work-in-progress commit.");
143
+
144
+ for (const verb of ["checkout", "switch"]) {
145
+ const invocation = gitCommands.find((x) => x.verb === verb);
146
+ if (!invocation) continue;
147
+ if (createsBranch(invocation.args)) {
148
+ block("SAFETY_GIT_BRANCH", `git ${verb} would create and leave the lane branch ${context.branch}.`);
149
+ continue;
150
+ }
151
+ const ref = commandRef(invocation.args, verb);
152
+ if (ref && ref !== context.branch)
153
+ block("SAFETY_GIT_BRANCH", `git ${verb} would leave the lane branch ${context.branch}.`);
154
+ }
155
+ if (gitCommands.some((x) => x.verb === "add" && x.args.some((arg) => arg === "-A" || arg === "--all")))
156
+ block("SAFETY_GIT_ADD_ALL", "git add -A/--all can stage work the lane does not own; stage owned paths by name.");
157
+ const reset = gitCommands.find((x) => x.verb === "reset");
158
+ if (reset) {
159
+ const ref = commandRef(reset.args, "reset");
160
+ if (ref && !/^HEAD(?:[~^].*)?$/.test(ref) && ref !== context.branch)
161
+ block("SAFETY_GIT_RESET_BRANCH", `git reset onto ${ref} is not allowed from ${context.branch}.`);
162
+ }
163
+ if (gitCommands.some((x) => x.verb === "worktree" && x.args[0] === "remove" && x.args.some((arg) => arg === "-f" || arg === "--force")))
164
+ block("SAFETY_WORKTREE_FORCE", "git worktree remove --force is forbidden; the coordinator removes a proven-safe worktree without --force.");
165
+
166
+ if (usesNoVerify(command)) {
167
+ if (lane) block("SAFETY_NO_VERIFY", "--no-verify is forbidden in a lane or subagent.");
168
+ else if (!context.noVerifyReason) block("SAFETY_NO_VERIFY_REASON", "A coordinator may use --no-verify only with a recorded reason.");
169
+ }
170
+
171
+ if (gitCommands.some((x) => x.verb === "push")) {
172
+ const receipt = context.reviewReceipt;
173
+ 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
+ else if (receipt.commit !== context.head)
175
+ block("SAFETY_REVIEW_COMMIT", `Push blocked: review covers ${receipt.commit}, not ${context.head}.`);
176
+ else if (receipt.base !== context.base)
177
+ block("SAFETY_REVIEW_BASE", `Push blocked: review used base ${receipt.base}, but origin/main is ${context.base}. Re-review the current diff.`);
178
+ }
179
+
180
+ if (lane && /(?:^|[\s/])(?:nodii-dev|@nodii\/dev(?:@[^\s]+)?)\s+(?:login|logout|agent\s+register)(?:\s|$)/.test(command))
181
+ block("SAFETY_IDENTITY", "A lane never runs nodii-dev login, logout or agent register; ask the coordinator.");
182
+ if (/\bnodii\/prod(?:\/|\b)/i.test(command))
183
+ block("SAFETY_PROD_SECRET", "nodii/prod secrets are outside lane authority.");
184
+ if (/(?:^|[;&|]\s*)(?:(?:export|env)\s+)?NODE_ENV\s*=\s*["']?test\b/i.test(command))
185
+ block("SAFETY_NODE_ENV_TEST", "NODE_ENV=test disables production-shaped behaviour and is forbidden in lane tests.");
186
+ if (/(?:--skipAuth\b|\bskipAuth\s*=\s*(?:true|1)\b|["']skipAuth["']\s*:\s*true\b)/i.test(command))
187
+ block("SAFETY_SKIP_AUTH", "skipAuth is forbidden; real-stack tests keep production authentication on.");
188
+
189
+ if (commandHas(tokens, "docker", "compose") && commandAfter(tokens, "docker", "compose").includes("down") && tokens.some((x) => x === "-v" || x === "--volumes")) {
190
+ const project = composeProject(tokens, context.cwd);
191
+ if (!project || !context.createdStacks.includes(project))
192
+ block("SAFETY_COMPOSE_OWNERSHIP", `docker compose down -v is allowed only for a stack this run created${project ? `; ${project} is not recorded as owned` : ""}.`);
193
+ }
194
+ if (commandHas(tokens, "docker", "volume", "rm")) {
195
+ const foreign = volumeNames(tokens).filter((x) => !context.createdVolumes.includes(x));
196
+ if (!volumeNames(tokens).length || foreign.length)
197
+ block("SAFETY_VOLUME_OWNERSHIP", `Named-volume deletion is allowed only for volumes this run created${foreign.length ? `; not owned: ${foreign.join(", ")}` : ""}.`);
198
+ }
199
+ if (commandHas(tokens, "docker", "volume", "prune"))
200
+ block("SAFETY_VOLUME_OWNERSHIP", "docker volume prune cannot prove ownership of every volume; remove only a named volume this run created.");
201
+ if (deployCommand(tokens, command) && context.deployGrantKnown && !exactDeployGrant(context))
202
+ block("SAFETY_DEPLOY_GRANT", `Deploy/apply blocked: no recorded GO deploy ${context.head} grant.`);
203
+
204
+ return violations;
205
+ }
206
+
207
+ /** Edit-tool ownership and generated-file blocks. */
208
+ export async function editViolations(paths, context) {
209
+ const violations = [];
210
+ for (const path of paths) {
211
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(context.cwd, path);
212
+ const rel = cleanPath(relative(context.root, absolute));
213
+ if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
214
+ violations.push({ code: "SAFETY_EDIT_OUTSIDE_REPO", message: `Edit blocked outside the lane repository: ${path}.` });
215
+ continue;
216
+ }
217
+ if (context.ownedPaths.length && !context.ownedPaths.some((x) => globPattern(x).test(rel))) {
218
+ violations.push({ code: "SAFETY_EDIT_OUTSIDE_SCOPE", message: `Edit blocked outside the lane's owned paths: ${rel}.` });
219
+ continue;
220
+ }
221
+ if (await generatedFile(rel, context))
222
+ violations.push({ code: "SAFETY_EDIT_GENERATED", message: `Edit blocked for generated file ${rel}; change its source and regenerate it.` });
223
+ }
224
+ return violations;
225
+ }
226
+
227
+ function editPaths(payload) {
228
+ const input = payload.tool_input ?? {};
229
+ return [input.file_path, input.path, input.notebook_path, ...(Array.isArray(input.paths) ? input.paths : [])].filter((x) => typeof x === "string" && x);
230
+ }
231
+
232
+ async function harnessGenerated(root) {
233
+ const text = await optional(resolve(root, ".nodii/harness-state.json"));
234
+ if (!text) return [];
235
+ try { return Object.keys(JSON.parse(text).files ?? {}); }
236
+ catch { return [".nodii/harness-state.json"]; }
237
+ }
238
+
239
+ function laneOwnership(lane, root) {
240
+ const direct = lane && Object.hasOwn(lane, "ownedPaths");
241
+ const frozen = lane?.freeze?.boundaries && Object.hasOwn(lane.freeze.boundaries, "ownedPaths");
242
+ const candidates = direct ? lane.ownedPaths : frozen ? lane.freeze.boundaries.ownedPaths : [];
243
+ const paths = [candidates].flat().filter((x) => typeof x === "string" && x).map((x) => {
244
+ if (!isAbsolute(x)) return cleanPath(x);
245
+ const rel = cleanPath(relative(root, x));
246
+ return rel.startsWith("../") ? x : rel;
247
+ });
248
+ return { known: Boolean(direct || frozen), paths };
249
+ }
250
+
251
+ const laneStack = (key) => String(key ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "");
252
+
253
+ async function currentLane(cwd, request, config) {
254
+ if (!config?.agentId) return null;
255
+ const branch = await git(cwd, "branch", "--show-current");
256
+ const root = await git(cwd, "rev-parse", "--show-toplevel");
257
+ const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
258
+ return lanes.find((x) => x.agentId === config.agentId && x.branch === branch && resolve(x.worktree ?? root) === resolve(root) && ACTIVE.has(x.status)) ?? null;
259
+ }
260
+
261
+ function deployGrantsFrom(text) {
262
+ return String(text ?? "").split(/\r?\n/).map((line) => /^GO deploy ([0-9a-f]{7,64})\s*$/.exec(line)?.[1]).filter(Boolean);
263
+ }
264
+
265
+ /** Resolve the facts a hook needs without creating a second lane record. */
266
+ export async function safetyContext(cwd = process.cwd(), { request = devRequest, configReader = readConfig, env = process.env } = {}) {
267
+ const root = await git(cwd, "rev-parse", "--show-toplevel");
268
+ const branch = await git(cwd, "branch", "--show-current");
269
+ const head = await git(cwd, "rev-parse", "HEAD");
270
+ const base = await git(cwd, "rev-parse", "origin/main");
271
+ let config = null;
272
+ try { config = await configReader(false); } catch {}
273
+ const configuredPath = env.NODII_DEV_CONFIG_PATH ?? configPath();
274
+ let role = basename(configuredPath).startsWith("lane-") ? "lane" : "unknown";
275
+ if (role === "unknown" && config?.agentId) {
276
+ try { role = (await request("GET", `/api/v1/agents/${encode(config.agentId)}`)).data?.role ?? role; } catch {}
277
+ }
278
+ let lane = null;
279
+ try { lane = await currentLane(cwd, request, config); } catch {}
280
+ const receiptPath = await gitPrivatePath(cwd, REVIEW_RECEIPT);
281
+ const statePath = await gitPrivatePath(cwd, SAFETY_STATE);
282
+ const receiptText = await optional(receiptPath);
283
+ const stateText = await optional(statePath);
284
+ const reviewReceipt = receiptText ? JSON.parse(receiptText) : null;
285
+ const state = stateText ? JSON.parse(stateText) : {};
286
+ const goText = env.NODII_DEV_GO_FILE ? await optional(env.NODII_DEV_GO_FILE) : null;
287
+ const ownership = laneOwnership(lane, root);
288
+ const laneProject = laneStack(lane?.key);
289
+ return {
290
+ cwd: payloadCwd(cwd), root, branch, head, base, role, lane,
291
+ ownedPaths: ownership.paths,
292
+ ownershipKnown: role === "coordinator" || ownership.known,
293
+ generatedPaths: [...await harnessGenerated(root), ...(state.generatedPaths ?? [])],
294
+ reviewReceipt,
295
+ createdStacks: [...new Set([...(state.createdStacks ?? []), ...(laneProject ? [laneProject] : [])])],
296
+ createdVolumes: state.createdVolumes ?? [],
297
+ deployGrants: deployGrantsFrom(goText),
298
+ deployGrantKnown: goText !== null,
299
+ noVerifyReason: env.NODII_DEV_NO_VERIFY_REASON?.trim() || null,
300
+ };
301
+ }
302
+
303
+ const payloadCwd = (cwd) => resolve(cwd);
304
+
305
+ function blocked(violation) {
306
+ throw new CliError(violation.code, violation.message, 2);
307
+ }
308
+
309
+ /** Claude PreToolUse entrypoint. Silent return means allowed. */
310
+ export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
311
+ const tool = payload.tool_name ?? "";
312
+ if (tool !== "Bash" && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
313
+ const cwd = payload.cwd ?? process.cwd();
314
+ const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
315
+ context.cwd = resolve(cwd);
316
+ if (tool === "Bash") {
317
+ const command = String(payload.tool_input?.command ?? "");
318
+ const violation = commandViolations(command, context)[0];
319
+ if (violation) blocked(violation);
320
+ if (usesNoVerify(command) && context.role === "coordinator" && context.noVerifyReason)
321
+ await recordNoVerifyReason(cwd, context.noVerifyReason);
322
+ return { allowed: true, nudges: commandNudges(command, context) };
323
+ }
324
+ const paths = editPaths(payload);
325
+ if (!paths.length) blocked({ code: "SAFETY_EDIT_PATH_MISSING", message: `Edit blocked: ${tool} supplied no file path.` });
326
+ const violation = (await editViolations(paths, context))[0];
327
+ if (violation) blocked(violation);
328
+ return {
329
+ allowed: true,
330
+ nudges: context.role !== "coordinator" && !context.ownershipKnown
331
+ ? ["nodii-dev safety: can't verify owned paths: the lane has no Dev ownedPaths field yet (follow-up AGENT-HARNESS-10)."]
332
+ : [],
333
+ };
334
+ }
335
+
336
+ async function writeSafetyState(cwd, update) {
337
+ const path = await gitPrivatePath(cwd, SAFETY_STATE);
338
+ const before = await optional(path);
339
+ const state = before ? JSON.parse(before) : {};
340
+ const next = update({ createdStacks: [], createdVolumes: [], ...state });
341
+ await mkdir(dirname(path), { recursive: true });
342
+ await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
343
+ return next;
344
+ }
345
+
346
+ function successful(payload) {
347
+ const response = payload.tool_response ?? {};
348
+ const code = response.exitCode ?? response.exit_code ?? response.status;
349
+ return code === 0 || (code === undefined && response.interrupted === false && response.is_error !== true);
350
+ }
351
+
352
+ /** Record only successful resource creation; says nothing when satisfied. */
353
+ export async function safetyPostHook(payload) {
354
+ if (payload.tool_name !== "Bash" || !successful(payload)) return { recorded: false };
355
+ const command = String(payload.tool_input?.command ?? "");
356
+ const tokens = words(command);
357
+ const cwd = payload.cwd ?? process.cwd();
358
+ if (commandHas(tokens, "docker", "compose") && commandAfter(tokens, "docker", "compose").includes("up")) {
359
+ const project = composeProject(tokens, cwd);
360
+ await writeSafetyState(cwd, (state) => ({ ...state, createdStacks: [...new Set([...state.createdStacks, project])] }));
361
+ return { recorded: true, kind: "stack", name: project };
362
+ }
363
+ if (commandHas(tokens, "docker", "volume", "create")) {
364
+ const name = commandAfter(tokens, "docker", "volume", "create").filter((x) => !x.startsWith("-")).at(-1);
365
+ if (name) {
366
+ await writeSafetyState(cwd, (state) => ({ ...state, createdVolumes: [...new Set([...state.createdVolumes, name])] }));
367
+ return { recorded: true, kind: "volume", name };
368
+ }
369
+ }
370
+ return { recorded: false };
371
+ }
372
+
373
+ /** The exact commit and origin/main base a review and its local receipt cover. */
374
+ export async function reviewCommitContext(cwd = process.cwd()) {
375
+ return {
376
+ commit: await git(cwd, "rev-parse", "HEAD"),
377
+ base: await git(cwd, "rev-parse", "origin/main"),
378
+ };
379
+ }
380
+
381
+ /** Pin the exact commit and origin/main base Dev accepted for review. */
382
+ export async function recordReviewReceipt(cwd = process.cwd()) {
383
+ const path = await gitPrivatePath(cwd, REVIEW_RECEIPT);
384
+ const receipt = {
385
+ ...(await reviewCommitContext(cwd)),
386
+ recordedAt: new Date().toISOString(),
387
+ };
388
+ await mkdir(dirname(path), { recursive: true });
389
+ await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
390
+ return receipt;
391
+ }
392
+
393
+ /** Record the exceptional coordinator reason without placing it in the repository. */
394
+ export async function recordNoVerifyReason(cwd, reason) {
395
+ const path = await gitPrivatePath(cwd, SAFETY_AUDIT);
396
+ await appendFile(path, `${JSON.stringify({ at: new Date().toISOString(), action: "--no-verify", reason })}\n`, { mode: 0o600 });
397
+ }
398
+
399
+ export const safetySettingsSnippet = () => ({
400
+ hooks: {
401
+ PreToolUse: [{ matcher: "^(Bash|Edit|Write|MultiEdit|NotebookEdit)$", hooks: [{ type: "command", command: "nodii-dev hook safety-pre" }] }],
402
+ PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
403
+ },
404
+ });
package/dist/setup.js CHANGED
@@ -1,6 +1,7 @@
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 { safetySettingsSnippet } from "./safety.js";
4
5
 
5
6
  export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
6
7
  const START = "<!-- nodii-dev build workflow:start -->";
@@ -43,11 +44,18 @@ export const managedBlock = `${START}
43
44
  Follow [the nodii build workflow skill](${SKILL_PATH}) for all build work. Drive lane state, evidence, heartbeats, landing, dev verification, and Built acknowledgement through \`nodii-dev\`.
44
45
  ${END}`;
45
46
 
46
- export const workflowHooks = () => ({
47
- SessionStart: [{ hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] }],
48
- Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook lane-heartbeat" }] }],
49
- PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "nodii-dev hook workflow-post" }] }],
50
- });
47
+ export const workflowHooks = () => {
48
+ const safety = safetySettingsSnippet().hooks;
49
+ return {
50
+ SessionStart: [{ hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] }],
51
+ Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook lane-heartbeat" }] }],
52
+ PreToolUse: safety.PreToolUse,
53
+ PostToolUse: [
54
+ ...safety.PostToolUse,
55
+ { matcher: "Bash", hooks: [{ type: "command", command: "nodii-dev hook workflow-post" }] },
56
+ ],
57
+ };
58
+ };
51
59
 
52
60
  const stable = (x) => JSON.stringify(x);
53
61
  function mergeHookList(current = [], additions = []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.23",
3
+ "version": "0.6.25",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {