novahiz 0.3.7 → 0.3.8

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.
@@ -29,4 +29,4 @@ Rules:
29
29
  - Load the Supabase skills for any Supabase work.
30
30
  - Be honest. Avoid false good ideas. Keep a critical stance. Zero simulation: never pretend to have run, tested, or verified something you did not.
31
31
  - Criticize the request when it is inconsistent, ambiguous, risky, or suboptimal, and propose an alternative.
32
- - Gate auto-repair: when a call fails with "Novahiz gate blocked", do not stop and do not ask the user. Execute the AUTO-REPAIR block from the error verbatim: load every skill it names with `skill({ name: "..." })`, then retry the exact same call once and continue the user's task where it left off. If the identical skills are reported missing again, the loads did not register — run `novahiz doctor`, report honestly to the user, and stop. Never bypass the gate with a shell write, an alternate tool, or NOVAHIZ_GATE.
32
+ - Gate reload: when a call fails with "Novahiz gate blocked", do not stop and do not ask the user. Execute the GATE RELOAD block from the error verbatim: load every skill it names with `skill({ name: "..." })`, then retry the exact same call once and continue the user's task where it left off. If the identical skills are reported missing again, the loads did not register — run `novahiz doctor`, report honestly to the user, and stop. Never bypass the gate with a shell write, an alternate tool, or NOVAHIZ_GATE.
@@ -506,7 +506,7 @@ function buildRepairDirective(failure: GateFailure, attempt: number): string {
506
506
  const steps = loads.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
507
507
  return [
508
508
  `${head} Missing skills: ${missing.join(", ")}.`,
509
- "AUTO-REPAIR — execute now, do not ask the user, do not stop:",
509
+ "GATE RELOAD — execute now, do not ask the user, do not stop:",
510
510
  steps,
511
511
  ` ${loads.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
512
512
  "Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
@@ -514,7 +514,7 @@ function buildRepairDirective(failure: GateFailure, attempt: number): string {
514
514
  }
515
515
 
516
516
  return [
517
- `${head} AUTO-REPAIR FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
517
+ `${head} GATE RELOAD FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
518
518
  "The loads did not register — diagnose instead of retrying:",
519
519
  " 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
520
520
  " 2. Realign the index (`novahiz sync`), then load the named skills again.",
@@ -530,8 +530,8 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
530
530
  // P0-B: reason of the last failed classify per session — gate tool calls are
531
531
  // refused while set, instead of running with empty categories (fail-open).
532
532
  const classifyFailedBySession = new Map<string, string>();
533
- // Auto-repair: denial count per `session|tool|missing set`. A first denial
534
- // carries the repair protocol; an identical repeat escalates to diagnosis
533
+ // Gate reload: denial count per `session|tool|missing set`. A first denial
534
+ // carries the reload protocol; an identical repeat escalates to diagnosis
535
535
  // instead of looping. Cleared on a successful call of the same tool.
536
536
  const repairAttemptsBySession = new Map<string, number>();
537
537
  const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
@@ -550,7 +550,7 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
550
550
  enforcementBySession.delete(sessionID);
551
551
  lastSeenBySession.delete(sessionID);
552
552
  classifyFailedBySession.delete(sessionID);
553
- // Auto-repair keys are prefixed with the session ID — drop them too.
553
+ // Gate reload keys are prefixed with the session ID — drop them too.
554
554
  for (const key of repairAttemptsBySession.keys()) {
555
555
  if (key.startsWith(`${sessionID}|`)) repairAttemptsBySession.delete(key);
556
556
  }
@@ -836,9 +836,9 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
836
836
  );
837
837
  }
838
838
  if (result.status === 2) {
839
- // Auto-repair: a structured denial becomes an executable directive
839
+ // Gate reload: a structured denial becomes an executable directive
840
840
  // (load the named skills, retry the same call, resume the task).
841
- // Counting identical denials turns a failed repair into a diagnosis
841
+ // Counting identical denials turns a failed reload into a diagnosis
842
842
  // instead of an infinite retry loop. The denial itself still stands
843
843
  // until the gate CLI sees the skills — nothing is granted here.
844
844
  const failure = parseGateFailure(result.stdout);
@@ -4,7 +4,7 @@
4
4
  * Subcommands: init, log, diff, status, restore, export, commit
5
5
  */
6
6
  import { execFileSync } from "node:child_process";
7
- import { existsSync } from "node:fs";
7
+ import { existsSync, writeSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { NovahizHome } from "../spec.js";
10
10
  import { isGraftAvailable, isGraftInitialized, initGraft, commitGraft, getGraftLog, getGraftDiff, getGraftStatus, restoreGraft, exportGraft, } from "../graft.js";
@@ -21,8 +21,12 @@ Usage:
21
21
  novahiz graft help Show this help
22
22
  `;
23
23
  function fail(msg) {
24
- console.error(`error: ${msg}`);
25
- process.exitCode = 1;
24
+ // WS3: exit immediately — a fail() that only sets exitCode let the switch
25
+ // keep running (usage-fail then commitGraft(""), "graft not found" then a
26
+ // spawn anyway). writeSync(2, ...) so the message survives the exit on a
27
+ // piped stderr (console.error is async on pipes).
28
+ writeSync(2, `error: ${msg}\n`);
29
+ process.exit(1);
26
30
  }
27
31
  export function graftCommand(argv) {
28
32
  const sub = argv[0] ?? "help";
@@ -18,8 +18,8 @@ function emitDeny(harness, event, reason) {
18
18
  }
19
19
  process.stdout.write(`Novahiz advisory: ${reason}\n`);
20
20
  }
21
- // AUTO-REPAIR attempt tracking: how many consecutive denials for this
22
- // session+tool since the last allow. attempt 1 = first block (repair
21
+ // GATE RELOAD attempt tracking: how many consecutive denials for this
22
+ // session+tool since the last allow. attempt 1 = first block (reload
23
23
  // directive), attempt 2+ = loads did not register (escalation). Computed
24
24
  // BEFORE enforceLedgerChecks logs the current row.
25
25
  function repairAttempt(db, session, tool) {
@@ -62,7 +62,14 @@ function recordSkillLoad(spec, db, session, skill) {
62
62
  db.prepare("INSERT OR IGNORE INTO skill_invocations (session_id, skill, invoked_at) VALUES (?, ?, ?)").run(session, skill, new Date().toISOString());
63
63
  }
64
64
  export function commandHook(parsed) {
65
- const harness = (asString(parsed.flags.harness) || "claude");
65
+ const harnessFlag = asString(parsed.flags.harness) || "claude";
66
+ const harness = harnessFlag === "claude" || harnessFlag === "codex" ? harnessFlag : "claude";
67
+ if (harness !== harnessFlag) {
68
+ // WS3: an unvalidated cast let a typo ("claudee") silently downgrade the
69
+ // PreToolUse deny to an advisory line = fail-open. Unknown values now
70
+ // fail toward the strictest format (claude deny) with a warning.
71
+ process.stderr.write(`novahiz: unknown harness "${harnessFlag.slice(0, 32)}" — treating as "claude".\n`);
72
+ }
66
73
  const event = asString(parsed.flags.event) || "PreToolUse";
67
74
  const root = NovahizHome();
68
75
  const raw = readStdin().trim();
@@ -7,7 +7,7 @@ import { rankSkills } from "../relevance.js";
7
7
  import { buildMcpEntries, enabledProviders, installCommands } from "../providers.js";
8
8
  import { bootstrapFor, checkDependencies, missingPrerequisites } from "../deps.js";
9
9
  import { classify } from "../classify.js";
10
- import { runCommand, runScript } from "../exec.js";
10
+ import { runScript } from "../exec.js";
11
11
  import { activeTask, buildWorkPackets, getTask } from "../ledger.js";
12
12
  export function commandCheck() {
13
13
  const root = NovahizHome();
@@ -187,6 +187,13 @@ export function commandStep(parsed) {
187
187
  process.exitCode = 1;
188
188
  return;
189
189
  }
190
+ // WS3: the step id follows the roadmap naming pattern (plan, write,
191
+ // impeccable-critique) — reject anything else before it reaches the database.
192
+ if (done.length > 0 && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(done)) {
193
+ print({ error: `invalid step id format: "${done}" (expected alphanumeric, hyphens, underscores, dots)` });
194
+ process.exitCode = 1;
195
+ return;
196
+ }
190
197
  const db = openDb(dbPathFor(root, spec));
191
198
  if (done.length > 0) {
192
199
  const ts = new Date().toISOString();
@@ -206,13 +213,26 @@ export function commandProviders(parsed) {
206
213
  return;
207
214
  }
208
215
  if (parsed.flags.install) {
216
+ const entries = installCommands(spec);
217
+ if (!flagOn(parsed, "yes")) {
218
+ // Dry run by default: the catalog is not signed, so the plan is shown
219
+ // and nothing executes until --yes is passed explicitly.
220
+ print({
221
+ plan: entries.map((entry) => ({ id: entry.id, kind: entry.kind, command: entry.command.join(" "), source: entry.source })),
222
+ note: "dry run: pass --yes to execute"
223
+ });
224
+ return;
225
+ }
209
226
  const results = [];
210
- for (const entry of installCommands(spec)) {
211
- const [command, ...args] = entry.command;
212
- const result = runCommand(command, args);
227
+ for (const entry of entries) {
228
+ // runScript applies the binary allowlist and the code-runner guard:
229
+ // catalog argv may only start a known packager/interpreter.
230
+ const result = runScript(entry.command);
213
231
  process.stdout.write(`${result.ok ? "ok " : "fail"} ${entry.id} (${entry.kind}) ${entry.command.join(" ")}\n`);
214
232
  if (!result.ok && result.stderr)
215
233
  process.stderr.write(result.stderr);
234
+ if (result.error)
235
+ process.stderr.write(`${result.error}\n`);
216
236
  results.push({ id: entry.id, kind: entry.kind, source: entry.source, command: entry.command.join(" "), ok: result.ok, error: result.error ?? null });
217
237
  }
218
238
  print(results);
@@ -247,9 +267,26 @@ export function commandDeps(parsed) {
247
267
  print({ node: process.version, platform: process.platform, dependencies: status });
248
268
  return;
249
269
  }
250
- const run = (command, label) => {
251
- const [bin, ...args] = command;
252
- const result = runCommand(bin, args);
270
+ if (!flagOn(parsed, "yes")) {
271
+ // Dry run by default: show what would run (bootstrap + install) without
272
+ // touching the machine; --yes opts into execution explicitly.
273
+ const plan = [];
274
+ for (const entry of missingPrerequisites(spec)) {
275
+ const bootstrap = bootstrapFor(entry.provider);
276
+ plan.push(bootstrap && bootstrap.length > 0
277
+ ? { provider: entry.provider.id, step: "bootstrap", command: bootstrap.join(" ") }
278
+ : { provider: entry.provider.id, step: "bootstrap", note: `missing ${entry.missing.join(", ")}` });
279
+ }
280
+ for (const entry of installCommands(spec)) {
281
+ plan.push({ provider: entry.id, step: "install", command: entry.command.join(" ") });
282
+ }
283
+ print({ plan, note: "dry run: pass --yes to execute" });
284
+ return;
285
+ }
286
+ const run = (argv, label) => {
287
+ // runScript applies the binary allowlist and the code-runner guard:
288
+ // catalog argv may only start a known packager/interpreter.
289
+ const result = runScript(argv);
253
290
  process.stdout.write(`${result.ok ? "ok " : "fail"} ${label}\n`);
254
291
  if (!result.ok && result.stderr)
255
292
  process.stderr.write(result.stderr);
@@ -171,7 +171,18 @@ function taskInsert(parsed, db, session, spec) {
171
171
  maxIterations: numberFlag(parsed, "max-iterations", { min: 1, integer: true }) || undefined
172
172
  });
173
173
  const positionRaw = asString(parsed.flags.position);
174
- const position = positionRaw === "" ? "end" : /^\d+$/.test(positionRaw) ? Number(positionRaw) : positionRaw;
174
+ let position = "end";
175
+ if (positionRaw !== "") {
176
+ if (/^\d+$/.test(positionRaw))
177
+ position = Number(positionRaw);
178
+ else if (positionRaw === "start" || positionRaw === "end")
179
+ position = positionRaw;
180
+ else {
181
+ print({ error: `invalid position: ${positionRaw} (expected a number, "start" or "end")` });
182
+ process.exitCode = 1;
183
+ return;
184
+ }
185
+ }
175
186
  print({ todo: insertTodo(db, taskId, item, position), task: getTask(db, taskId) });
176
187
  return;
177
188
  }
@@ -348,7 +359,9 @@ function taskNew(parsed, db, session, spec) {
348
359
  return;
349
360
  }
350
361
  const id = asString(parsed.flags.id) || undefined;
351
- const task = createTask(db, { title, id, sessionId: session || undefined });
362
+ // F: the shell's cwd is the project this plan belongs to — record it so the
363
+ // ledger only counts and blocks edits inside that project.
364
+ const task = createTask(db, { title, id, sessionId: session || undefined, projectRoot: process.cwd() });
352
365
  print({ task, todos: [] });
353
366
  return;
354
367
  }
package/dist/db.js CHANGED
@@ -159,12 +159,16 @@ function ensureColumn(db, table, column, definition) {
159
159
  return;
160
160
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
161
161
  }
162
- export const SCHEMA_VERSION = 1;
162
+ export const SCHEMA_VERSION = 2;
163
163
  function migrate(db) {
164
164
  ensureColumn(db, "tasks", "revision", "INTEGER NOT NULL DEFAULT 0");
165
165
  ensureColumn(db, "tasks", "reviewed_at", "TEXT");
166
166
  ensureColumn(db, "tasks", "edits_since_review", "INTEGER NOT NULL DEFAULT 0");
167
167
  ensureColumn(db, "tasks", "todos_since_review", "INTEGER NOT NULL DEFAULT 0");
168
+ // F: the project a task belongs to — ledger edits are filtered against it so
169
+ // one project's plan never counts or blocks another project's files. NULL on
170
+ // legacy tasks keeps the previous unscoped owner behaviour.
171
+ ensureColumn(db, "tasks", "project_root", "TEXT");
168
172
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
169
173
  }
170
174
  export function setMeta(db, key, value) {
@@ -1,4 +1,4 @@
1
- // Auto-repair for gate FAIL results.
1
+ // Gate reload for gate FAIL results.
2
2
  //
3
3
  // When the gate denies a tool call because required skills are not loaded,
4
4
  // the denial must be actionable: parse the structured FAIL payload and build
@@ -43,7 +43,7 @@ export function parseGateFailure(stdout) {
43
43
  /**
44
44
  * Build the actionable denial for a gate failure.
45
45
  *
46
- * attempt 1 — missing skills: the repair protocol (load, retry, resume).
46
+ * attempt 1 — missing skills: the gate reload protocol (load, retry, resume).
47
47
  * attempt 2+ — the same skills are still missing: the loads did not take
48
48
  * effect, so the directive escalates to diagnosis and stops
49
49
  * the loop instead of repeating itself.
@@ -63,14 +63,14 @@ export function buildRepairDirective(failure, attempt) {
63
63
  const steps = loads.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
64
64
  return [
65
65
  `${head} Missing skills: ${missing.join(", ")}.`,
66
- "AUTO-REPAIR — execute now, do not ask the user, do not stop:",
66
+ "GATE RELOAD — execute now, do not ask the user, do not stop:",
67
67
  steps,
68
68
  ` ${loads.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
69
69
  "Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
70
70
  ].join("\n");
71
71
  }
72
72
  return [
73
- `${head} AUTO-REPAIR FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
73
+ `${head} GATE RELOAD FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
74
74
  "The loads did not register — diagnose instead of retrying:",
75
75
  " 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
76
76
  " 2. Realign the index (`novahiz sync`), then load the named skills again.",
package/dist/gate.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
1
2
  import { hasPlaceholder, hasProse, hasStyle, isTrivial } from "./content.js";
2
3
  import { determineTier } from "./complexity.js";
3
4
  // Shared ledger enforcement (audit P1-D/M1): the CLI command and the MCP
4
5
  // novahiz_gate tool both run these checks so their verdicts cannot diverge.
5
- import { activeTask, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "./ledger.js";
6
+ import { activeTask, ownedByOpenTodo, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "./ledger.js";
6
7
  import { autoCommit } from "./graft.js";
7
8
  const CLASS_BY_EXTENSION = {
8
9
  css: "design",
@@ -455,7 +456,22 @@ export function enforceLedgerChecks(db, input) {
455
456
  if (ledgerConfig?.enabled !== false) {
456
457
  const task = activeTask(db, session || undefined);
457
458
  if (task) {
458
- if (["edit", "write", "patch", "apply_patch"].includes(tool))
459
+ // F: a task bound to a project only ever owns paths inside that project —
460
+ // another project's edits never advance or block it. Legacy tasks without
461
+ // project_root keep the owner-only behaviour. Relative paths resolve
462
+ // against the caller's cwd; absolute paths (plugin flow) stand as-is.
463
+ const inProject = (filePath) => {
464
+ if (!task.project_root)
465
+ return true;
466
+ const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(process.cwd(), filePath);
467
+ const rel = relative(task.project_root, abs);
468
+ return rel.length === 0 || (!rel.startsWith("..") && !isAbsolute(rel));
469
+ };
470
+ // Cadence counts only edits an open todo actually owns (owner-scoped
471
+ // review): a session or project touching unrelated paths must never
472
+ // advance this plan toward its review block.
473
+ const ownedTarget = results.some((entry) => entry.path.length > 0 && inProject(entry.path) && ownedByOpenTodo(db, task.id, entry.path));
474
+ if (["edit", "write", "patch", "apply_patch"].includes(tool) && ownedTarget)
459
475
  recordEdit(db, task.id);
460
476
  // Targeted review: block only paths owned by an open todo with an owner
461
477
  // pattern. A due review no longer freezes every target.
@@ -464,6 +480,9 @@ export function enforceLedgerChecks(db, input) {
464
480
  // pathless command must not be blocked by a file-ownership check.
465
481
  if (entry.path.length === 0)
466
482
  continue;
483
+ // F: outside the task's project nothing in this ledger applies to it.
484
+ if (!inProject(entry.path))
485
+ continue;
467
486
  const reason = reviewBlockReason(db, task.id, entry.path, ledgerConfig.review);
468
487
  if (reason && !entry.ignored) {
469
488
  entry.allow = false;
package/dist/ledger.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { globToRegExp, isSafeRegexPattern } from "./gate.js";
2
3
  import { autoCommit } from "./graft.js";
3
4
  export const DEFAULT_MAX_ITERATIONS = 12;
@@ -45,7 +46,11 @@ export function createTask(db, options) {
45
46
  if (options.sessionId) {
46
47
  db.prepare("INSERT OR IGNORE INTO sessions (id, categories, required_skills, updated_at) VALUES (?, '[]', '[]', ?)").run(options.sessionId, ts);
47
48
  }
48
- db.prepare("INSERT INTO tasks (id, title, status, session_id, created_at, updated_at) VALUES (?, ?, 'active', ?, ?, ?)").run(taskId, options.title, options.sessionId ?? null, ts, ts);
49
+ // F: remember which project the plan belongs to so the ledger can filter its
50
+ // edits to that project. Callers that cannot know it (legacy MCP flows) pass
51
+ // nothing and get NULL = unscoped, the behaviour before this column existed.
52
+ const projectRoot = options.projectRoot && options.projectRoot.trim().length > 0 ? resolve(options.projectRoot) : null;
53
+ db.prepare("INSERT INTO tasks (id, title, status, session_id, created_at, updated_at, project_root) VALUES (?, ?, 'active', ?, ?, ?, ?)").run(taskId, options.title, options.sessionId ?? null, ts, ts, projectRoot);
49
54
  autoCommit("task-created", options.title);
50
55
  return getTask(db, taskId);
51
56
  }
@@ -62,9 +67,13 @@ function requireTask(db, id) {
62
67
  return task;
63
68
  }
64
69
  export function activeTask(db, sessionId) {
70
+ // G2: two worlds, never mixed. A caller with a session only ever sees its own
71
+ // tasks; a sessionless caller only sees unbound tasks (session_id NULL) — a
72
+ // session-bound plan can no longer be grabbed (counted, blocked) by a foreign
73
+ // sessionless gate run, and the newest active task is no longer a free-for-all.
65
74
  const row = sessionId
66
75
  ? db.prepare("SELECT * FROM tasks WHERE status = 'active' AND session_id = ? ORDER BY created_at DESC LIMIT 1").get(sessionId)
67
- : db.prepare("SELECT * FROM tasks WHERE status = 'active' ORDER BY created_at DESC LIMIT 1").get();
76
+ : db.prepare("SELECT * FROM tasks WHERE status = 'active' AND session_id IS NULL ORDER BY created_at DESC LIMIT 1").get();
68
77
  return row ?? null;
69
78
  }
70
79
  export function getTodo(db, id) {
@@ -315,6 +324,19 @@ export function reviewDue(db, taskId, policy = { edits: DEFAULT_REVIEW_EDITS, to
315
324
  : "";
316
325
  return { due, edits: task.edits_since_review, todos: task.todos_since_review, policy, reason };
317
326
  }
327
+ /**
328
+ * Does an open todo of this task own filePath through a non-empty owner
329
+ * pattern? Blank owners own everything (ownedBy semantics) and are excluded
330
+ * on purpose: review mechanics — cadence and block — stay tied to explicit
331
+ * ownership, so edits from another session or project never advance this plan.
332
+ */
333
+ export function ownedByOpenTodo(db, taskId, filePath) {
334
+ if (filePath.length === 0)
335
+ return false;
336
+ return listTodos(db, taskId).some((todo) => (todo.status === "pending" || todo.status === "in_progress") &&
337
+ (todo.owner ?? "").trim().length > 0 &&
338
+ ownedBy(todo, filePath));
339
+ }
318
340
  /**
319
341
  * Which gate targets a due plan review should block.
320
342
  * Only paths owned by an open todo with a non-empty owner pattern are blocked.
@@ -325,10 +347,7 @@ export function reviewBlockReason(db, taskId, filePath, policy) {
325
347
  const due = reviewDue(db, taskId, policy);
326
348
  if (!due.due)
327
349
  return null;
328
- const openOwned = listTodos(db, taskId).filter((todo) => (todo.status === "pending" || todo.status === "in_progress") && (todo.owner ?? "").trim().length > 0);
329
- if (openOwned.length === 0)
330
- return null;
331
- return openOwned.some((todo) => ownedBy(todo, filePath)) ? due.reason : null;
350
+ return ownedByOpenTodo(db, taskId, filePath) ? due.reason : null;
332
351
  }
333
352
  export function revisionSignals(db, taskId) {
334
353
  const todos = listTodos(db, taskId);
package/docs/PROVIDERS.md CHANGED
@@ -98,12 +98,14 @@ Control it in `novahiz.config.json`:
98
98
  Run the install commands on demand:
99
99
 
100
100
  ```
101
- node src/cli.ts providers --install
101
+ node src/cli.ts providers --install --yes
102
102
  node install/install.mjs --install-providers
103
103
  ```
104
104
 
105
105
  Installation is opt-in on purpose. The commands download third-party packages, including a large Rust binary for `narsil`, so `autoInstall` defaults to `false`. Enabling it means you trust each upstream listed in `source`.
106
106
 
107
+ `providers --install` and `deps --install` print the plan and execute nothing until `--yes` is passed. Whatever runs then goes through a binary allowlist (`node`, `npm`, `npx`, `uv`, `uvx`, `python`, `py`) — the same one bootstrap uses — so a tampered `providers.json` cannot execute an arbitrary program.
108
+
107
109
  ## Dependencies
108
110
 
109
111
  Each provider declares its prerequisites in `requires` (the executable it needs) and, when it can be bootstrapped, a per-platform `bootstrap` command. No provider currently declares a `bootstrap`; `Novahiz deps --install` supports the field for future entries.
@@ -111,7 +113,7 @@ Each provider declares its prerequisites in `requires` (the executable it needs)
111
113
  - `npx` based providers need `npx`, which ships with Node.
112
114
  - `dart` needs the Dart SDK on `PATH` (`dart --version`). Flutter installs ship it.
113
115
 
114
- `Novahiz deps` checks every prerequisite and reports what is missing. `Novahiz deps --install` first bootstraps a missing prerequisite through its official installer, then runs each provider's install command. The installer runs the check on every install and, when `providers.autoInstall` is true or `--install-providers` is passed, runs the installs too.
116
+ `Novahiz deps` checks every prerequisite and reports what is missing. `Novahiz deps --install` prints the plan (bootstrap and install steps) and runs nothing; with `--yes` it first bootstraps a missing prerequisite through its official installer, then runs each provider's install command. The installer runs the check on every install and, when `providers.autoInstall` is true or `--install-providers` is passed, runs the installs with `--yes` too.
115
117
 
116
118
  ## Troubleshooting
117
119
 
@@ -137,7 +139,7 @@ Confirm the tool count with `narsil-mcp tools list` afterwards, then rerun `Nova
137
139
 
138
140
  - `Novahiz providers` lists providers, optionally by `--category` or a query.
139
141
  - `Novahiz providers --mcp-json` prints the MCP entry map.
140
- - `Novahiz providers --install` runs the official install commands.
141
- - `Novahiz deps [--install]` checks prerequisites and bootstraps or installs missing ones.
142
+ - `Novahiz providers --install [--yes]` plans the official install commands; `--yes` executes them.
143
+ - `Novahiz deps [--install] [--yes]` checks prerequisites and plans or runs the bootstrap/install commands.
142
144
  - MCP `novahiz_providers` and `novahiz_deps` expose the list and the dependency status over stdio.
143
145
  - `Novahiz report` lists the provider ids.
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
- import { nodeVersionOk, opencodeConfigDir, NovahizHome } from "./lib.mjs";
8
+ import { nodeVersionOk, opencodeConfigDir, NovahizHome, unsafeHostToken } from "./lib.mjs";
9
9
 
10
10
  const REPO_URL = "https://github.com/novahiz/novahiz.git";
11
11
  // Was hardcoded to ~/.config/novahiz, ignoring NOVAHIZ_HOME/NOVAHIZ_HOME.
@@ -21,19 +21,34 @@ function error(msg) {
21
21
  }
22
22
 
23
23
  // npm/npx are .cmd shims on Windows: spawning them without a shell throws
24
- // ENOENT (post-CVE-2024-* Node refuses to run .cmd via CreateProcess). Node
25
- // deprecates args arrays with shell:true (DEP0190), which wants a single
26
- // string — tokens are static (catalog package names), so joining is safe.
24
+ // ENOENT (post-CVE-2024-* Node refuses to run .cmd via CreateProcess). The
25
+ // line handed to cmd.exe only contains tokens accepted by unsafeHostToken
26
+ // (lib.mjs — the install-side mirror of src/exec.ts SAFE_TOKEN, widened by
27
+ // "*" for the literal flutter-skills glob); a refused token fails the run
28
+ // loudly instead of being reinterpreted by cmd.exe. Same explicit cmd.exe
29
+ // spelling as src/exec.ts resolveSpawn — argv form, no deprecated shell flag
30
+ // (DEP0190).
27
31
  const HOST_CMDS = new Set(["npm", "npx"]);
28
32
  function hostInvocation(cmd, args) {
29
33
  if (process.platform === "win32" && HOST_CMDS.has(cmd)) {
30
- return { command: [cmd, ...args].join(" "), spawnArgs: undefined, opts: { shell: true } };
34
+ const bad = unsafeHostToken([cmd, ...args]);
35
+ if (bad !== null) return { refused: bad };
36
+ const shell = process.env.ComSpec ?? "cmd.exe";
37
+ return { command: shell, spawnArgs: ["/d", "/s", "/c", [cmd, ...args].join(" ")], opts: {} };
31
38
  }
32
39
  return { command: cmd, spawnArgs: args, opts: {} };
33
40
  }
34
41
 
42
+ function refusedResult(token) {
43
+ return { ok: false, status: 1, stdout: "", stderr: `refused unsafe token: ${token}\n` };
44
+ }
45
+
35
46
  function run(cmd, args, opts = {}) {
36
47
  const inv = hostInvocation(cmd, args);
48
+ if (inv.refused !== undefined) {
49
+ error(`refused unsafe token in spawn: ${inv.refused}`);
50
+ return false;
51
+ }
37
52
  const result = spawnSync(inv.command, inv.spawnArgs, {
38
53
  encoding: "utf8",
39
54
  stdio: "inherit",
@@ -45,6 +60,7 @@ function run(cmd, args, opts = {}) {
45
60
 
46
61
  function runCapture(cmd, args, opts = {}) {
47
62
  const inv = hostInvocation(cmd, args);
63
+ if (inv.refused !== undefined) return refusedResult(inv.refused);
48
64
  const result = spawnSync(inv.command, inv.spawnArgs, {
49
65
  encoding: "utf8",
50
66
  stdio: ["pipe", "pipe", "pipe"],
@@ -423,7 +423,7 @@ async function main() {
423
423
  if (check.stdout) process.stdout.write(check.stdout);
424
424
  if (autoInstall) {
425
425
  note("Installing dependencies and providers (MCP, skills, commands)");
426
- const result = spawnSync(process.execPath, [cli, "deps", "--install"], {
426
+ const result = spawnSync(process.execPath, [cli, "deps", "--install", "--yes"], {
427
427
  encoding: "utf8",
428
428
  env: { ...process.env, NOVAHIZ_HOME: home }
429
429
  });
package/install/lib.mjs CHANGED
@@ -190,13 +190,45 @@ export function loadManifest(home) {
190
190
  return readJson(join(home, ".novahiz-install.json"), { created: [], backups: [] });
191
191
  }
192
192
 
193
- // npm/npx are .cmd shims on Windows: direct spawnSync throws ENOENT, so they
194
- // need cmd.exe. Node deprecates args arrays with shell:true (DEP0190), which
195
- // only wants a single string — every token here is static (catalog package
196
- // names, fixed flags), so joining with spaces is safe.
193
+ // npm/npx are .cmd shims on Windows: direct spawnSync throws ENOENT
194
+ // (post-CVE-2024-* Node refuses to run .cmd via CreateProcess), so they need
195
+ // cmd.exe. The line handed to cmd.exe is built only from tokens that pass
196
+ // SAFE_HOST_TOKEN — the install-side mirror of src/exec.ts SAFE_TOKEN, widened
197
+ // by a single character: "*" (cmd.exe performs no glob expansion, and the
198
+ // flutter-skills `--skill *` argument must reach npx literally). Everything a
199
+ // shell can reinterpret — & | < > ^ % ! ( ) " ' ` ; $ ? whitespace, newlines —
200
+ // stays banned, so the joined line can never grow a second command. Tokens are
201
+ // static today (catalog package names, fixed flags); this check keeps that
202
+ // true for any future argument instead of trusting it. Same cmd.exe spelling
203
+ // as src/exec.ts resolveSpawn — cmd.exe is spawned explicitly with argv, no
204
+ // deprecated shell flag (DEP0190), so the joined line is the only string cmd
205
+ // ever parses.
206
+ const SAFE_HOST_TOKEN = /^[A-Za-z0-9@._+*,/:=~-]+$/;
207
+
208
+ export function unsafeHostToken(tokens) {
209
+ for (const token of tokens) {
210
+ if (token.length === 0) return "";
211
+ if (!SAFE_HOST_TOKEN.test(token)) return token;
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function refusedHost(token) {
217
+ return {
218
+ status: 1,
219
+ stdout: "",
220
+ stderr: `refused unsafe token: ${token}\n`,
221
+ error: new Error(`refused unsafe token: ${token}`),
222
+ };
223
+ }
224
+
197
225
  export function spawnHost(cmd, args, opts = {}) {
198
226
  if (process.platform === "win32") {
199
- return spawnSync([cmd, ...args].join(" "), { ...opts, encoding: "utf8", shell: true });
227
+ const bad = unsafeHostToken([cmd, ...args]);
228
+ if (bad !== null) return refusedHost(bad);
229
+ const shell = process.env.ComSpec ?? "cmd.exe";
230
+ const line = [cmd, ...args].join(" ");
231
+ return spawnSync(shell, ["/d", "/s", "/c", line], { ...opts, encoding: "utf8" });
200
232
  }
201
233
  return spawnSync(cmd, args, { ...opts, encoding: "utf8" });
202
234
  }
@@ -134,6 +134,7 @@ const TOOLS = [
134
134
  id: { type: "string", description: "Task id (new) or todo/task id (start, done, block, drop)." },
135
135
  task: { type: "string", description: "Task id. Defaults to the active task." },
136
136
  session: { type: "string", description: "Session id used to scope the active task." },
137
+ projectRoot: { type: "string", description: "Project directory a new task belongs to (action new). Omitted = unscoped legacy task." },
137
138
  label: { type: "string", description: "Todo label for action todo." },
138
139
  kind: { type: "string", enum: ["read", "edit", "verify", "delegate"], description: "Todo kind." },
139
140
  acceptance: { type: "string", description: "Acceptance criterion for the todo." },
@@ -393,36 +394,37 @@ function callTool(name, args) {
393
394
  // Same shape as the CLI's results array: path alongside the GateResult,
394
395
  // on the same object so enforceLedgerChecks mutates this verdict in place.
395
396
  result.path = file;
396
- if (session.length > 0) {
397
- let mdb = null;
397
+ // Parity with the CLI: an empty session no longer skips this block. The
398
+ // sessionless call still enforces unbound tasks (activeTask) and fails
399
+ // closed on DB errors; it simply writes no enforcement_log row.
400
+ let mdb = null;
401
+ try {
402
+ mdb = openDb(resolve(spec.root, spec.config.dbPath));
403
+ } catch {
404
+ mdb = null;
405
+ }
406
+ if (mdb) {
398
407
  try {
399
- mdb = openDb(resolve(spec.root, spec.config.dbPath));
400
- } catch {
401
- mdb = null;
402
- }
403
- if (mdb) {
404
- try {
405
- const enforced = enforceLedgerChecks(mdb, {
406
- session,
407
- tool: String(args?.tool ?? "edit"),
408
- paths: [file],
409
- categories,
410
- results: [result],
411
- spec,
412
- gateConfig: spec.config.gate
413
- });
414
- if (enforced.reasons.length > 0) {
415
- result.reasons.push(...enforced.reasons);
416
- result.allow = false;
417
- }
418
- if (enforced.reviewWarning) result.reasons.push(enforced.reviewWarning);
419
- } finally {
420
- mdb.close();
408
+ const enforced = enforceLedgerChecks(mdb, {
409
+ session,
410
+ tool: String(args?.tool ?? "edit"),
411
+ paths: [file],
412
+ categories,
413
+ results: [result],
414
+ spec,
415
+ gateConfig: spec.config.gate
416
+ });
417
+ if (enforced.reasons.length > 0) {
418
+ result.reasons.push(...enforced.reasons);
419
+ result.allow = false;
421
420
  }
422
- } else {
423
- result.allow = false;
424
- result.reasons.push("DB open failed: ledger enforcement unavailable");
421
+ if (enforced.reviewWarning) result.reasons.push(enforced.reviewWarning);
422
+ } finally {
423
+ mdb.close();
425
424
  }
425
+ } else {
426
+ result.allow = false;
427
+ result.reasons.push("DB open failed: ledger enforcement unavailable");
426
428
  }
427
429
  // A gate refusal is a normal verdict, not an execution error.
428
430
  return toolResult(result, false);
@@ -466,6 +468,11 @@ function callTool(name, args) {
466
468
  if (name === "novahiz_step") {
467
469
  const session = String(args?.session ?? "default");
468
470
  const done = args?.done ? String(args.done) : "";
471
+ // WS3: the step id follows the roadmap naming pattern (plan, write,
472
+ // impeccable-critique) — reject anything else before it reaches the database.
473
+ if (done.length > 0 && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(done)) {
474
+ throw new Error(`Invalid params: step id must match [A-Za-z0-9][A-Za-z0-9._-]{0,127} (got "${done}")`);
475
+ }
469
476
  const db = openDb(resolve(spec.root, spec.config.dbPath));
470
477
  try {
471
478
  if (done.length > 0) {
@@ -497,7 +504,9 @@ function callTool(name, args) {
497
504
  const db = openDb(resolve(spec.root, spec.config.dbPath));
498
505
  try {
499
506
  if (action === "new") {
500
- return toolResult(createTask(db, { title: String(args?.title ?? ""), id: args?.id ? String(args.id) : undefined, sessionId: session }));
507
+ // F: projectRoot scopes the new task to a project when the caller knows
508
+ // it; omitted = unscoped legacy task (no path filtering).
509
+ return toolResult(createTask(db, { title: String(args?.title ?? ""), id: args?.id ? String(args.id) : undefined, sessionId: session, projectRoot: args?.projectRoot ? String(args.projectRoot) : undefined }));
501
510
  }
502
511
  if (action === "plan") {
503
512
  const taskId = args?.task ? String(args.task) : activeTask(db, session)?.id;