@decidio/sdk 0.8.0 → 0.9.0

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/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync, fstatSync, readSync } from "node:fs";
3
3
  import { randomBytes, createHash } from "node:crypto";
4
4
  import { homedir, tmpdir } from "node:os";
5
- import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, relative, isAbsolute } from "node:path";
5
+ import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, dirname, relative, isAbsolute } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { isIP } from "node:net";
8
8
  import { execFileSync, spawnSync } from "node:child_process";
@@ -835,11 +835,12 @@ async function firstRunFork(signedInAs = null) {
835
835
  return { kind: "stop", code: 1 };
836
836
  }
837
837
  log("");
838
- log(` What next? ${dim("[1/2/3]")}`);
838
+ log(` What next? ${dim("[1/2/3/4]")}`);
839
839
  log(` ${bold("1")} the two-minute tour against your workspace ${dim("npx @decidio/sdk quickstart")}`);
840
840
  log(` ${bold("2")} wire up your own agent now (register, token, .env) ${dim("npx @decidio/sdk init <agentId>")}`);
841
841
  log(` ${bold("3")} sign out of this computer ${dim("npx @decidio/sdk logout")}`);
842
- const b = await pick("1, 2 or 3", /^[123]$/);
842
+ log(` ${bold("4")} put Decidio on your phone (scan a QR here) ${dim("npx @decidio/sdk phone")}`);
843
+ const b = await pick("1, 2, 3 or 4", /^[1234]$/);
843
844
  if (!b) {
844
845
  log(` No answer — you are signed in; run any command above when you are ready.`);
845
846
  return { kind: "stop", code: 0 };
@@ -854,6 +855,11 @@ async function firstRunFork(signedInAs = null) {
854
855
  log("");
855
856
  return { kind: "logout" };
856
857
  }
858
+ if (b === "4") {
859
+ log(` → running: npx @decidio/sdk phone`);
860
+ log("");
861
+ return { kind: "phone" };
862
+ }
857
863
  const here = readEnvFile();
858
864
  if (["DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN"].some((k) => (here[k] ?? "").trim()) && here.DECIDIO_AGENT_ID) {
859
865
  const tour = /^quickstart-/.test(here.DECIDIO_AGENT_ID);
@@ -888,8 +894,73 @@ async function cmdLogin(emailArg) {
888
894
  return 0;
889
895
  }
890
896
  const token = await loginFlow("Signing in", emailArg);
897
+ if (token)
898
+ log(dim(` next: approve from your phone — npx @decidio/sdk phone`));
891
899
  return token ? 0 : 1;
892
900
  }
901
+ const PHONE_MAX_QRS = 3;
902
+ async function cmdPhone() {
903
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
904
+ err(`✘ phone setup prints a one-time sign-in QR, so it runs only in an interactive terminal (never into a log or a pipe).`);
905
+ err(` Run it in a terminal: npx @decidio/sdk phone`);
906
+ return 2;
907
+ }
908
+ const session = await acquireSession("Setting up your phone");
909
+ if (!session)
910
+ return 1;
911
+ const who = process.env.DECIDIO_SESSION_TOKEN ? null : loadSession()?.email ?? null;
912
+ log("");
913
+ log(` Put Decidio on your phone? ${dim("[y/n]")}`);
914
+ log(` The QR signs that phone in${who ? ` as ${who}` : ""} - once, within 5 minutes. Whoever scans it first is signed in`);
915
+ log(` as you, so don't show it on a shared screen or a projector.`);
916
+ const yes = (await ask(" › ")).trim().toLowerCase();
917
+ if (yes !== "y") {
918
+ log(` Nothing was made. Run npx @decidio/sdk phone when you are ready.`);
919
+ return 0;
920
+ }
921
+ for (let n = 1; n <= PHONE_MAX_QRS; n++) {
922
+ const r = await api("/demo/session-link", { method: "POST", body: JSON.stringify({ setup: true }) }, session, true);
923
+ if (r.status === 404) {
924
+ log(` ✘ QR phone setup is on the Decidio lab for now. On your company's Decidio, open ${APP_URL}/phone on the phone`);
925
+ log(` and sign in with your company's sign-in (SSO).`);
926
+ return 1;
927
+ }
928
+ if (r.status === 401) {
929
+ log(` ✘ this computer's sign-in is no longer valid - run npx @decidio/sdk logout, then npx @decidio/sdk phone again.`);
930
+ return 1;
931
+ }
932
+ const link = r.status >= 200 && r.status < 300 && typeof r.body?.link === "string" ? String(r.body.link) : null;
933
+ if (!link) {
934
+ log(` ✘ no QR this time (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. Nothing was signed in.`);
935
+ return 1;
936
+ }
937
+ const qr = typeof r.body?.qr === "string" ? String(r.body.qr) : null;
938
+ log("");
939
+ if (qr) {
940
+ log(` Scan with the phone's camera ${dim("(one time · 5 minutes)")}:`);
941
+ for (const l of qr.split("\n"))
942
+ if (l.trim().length)
943
+ log(" " + l);
944
+ }
945
+ else
946
+ log(` Open this on the phone ${dim("(one time · 5 minutes - no QR came back this time)")}:`);
947
+ log(` ${dim(link)}`);
948
+ log(` ${bold("iPhone")} the camera opens the Decidio app, signed in. No app yet? The page walks you through installing it,`);
949
+ log(` then scan again.`);
950
+ log(` ${bold("Android")} opens in the phone's browser, signed in (phone alerts come with the app, iPhone only for now).`);
951
+ log("");
952
+ if (n === PHONE_MAX_QRS) {
953
+ log(` That's ${PHONE_MAX_QRS} - run npx @decidio/sdk phone again for more.`);
954
+ return 0;
955
+ }
956
+ const next = (await ask(` Enter = a fresh QR (this one stops working) · q = done: `)).trim().toLowerCase();
957
+ if (next !== "" || !sharedRlOpen || process.stdin.readableEnded || process.stdin.destroyed) {
958
+ log(` Done.`);
959
+ return 0;
960
+ }
961
+ }
962
+ return 0;
963
+ }
893
964
  const TRUTH_SESSION = "The session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet";
894
965
  function cmdLogout() {
895
966
  if (endSession()) {
@@ -2370,14 +2441,198 @@ function hookSegments(command) {
2370
2441
  while (tokens.length && tokens[0].startsWith("-"))
2371
2442
  tokens.shift();
2372
2443
  }
2373
- const program = basename((tokens[0] ?? "").replace(/^["']|["']$/g, "")).toLowerCase();
2374
- return { program, args: tokens.slice(1), raw };
2444
+ const bin = (tokens[0] ?? "").replace(/^["']|["']$/g, "");
2445
+ return { program: basename(bin).toLowerCase(), bin, args: tokens.slice(1), raw };
2375
2446
  });
2376
2447
  }
2377
- const hookFlag = (args, letters, longs = []) => args.some((a) => (/^-[A-Za-z]+$/.test(a) && [...letters].some((l) => a.slice(1).includes(l))) || longs.includes(a));
2378
- const hookSub = (s, i = 0) => (s.args.filter((a) => !a.startsWith("-"))[i] ?? "").toLowerCase();
2448
+ const hookFlag = (args, letters, longs = []) => args.some((a) => (/^-[A-Za-z]+$/.test(a) && [...letters].some((l) => a.slice(1).includes(l))) || longs.includes(a.split("=")[0]));
2449
+ const HOOK_GIT_VALUED = ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"];
2450
+ function hookGitArgs(s) {
2451
+ let i = 0;
2452
+ while (i < s.args.length && s.args[i].startsWith("-"))
2453
+ i += HOOK_GIT_VALUED.includes(s.args[i]) ? 2 : 1;
2454
+ return s.args.slice(i);
2455
+ }
2456
+ function hookGitElsewhere(s, cwdAbs) {
2457
+ for (let i = 0; i < s.args.length && s.args[i].startsWith("-"); i++) {
2458
+ const f = s.args[i];
2459
+ if (f === "-C") {
2460
+ if (resolvePath(cwdAbs, (s.args[i + 1] ?? "").replace(/^["']|["']$/g, "")) !== resolvePath(cwdAbs))
2461
+ return true;
2462
+ i++;
2463
+ }
2464
+ else if (/^--(git-dir|work-tree)(=|$)/.test(f))
2465
+ return true;
2466
+ else if (HOOK_GIT_VALUED.includes(f))
2467
+ i++;
2468
+ }
2469
+ return false;
2470
+ }
2471
+ const hookSub = (s, i = 0) => ((s.program === "git" ? hookGitArgs(s) : s.args).filter((a) => !a.startsWith("-"))[i] ?? "").toLowerCase();
2379
2472
  const HOOK_DB_CLIENTS = ["psql", "pgcli", "mysql", "mysqlsh", "mycli", "mariadb", "sqlite3", "sqlite", "litecli", "mongosh", "mongo", "redis-cli", "clickhouse-client", "clickhouse", "sqlcmd", "bq", "duckdb", "cqlsh", "snowsql", "usql", "dbmate", "goose", "flyway", "liquibase", "atlas", "alembic", "knex", "sequelize", "typeorm", "drizzle-kit", "supabase", "wrangler", "turso", "pscale"];
2473
+ const hookSqlStmt = (t) => /\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(t) || /\bTRUNCATE\s+(TABLE\s+)?\S/i.test(t) || /\bDELETE\s+FROM\s+\S/i.test(t);
2474
+ const hookUnq = (a) => a.replace(/^["']|["']$/g, "");
2475
+ const HOOK_INTERP = /^(python[0-9.]*|node|deno|bun|ruby|perl|php|tsx|ts-node)(\.exe)?$/;
2476
+ const hookInterp = (s) => HOOK_INTERP.test(s.program) || s.program === "npx" || (s.program === "uv" && s.args[0] === "run")
2477
+ || s.program === "manage.py" || (s.program === "rails" && hookSub(s) === "runner");
2478
+ function hookUnwrap(s) {
2479
+ let args;
2480
+ if (s.program === "uv" && s.args[0] === "run")
2481
+ args = s.args.slice(1);
2482
+ else if (s.program === "npx")
2483
+ args = s.args;
2484
+ else
2485
+ return s;
2486
+ while (args.length && args[0].startsWith("-"))
2487
+ args = args.slice(1);
2488
+ if (!args.length)
2489
+ return s;
2490
+ const bin = hookUnq(args[0]);
2491
+ return { program: basename(bin).toLowerCase(), bin, args: args.slice(1), raw: s.raw };
2492
+ }
2493
+ function hookInline(s0) {
2494
+ const s = hookUnwrap(s0);
2495
+ const django = s.program === "manage.py" || (/^python[0-9.]*(\.exe)?$/.test(s.program) && hookUnq(s.args.find((a) => !a.startsWith("-")) ?? "").endsWith("manage.py"));
2496
+ if (django || (s.program === "rails" && hookSub(s) === "runner"))
2497
+ return /<</.test(s.raw) || s.args.some((a) => ["-c", "--command", "-e", "-"].includes(a));
2498
+ if (!HOOK_INTERP.test(s.program))
2499
+ return false;
2500
+ if (/<</.test(s.raw))
2501
+ return true;
2502
+ if (s.program.startsWith("deno") && s.args[0] === "eval")
2503
+ return true;
2504
+ for (const a of s.args) {
2505
+ if (a === "-")
2506
+ return true;
2507
+ if (a === "-m" || /^-(V|h|-version|-help)$/.test(a))
2508
+ return false;
2509
+ if (/^-[A-Za-z]*[ce]$/.test(a) || a === "--eval" || a === "--command" || (/^(node|bun)/.test(s.program) && (a === "-p" || a === "--print")) || (s.program.startsWith("php") && a === "-r"))
2510
+ return true;
2511
+ if (!a.startsWith("-"))
2512
+ return false;
2513
+ }
2514
+ return true;
2515
+ }
2516
+ function hookExpand(p, at) {
2517
+ const t = hookUnq(p);
2518
+ const m = t.match(/^(~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|\$env:HOME|\$CLAUDE_PROJECT_DIR|\$\{CLAUDE_PROJECT_DIR\}|\$PWD|\$\{PWD\})(?=$|[\\/])/i);
2519
+ const root = !m ? "" : /PROJECT/i.test(m[1]) ? at.projectDir : /PWD/i.test(m[1]) ? at.cwd : at.home;
2520
+ const out = m ? root + t.slice(m[0].length) : t;
2521
+ return /[$`]/.test(out) ? null : out;
2522
+ }
2523
+ function hookSegDirs(segs, at) {
2524
+ let dir = at.cwd;
2525
+ return segs.map((s) => {
2526
+ const here = dir;
2527
+ if (["cd", "pushd", "chdir", "set-location", "sl", "push-location"].includes(s.program)) {
2528
+ const t = s.args.filter((a) => a !== "-P" && a !== "-L" && !/^-(Path|LiteralPath)$/i.test(a))[0];
2529
+ const e = t === undefined ? at.home : t === "-" ? null : hookExpand(t, at);
2530
+ dir = dir === null || e === null ? null : resolvePath(dir, e);
2531
+ }
2532
+ else if (s.program === "popd" || s.program === "pop-location")
2533
+ dir = null;
2534
+ return here;
2535
+ });
2536
+ }
2537
+ const hookNorm = (p) => resolvePath(p).split(pathSep).join("/").toLowerCase().replace(/\/+$/, "");
2538
+ function hookGuardKind(abs, at) {
2539
+ const real = (p) => { try {
2540
+ return realPath(p);
2541
+ }
2542
+ catch {
2543
+ return p;
2544
+ } };
2545
+ const paths = [...new Set([hookNorm(abs), hookNorm(joinPath(real(dirname(abs)), basename(abs)))])];
2546
+ const homes = [...new Set([hookNorm(at.home), hookNorm(real(at.home))])], projects = [...new Set([hookNorm(at.projectDir), hookNorm(real(at.projectDir))])];
2547
+ const settings = (p) => /\/\.claude\/settings[^/]*\.json$/.test(p);
2548
+ for (const p of paths) {
2549
+ for (const h of homes) {
2550
+ if (settings(p) && p.slice(0, p.lastIndexOf("/")) === `${h}/.claude`)
2551
+ return "user settings";
2552
+ if (p.startsWith(`${h}/.claude/plugins/`) && p.slice(h.length + 17).includes("decidio"))
2553
+ return "Decidio plugin";
2554
+ }
2555
+ if (settings(p) && projects.some((r) => p.startsWith(r + "/")))
2556
+ return "project settings";
2557
+ }
2558
+ return null;
2559
+ }
2560
+ function hookGuardDir(abs, at) {
2561
+ const d = hookNorm(abs), h = hookNorm(at.home);
2562
+ return d === `${h}/.claude` || d === `${h}/.claude/plugins` || (basename(d) === ".claude" && d.startsWith(hookNorm(at.projectDir) + "/"));
2563
+ }
2564
+ function hookShowPath(abs, at) {
2565
+ const inside = (root) => { const r = relative(root, abs); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : null; };
2566
+ const p = inside(at.projectDir);
2567
+ if (p !== null)
2568
+ return p;
2569
+ const h = inside(at.home);
2570
+ if (h !== null)
2571
+ return "~/" + h;
2572
+ return abs.split(pathSep).join("/");
2573
+ }
2574
+ function hookPlainArgs(args) {
2575
+ const out = [];
2576
+ for (let i = 0; i < args.length; i++) {
2577
+ const a = args[i];
2578
+ if (/^(\d+|&)?(>>?|<)\|?$/.test(a)) {
2579
+ i++;
2580
+ continue;
2581
+ }
2582
+ if (/^(\d+|&)?[<>]/.test(a))
2583
+ continue;
2584
+ out.push(a);
2585
+ }
2586
+ return out;
2587
+ }
2588
+ function hookWriteTargets(s) {
2589
+ const out = [...s.raw.matchAll(/(?:\d+|&)?>>?\|?\s*([^\s;&|<>()]+)/g)].map((m) => m[1]).filter((t) => !t.startsWith("&"));
2590
+ const files = hookPlainArgs(s.args).filter((a) => !a.startsWith("-"));
2591
+ const p = s.program;
2592
+ if (["tee", "sponge", "rm", "unlink", "truncate", "rd", "rmdir", "set-content", "add-content", "out-file", "clear-content", "remove-item", "ri", "del", "erase", "new-item", "ni", "tee-object"].includes(p))
2593
+ out.push(...files);
2594
+ else if (p === "sed" && s.args.some((a) => /^-[A-Za-z]*i/.test(a) || /^--in-place(=|$)/.test(a)))
2595
+ out.push(...files);
2596
+ else if (["mv", "move", "move-item", "mi", "cp", "copy", "copy-item", "cpi"].includes(p)) {
2597
+ const t = s.args.findIndex((a) => a === "-t" || a === "--target-directory");
2598
+ const tdir = t >= 0 ? s.args[t + 1] : s.args.find((a) => a.startsWith("--target-directory="))?.slice(19);
2599
+ const srcs = tdir !== undefined ? files.filter((f) => f !== tdir) : files.slice(0, -1);
2600
+ const dest = tdir ?? files[files.length - 1];
2601
+ if (dest !== undefined)
2602
+ out.push(dest, ...srcs.map((f) => joinPath(hookUnq(dest), basename(hookUnq(f)))));
2603
+ if (["mv", "move", "move-item", "mi"].includes(p))
2604
+ out.push(...srcs);
2605
+ }
2606
+ else if (p === "dd")
2607
+ out.push(...s.args.filter((a) => a.startsWith("of=")).map((a) => a.slice(3)));
2608
+ return out;
2609
+ }
2610
+ function hookGuardHits(segs, at) {
2611
+ const hits = [];
2612
+ const dirs = hookSegDirs(segs, at);
2613
+ segs.forEach((s, i) => {
2614
+ for (const t of hookWriteTargets(s)) {
2615
+ const e = hookExpand(t.replace(/\\/g, "/"), at);
2616
+ const dir = dirs[i];
2617
+ if (e !== null && (dir !== null || isAbsolute(e))) {
2618
+ const abs = resolvePath(dir ?? at.cwd, e);
2619
+ if (hookGuardKind(abs, at) || hookGuardDir(abs, at))
2620
+ hits.push({ key: `path:${abs}`, display: hookShowPath(abs, at) });
2621
+ }
2622
+ else if (/(^|[\\/])\.claude[\\/](settings[^\\/]*\.json|plugins[\\/].*decidio)/i.test(hookUnq(t))) {
2623
+ hits.push({ key: `text:${t}`, display: hookUnq(t) });
2624
+ }
2625
+ }
2626
+ if (s.program === "claude" && /^plugins?$/.test(s.args[0] ?? "") && s.args.some((a) => /^(disable|uninstall|remove|rm)$/.test(a)) && s.args.some((a) => /decidio/i.test(a)))
2627
+ hits.push({ key: `plugin:${s.args.join(" ")}`, display: `claude ${s.args.join(" ")}` });
2628
+ if (/decidio/i.test(s.raw) && s.args.some((a, k) => a === "hook" && s.args[k + 1] === "install") && s.args.some((a) => a === "--cli" || a.startsWith("--cli=")))
2629
+ hits.push({ key: `install:${s.args.join(" ")}`, display: `hook install ${s.args.slice(s.args.indexOf("install") + 1).join(" ")}` });
2630
+ });
2631
+ return hits;
2632
+ }
2380
2633
  const HOOK_RULES = [
2634
+ { category: "decidio-guard", summary: "turns off or changes Decidio's check on this agent: a shell write to .claude/settings*.json, ~/.claude/settings*.json or the Decidio plugin's files (>, >>, tee, sed -i, rm, mv, cp, truncate, Set-Content, Out-File, Remove-Item, Move-Item, Copy-Item), claude plugin disable|uninstall decidio, hook install --cli; a Write/Edit/MultiEdit that removes or alters a Decidio hook, sets disableAllHooks, turns the Decidio plugin off, touches its files or leaves invalid JSON",
2635
+ matches: (segs, _cmd, at) => hookGuardHits(segs, at).length > 0 },
2381
2636
  { category: "delete-recursive", summary: "rm -r/-rf, find -delete / -exec rm, rmdir|rd|del /s, Remove-Item -Recurse, shred",
2382
2637
  matches: (segs) => segs.some((s) => (s.program === "rm" && hookFlag(s.args, "rR", ["--recursive"]))
2383
2638
  || (s.program === "find" && (s.args.includes("-delete") || s.args.some((a, i) => i > 0 && s.args[i - 1] === "-exec" && basename(a) === "rm")))
@@ -2394,10 +2649,10 @@ const HOOK_RULES = [
2394
2649
  || (hookSub(s) === "update-ref" && s.args.includes("-d"))
2395
2650
  || (hookSub(s) === "reflog" && hookSub(s, 1) === "expire")
2396
2651
  || (hookSub(s) === "gc" && s.args.some((a) => a.startsWith("--prune"))))) },
2397
- { category: "sql-destructive", summary: "DROP TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE, TRUNCATE, DELETE FROM - sent to a database client (psql, mysql, sqlite3, mongosh, sqlcmd, bq, ...); prisma migrate reset / db push --force-reset; rails db:drop|db:reset",
2652
+ { category: "sql-destructive", summary: "DROP TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE, TRUNCATE, DELETE FROM - sent to a database client (psql, mysql, sqlite3, mongosh, sqlcmd, bq, ...) or run by a program (python, node, deno, bun, ruby, perl, php, tsx, ts-node, npx, uv run, manage.py, rails runner) - in its own arguments, or anywhere on the line when it reads code inline (-c, -e, --eval, -, a here-doc, a here-string piped in); prisma migrate reset / db push --force-reset; rails db:drop|db:reset",
2398
2653
  matches: (segs, cmd) => {
2399
2654
  const toDb = segs.some((s) => HOOK_DB_CLIENTS.includes(s.program) || /\bprisma\b/.test(s.raw) || /\.sql\b/.test(s.raw));
2400
- return (toDb && (/\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(cmd) || /\bTRUNCATE\s+(TABLE\s+)?\S/i.test(cmd) || /\bDELETE\s+FROM\s+\S/i.test(cmd)))
2655
+ return ((toDb || segs.some(hookInline)) && hookSqlStmt(cmd)) || segs.some((s) => hookInterp(s) && hookSqlStmt(s.raw))
2401
2656
  || segs.some((s) => (/\bprisma\b/.test(s.raw) && (/\bmigrate\s+reset\b/.test(s.raw) || /--force-reset\b/.test(s.raw)))
2402
2657
  || (["rails", "rake"].includes(s.program) && /\bdb:(drop|reset|schema:load)\b/.test(s.raw)));
2403
2658
  } },
@@ -2471,11 +2726,207 @@ function hookExtraRules() {
2471
2726
  return { category: r.category, summary: typeof r.summary === "string" ? r.summary : `/${r.pattern}/${r.flags ?? ""}`, matches: (_s, cmd) => { re.lastIndex = 0; return re.test(cmd); } };
2472
2727
  });
2473
2728
  }
2474
- function hookMatch(command) {
2729
+ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], depth = 0) {
2475
2730
  const segs = hookSegments(command);
2476
- for (const r of [...HOOK_RULES, ...hookExtraRules()])
2477
- if (r.matches(segs, command))
2731
+ for (const r of rules)
2732
+ if (r.matches(segs, command, at))
2478
2733
  return r;
2734
+ if (depth < 2)
2735
+ for (const inner of hookShellInner(command)) {
2736
+ const r = hookMatch(inner, at, rules, depth + 1);
2737
+ if (r)
2738
+ return r;
2739
+ }
2740
+ return null;
2741
+ }
2742
+ function hookSelfDecides(command, depth = 0) {
2743
+ const hit = hookSegments(command).some((s) => {
2744
+ const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
2745
+ const cli = (x) => /^@decidio\/sdk(@\S+)?$/i.test(x) || /^decidio(@\S+)?$/i.test(x)
2746
+ || /(^|[\\/])(decidio-guard|@decidio[\\/]sdk)[\\/](dist[\\/])?cli\.(js|ts)$/i.test(x);
2747
+ const runsCli = s.program === "decidio" || s.program === "decidio.js"
2748
+ || ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
2749
+ const i = a.indexOf("approvals");
2750
+ return runsCli && i >= 0 && a.slice(i + 1).some((x) => /^(approve|reject)$/i.test(x));
2751
+ });
2752
+ return hit || (depth < 2 && hookShellInner(command).some((c) => hookSelfDecides(c, depth + 1)));
2753
+ }
2754
+ function hookShellInner(command) {
2755
+ const out = [];
2756
+ const re = /(?:^|[\s;&|(`])(?:bash|sh|zsh|dash|ksh|pwsh|powershell)(?:\.exe)?((?:\s+-[A-Za-z]+)*?)\s+(?:-[A-Za-z]*c|-Command|-command)\s+/g;
2757
+ for (let m = re.exec(command); m; m = re.exec(command)) {
2758
+ let i = m.index + m[0].length, arg = "";
2759
+ const q = command[i];
2760
+ if (q === "'") {
2761
+ const end = command.indexOf("'", i + 1);
2762
+ arg = command.slice(i + 1, end < 0 ? undefined : end);
2763
+ }
2764
+ else if (q === '"') {
2765
+ for (i += 1; i < command.length && command[i] !== '"'; i++)
2766
+ arg += command[i] === "\\" && (command[i + 1] === '"' || command[i + 1] === "\\") ? command[++i] : command[i];
2767
+ }
2768
+ else
2769
+ arg = command.slice(i).split(/\s/, 1)[0] ?? "";
2770
+ if (arg.trim())
2771
+ out.push(arg);
2772
+ }
2773
+ return out;
2774
+ }
2775
+ const HOOK_FOLLOW_MAX = 256 * 1024;
2776
+ const HOOK_SHELLS = ["bash", "sh", "zsh", "dash", "ksh"];
2777
+ const HOOK_PROGRAM_EXT = /\.(py|js|mjs|cjs|ts|rb)$/i;
2778
+ function hookProjectFile(p, dir, at) {
2779
+ try {
2780
+ const e = hookExpand(p, at);
2781
+ if (e === null)
2782
+ return null;
2783
+ const real = realPath(resolvePath(dir, e));
2784
+ let root = at.projectDir;
2785
+ try {
2786
+ root = realPath(at.projectDir);
2787
+ }
2788
+ catch { }
2789
+ const r = relative(root, real);
2790
+ if (!r || r.startsWith("..") || isAbsolute(r))
2791
+ return null;
2792
+ const st = statSync(real);
2793
+ if (!st.isFile() || st.size > HOOK_FOLLOW_MAX)
2794
+ return null;
2795
+ const buf = readFileSync(real);
2796
+ if (buf.length > HOOK_FOLLOW_MAX || buf.subarray(0, 8192).includes(0))
2797
+ return null;
2798
+ return { rel: r.split(pathSep).join("/"), text: buf.toString("utf8"), sha: createHash("sha256").update(buf).digest("hex") };
2799
+ }
2800
+ catch {
2801
+ return null;
2802
+ }
2803
+ }
2804
+ const hookLines = (text) => {
2805
+ const phys = text.split(/\r?\n/);
2806
+ const out = [];
2807
+ for (let i = 0; i < phys.length; i++) {
2808
+ const n = i + 1;
2809
+ let t = phys[i];
2810
+ while (/\\$/.test(t) && i + 1 < phys.length)
2811
+ t = t.slice(0, -1) + " " + phys[++i];
2812
+ out.push({ n, t });
2813
+ }
2814
+ return out;
2815
+ };
2816
+ function hookScanShell(f, at, rules) {
2817
+ const lines = hookLines(f.text).filter((l) => l.t.trim() && !/^\s*#/.test(l.t));
2818
+ for (const l of lines) {
2819
+ const r = hookMatch(l.t, at, rules);
2820
+ if (r)
2821
+ return { rule: r, rel: f.rel, line: l.n, text: l.t, sha: f.sha };
2822
+ }
2823
+ const r = hookMatch(lines.map((l) => l.t).join("\n"), at, rules);
2824
+ if (!r)
2825
+ return null;
2826
+ const l = lines.find((x) => hookSqlStmt(x.t)) ?? lines[0];
2827
+ return { rule: r, rel: f.rel, line: l.n, text: l.t, sha: f.sha };
2828
+ }
2829
+ function hookScanSql(f, rule) {
2830
+ const lines = hookLines(f.text).filter((l) => l.t.trim() && !/^\s*--/.test(l.t));
2831
+ const l = lines.find((x) => hookSqlStmt(x.t)) ?? (hookSqlStmt(lines.map((x) => x.t).join("\n")) ? lines.find((x) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(x.t)) : undefined);
2832
+ return l ? { rule, rel: f.rel, line: l.n, text: l.t, sha: f.sha } : null;
2833
+ }
2834
+ function hookScanProgram(f, rule) {
2835
+ const l = hookLines(f.text).find((x) => /\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(x.t) || /\bTRUNCATE\s+(TABLE\s+)?["'`[]?[A-Za-z_]/.test(x.t) || /\btruncate\s+table\s+\S/i.test(x.t));
2836
+ return l ? { rule, rel: f.rel, line: l.n, text: l.t, sha: f.sha } : null;
2837
+ }
2838
+ const HOOK_PM_OWN = new Set(["install", "i", "add", "remove", "rm", "uninstall", "un", "update", "up", "upgrade", "exec", "dlx", "create", "init", "publish", "pack", "link", "ln", "unlink", "why", "list", "ls", "outdated", "audit", "config", "info", "version", "help", "bin", "cache", "global", "set", "workspace", "workspaces", "dedupe", "import", "rebuild", "rb", "prune", "store", "patch", "plugin", "node", "env", "root", "fetch", "licenses", "login", "logout", "owner", "tag", "team", "unplug", "constraints", "explain", "npm", "dist-tag", "deploy", "setup", "doctor", "server"]);
2839
+ function hookPackageScript(s) {
2840
+ const a = hookPlainArgs(s.args).filter((x) => !x.startsWith("-"));
2841
+ if (s.program === "npm") {
2842
+ if (["run", "run-script", "rum", "urn"].includes(a[0] ?? ""))
2843
+ return a[1] ?? null;
2844
+ return { test: "test", t: "test", tst: "test", start: "start", stop: "stop", restart: "restart" }[a[0] ?? ""] ?? null;
2845
+ }
2846
+ if (s.program === "bun")
2847
+ return a[0] === "run" ? a[1] ?? null : null;
2848
+ if (s.program === "pnpm" || s.program === "yarn")
2849
+ return a[0] === "run" ? a[1] ?? null : a[0] && !HOOK_PM_OWN.has(a[0]) ? a[0] : null;
2850
+ return null;
2851
+ }
2852
+ function hookProgramFile(s) {
2853
+ const ext = /^python[0-9.]*(\.exe)?$/.test(s.program) ? /\.py$/i : /^(node|deno|bun|tsx|ts-node)(\.exe)?$/.test(s.program) ? /\.(js|mjs|cjs|ts)$/i : /^ruby(\.exe)?$/.test(s.program) ? /\.rb$/i : null;
2854
+ if (!ext)
2855
+ return null;
2856
+ let a = hookPlainArgs(s.args);
2857
+ if (/^(deno|bun)/.test(s.program) && a[0] === "run")
2858
+ a = a.slice(1);
2859
+ for (const x of a) {
2860
+ if (x === "-" || x === "-m" || /^-[A-Za-z]*[ce]$/.test(x) || x === "--eval" || x === "-p" || x === "--print")
2861
+ return null;
2862
+ if (x.startsWith("-"))
2863
+ continue;
2864
+ return ext.test(hookUnq(x)) ? hookUnq(x) : null;
2865
+ }
2866
+ return null;
2867
+ }
2868
+ function hookFollow(segs, at) {
2869
+ const rules = [...HOOK_RULES, ...hookExtraRules()];
2870
+ const sql = HOOK_RULES.find((r) => r.category === "sql-destructive");
2871
+ const dirs = hookSegDirs(segs, at);
2872
+ for (let i = 0; i < segs.length; i++) {
2873
+ const dir = dirs[i];
2874
+ if (dir === null || dir === undefined)
2875
+ continue;
2876
+ const s = hookUnwrap(segs[i]);
2877
+ const file = (p) => (p ? hookProjectFile(p, dir, at) : null);
2878
+ const redirIn = s.raw.match(/(?:^|[^<])<(?![<(])\s*([^\s<>|&;()]+)/)?.[1];
2879
+ let sh;
2880
+ if (HOOK_SHELLS.includes(s.program)) {
2881
+ const a = hookPlainArgs(s.args);
2882
+ sh = a.some((x) => /^-[A-Za-z]*c[A-Za-z]*$/.test(x)) ? undefined : a.find((x) => !x.startsWith("-")) ?? redirIn;
2883
+ }
2884
+ else if (s.program === "source" || s.program === ".")
2885
+ sh = hookPlainArgs(s.args)[0];
2886
+ else if (/[\\/]/.test(s.bin) && !HOOK_PROGRAM_EXT.test(s.bin) && !/\.sql$/i.test(s.bin))
2887
+ sh = s.bin;
2888
+ if (sh !== undefined) {
2889
+ const f = file(sh);
2890
+ const hit = f && hookScanShell(f, at, rules);
2891
+ if (hit)
2892
+ return hit;
2893
+ continue;
2894
+ }
2895
+ if (HOOK_DB_CLIENTS.includes(s.program)) {
2896
+ const k = s.args.findIndex((x) => x === "-f" || x === "--file");
2897
+ const sqlFile = (s.program === "psql" ? (k >= 0 ? s.args[k + 1] : s.args.find((x) => x.startsWith("--file="))?.slice(7)) : undefined)
2898
+ ?? redirIn ?? (["sqlite3", "sqlite", "duckdb"].includes(s.program) ? s.raw.match(/\.read\s+["']?([^"'\s]+)/)?.[1] : undefined);
2899
+ const f = file(sqlFile);
2900
+ const hit = f && hookScanSql(f, sql);
2901
+ if (hit)
2902
+ return hit;
2903
+ continue;
2904
+ }
2905
+ const script = hookPackageScript(s);
2906
+ if (script) {
2907
+ const f = file("package.json");
2908
+ let body;
2909
+ try {
2910
+ body = f ? JSON.parse(f.text)?.scripts?.[script] : undefined;
2911
+ }
2912
+ catch {
2913
+ body = undefined;
2914
+ }
2915
+ if (f && typeof body === "string") {
2916
+ const r = hookMatch(body, at, rules);
2917
+ if (r) {
2918
+ const n = f.text.split(/\r?\n/).findIndex((l) => l.replace(/\s+/g, "").includes(JSON.stringify(script) + ":"));
2919
+ return { rule: r, rel: f.rel, line: n + 1 || 1, text: body, sha: f.sha };
2920
+ }
2921
+ continue;
2922
+ }
2923
+ }
2924
+ const prog = hookProgramFile(s) ?? (/[\\/]/.test(s.bin) && HOOK_PROGRAM_EXT.test(s.bin) ? s.bin : null);
2925
+ const f = file(prog ?? undefined);
2926
+ const hit = f && hookScanProgram(f, sql);
2927
+ if (hit)
2928
+ return hit;
2929
+ }
2479
2930
  return null;
2480
2931
  }
2481
2932
  function hookRedact(command) {
@@ -2487,21 +2938,29 @@ function hookRedact(command) {
2487
2938
  .replace(/((?:-H|--header)\s+["']?(?:authorization|x-api-key|cookie)\s*:\s*)[^"']+/gi, "$1[REDACTED]")
2488
2939
  .replace(/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk_(?:live|test)_[A-Za-z0-9]{10,})\b/g, "[REDACTED]");
2489
2940
  }
2490
- function hookSubject(category, segs, cwdAbs, projectDir) {
2491
- const textOnly = (why) => ({ fingerprint: null, display: `text-only (${why})` });
2492
- const relOrAbs = (p) => { const r = relative(projectDir, p); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : p; };
2493
- const nonFlags = (s) => s.args.filter((a) => !a.startsWith("-"));
2494
- const git = () => {
2941
+ const hookGitMemo = new Map();
2942
+ function hookGitRead(cwdAbs) {
2943
+ const hit = hookGitMemo.get(cwdAbs);
2944
+ if (hit)
2945
+ return hit;
2946
+ const run = (args) => {
2495
2947
  try {
2496
- const run = (args) => execFileSync("git", args, { cwd: cwdAbs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
2497
- const head = run(["rev-parse", "HEAD"]);
2498
- const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
2499
- return /^[0-9a-f]{40}$/.test(head) && branch ? { head, branch } : null;
2948
+ return execFileSync("git", args, { cwd: cwdAbs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
2500
2949
  }
2501
2950
  catch {
2502
2951
  return null;
2503
2952
  }
2504
2953
  };
2954
+ const head = run(["rev-parse", "HEAD"]);
2955
+ const out = { head: head && /^[0-9a-f]{40}$/.test(head) ? head : null, branch: head ? run(["rev-parse", "--abbrev-ref", "HEAD"]) : null, origin: run(["remote", "get-url", "origin"]) };
2956
+ hookGitMemo.set(cwdAbs, out);
2957
+ return out;
2958
+ }
2959
+ function hookSubject(category, segs, cwdAbs, projectDir, at) {
2960
+ const textOnly = (why) => ({ fingerprint: null, display: `text-only (${why})` });
2961
+ const relOrAbs = (p) => { const r = relative(projectDir, p); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : p; };
2962
+ const nonFlags = (s) => s.args.filter((a) => !a.startsWith("-"));
2963
+ const git = () => { const g = hookGitRead(cwdAbs); return g.head && g.branch ? { head: g.head, branch: g.branch } : null; };
2505
2964
  const firstOf = (programs) => segs.findIndex((s) => programs.includes(s.program));
2506
2965
  const movedTree = (i) => segs.slice(0, i).some((s) => s.program === "cd" || s.program === "pushd");
2507
2966
  if (category === "git-rewrite" || category === "infra-apply") {
@@ -2510,11 +2969,13 @@ function hookSubject(category, segs, cwdAbs, projectDir) {
2510
2969
  return textOnly("no subject");
2511
2970
  if (movedTree(i))
2512
2971
  return textOnly("a cd on the same line moves the call to another directory");
2972
+ if (category === "git-rewrite" && hookGitElsewhere(segs[i], cwdAbs))
2973
+ return textOnly("git -C names another directory");
2513
2974
  const g = git();
2514
2975
  if (!g)
2515
2976
  return textOnly("not a git repository here");
2516
2977
  const seg = segs[i];
2517
- const args = nonFlags(seg);
2978
+ const args = category === "git-rewrite" ? hookGitArgs(seg).filter((a) => !a.startsWith("-")) : nonFlags(seg);
2518
2979
  const extra = category === "git-rewrite" ? args.slice(1).join(" ") : [relOrAbs(cwdAbs), ...seg.args.filter((a) => /^(-var-file|--var-file|-plan|--plan)/.test(a) || /\.(tfplan|tfvars)$/.test(a))].join(" ");
2519
2980
  return { fingerprint: `${category}:${g.head}:${g.branch}:${extra}`, display: `HEAD ${g.head.slice(0, 7)} on ${g.branch}${extra ? ` · ${hookRedact(extra).slice(0, 120)}` : ""}` };
2520
2981
  }
@@ -2533,17 +2994,245 @@ function hookSubject(category, segs, cwdAbs, projectDir) {
2533
2994
  if (category === "sql-destructive") {
2534
2995
  const stIdx = segs.findIndex((s) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(s.raw));
2535
2996
  const dbIdx = firstOf(HOOK_DB_CLIENTS);
2536
- const client = dbIdx >= 0 ? segs[dbIdx] : stIdx >= 0 ? segs[stIdx] : null;
2997
+ const own = segs.findIndex((s) => hookInterp(s) && hookSqlStmt(s.raw));
2998
+ const inIdx = dbIdx >= 0 ? -1 : own >= 0 ? own : segs.findIndex(hookInline);
2999
+ const client = dbIdx >= 0 ? segs[dbIdx] : inIdx >= 0 ? segs[inIdx] : stIdx >= 0 ? segs[stIdx] : null;
2537
3000
  if (!client)
2538
3001
  return textOnly("no statement");
2539
3002
  const stmtSeg = stIdx >= 0 ? segs[stIdx] : client;
2540
3003
  const stmt = (stmtSeg.raw.match(/\b(DROP\s+\w+[^;'"]*|TRUNCATE[^;'"]*|DELETE\s+FROM[^;'"]*)/i)?.[1] ?? stmtSeg.raw).replace(/\s+/g, " ").trim();
2541
3004
  const stmtAt = client.args.findIndex((a) => /^["']?(DROP|TRUNCATE|DELETE)\b/i.test(a));
2542
- const target = (stmtAt >= 0 ? client.args.slice(0, stmtAt) : client.args).filter((a) => !a.startsWith("-")).join(" ");
3005
+ const target = (stmtAt >= 0 ? client.args.slice(0, stmtAt) : client.args).filter((a) => !a.startsWith("-") && !/^["'@]+$/.test(a)).join(" ");
2543
3006
  return { fingerprint: `sql:${client.program}:${target}:${stmt.toUpperCase()}`, display: hookRedact(`${client.program}${target ? ` ${target}` : ""}: ${stmt}`).slice(0, 160) };
2544
3007
  }
3008
+ if (category === "decidio-guard" && at) {
3009
+ const hits = hookGuardHits(segs, at);
3010
+ if (!hits.length)
3011
+ return textOnly("no subject");
3012
+ return { fingerprint: `guard:${[...new Set(hits.map((h) => h.key))].sort().join("\0")}`, display: hookRedact([...new Set(hits.map((h) => h.display))].join(", ")).slice(0, 160) };
3013
+ }
2545
3014
  return textOnly("text binds this category");
2546
3015
  }
3016
+ const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
3017
+ const hookSorted = (v) => Array.isArray(v) ? v.map(hookSorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, hookSorted(v[k])])) : v;
3018
+ function hookOurHandlers(settings) {
3019
+ const out = [];
3020
+ const hooks = settings && typeof settings === "object" ? settings.hooks : undefined;
3021
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
3022
+ return out;
3023
+ for (const [event, list] of Object.entries(hooks))
3024
+ if (Array.isArray(list))
3025
+ for (const g of list)
3026
+ if (g && typeof g === "object" && Array.isArray(g.hooks))
3027
+ for (const h of g.hooks)
3028
+ if (hookIsOurs(h)) {
3029
+ const matcher = typeof g.matcher === "string" ? g.matcher : "";
3030
+ out.push({ event, matcher, iff: typeof h.if === "string" ? h.if : "", key: `${event}\0${matcher}\0${JSON.stringify(hookSorted(h))}` });
3031
+ }
3032
+ return out;
3033
+ }
3034
+ function hookFindEdit(text, from) {
3035
+ if (text.includes(from))
3036
+ return from;
3037
+ const fold = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"');
3038
+ const i = fold(text).indexOf(fold(from));
3039
+ return i < 0 ? null : text.slice(i, i + from.length);
3040
+ }
3041
+ function hookFileGuard(tool, ti, at) {
3042
+ const t = (ti && typeof ti === "object" ? ti : {});
3043
+ if (typeof t.file_path !== "string" || !t.file_path)
3044
+ return null;
3045
+ const abs = resolvePath(at.cwd, t.file_path);
3046
+ const kind = hookGuardKind(abs, at);
3047
+ if (!kind)
3048
+ return null;
3049
+ const display = hookShowPath(abs, at);
3050
+ const sha = (s) => createHash("sha256").update(s, "utf8").digest("hex");
3051
+ let before = null, unreadable = false;
3052
+ try {
3053
+ before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
3054
+ }
3055
+ catch {
3056
+ unreadable = true;
3057
+ }
3058
+ let after;
3059
+ if (tool === "Write") {
3060
+ if (typeof t.content !== "string")
3061
+ return null;
3062
+ after = t.content;
3063
+ }
3064
+ else {
3065
+ const edits = tool === "Edit" ? [t] : Array.isArray(t.edits) ? t.edits : null;
3066
+ if (!edits || !edits.length)
3067
+ return null;
3068
+ if (unreadable)
3069
+ return { abs, display, changes: ["the current file could not be read, so the change cannot be checked"], sha: sha(`unreadable\n${JSON.stringify(edits)}`) };
3070
+ let text = before;
3071
+ for (const raw of edits) {
3072
+ const e = (raw && typeof raw === "object" ? raw : {});
3073
+ const from = e.old_string, to = e.new_string;
3074
+ if (typeof from !== "string" || typeof to !== "string")
3075
+ return null;
3076
+ if (text === null) {
3077
+ if (from !== "")
3078
+ return null;
3079
+ text = to;
3080
+ continue;
3081
+ }
3082
+ if (from === "") {
3083
+ text = text.trim() === "" ? to : to + text;
3084
+ continue;
3085
+ }
3086
+ const found = hookFindEdit(text, from);
3087
+ if (found === null)
3088
+ return null;
3089
+ text = e.replace_all === true ? text.split(found).join(to) : text.replace(found, () => to);
3090
+ }
3091
+ after = text ?? "";
3092
+ }
3093
+ const changes = [];
3094
+ if (kind === "Decidio plugin")
3095
+ changes.push("changes the Decidio plugin's own files");
3096
+ else {
3097
+ let parsed, valid = true;
3098
+ try {
3099
+ parsed = JSON.parse(after);
3100
+ }
3101
+ catch {
3102
+ valid = false;
3103
+ }
3104
+ const obj = valid && !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
3105
+ if (!obj)
3106
+ changes.push(valid ? "leaves the file something other than a JSON object" : "leaves the file invalid JSON");
3107
+ let prev = {};
3108
+ if (unreadable)
3109
+ changes.push("the current file could not be read, so what it removes cannot be checked");
3110
+ else if (before !== null) {
3111
+ try {
3112
+ prev = JSON.parse(before);
3113
+ }
3114
+ catch {
3115
+ prev = {};
3116
+ }
3117
+ }
3118
+ const now = obj ? parsed : {};
3119
+ const nowHandlers = hookOurHandlers(now);
3120
+ const kept = new Set(nowHandlers.map((h) => h.key)), slots = new Set(nowHandlers.map((h) => `${h.event}\0${h.matcher}\0${h.iff}`));
3121
+ const lost = hookOurHandlers(prev).filter((h) => !kept.has(h.key));
3122
+ const verb = (h) => slots.has(`${h.event}\0${h.matcher}\0${h.iff}`) ? "changes" : "removes";
3123
+ if (lost.length === 1)
3124
+ changes.push(`${verb(lost[0])} Decidio's ${lost[0].event} hook${lost[0].matcher ? ` (${lost[0].matcher}${lost[0].iff ? `, ${lost[0].iff}` : ""})` : ""}`);
3125
+ else if (lost.length) {
3126
+ const removed = lost.filter((h) => verb(h) === "removes").length;
3127
+ changes.push(`${removed === lost.length ? "removes" : removed ? "removes or changes" : "changes"} ${lost.length} of Decidio's hooks (${[...new Set(lost.map((h) => h.event))].join(", ")})`);
3128
+ }
3129
+ if (now.disableAllHooks === true && prev?.disableAllHooks !== true)
3130
+ changes.push("sets disableAllHooks");
3131
+ const ep = (o) => (o && typeof o === "object" && o.enabledPlugins && typeof o.enabledPlugins === "object" && !Array.isArray(o.enabledPlugins) ? o.enabledPlugins : {});
3132
+ const off = Object.keys({ ...ep(prev), ...ep(now) }).filter((k) => /decidio/i.test(k) && (ep(now)[k] === false || (ep(prev)[k] === true && ep(now)[k] !== true)));
3133
+ if (off.length)
3134
+ changes.push(`turns the Decidio plugin off (enabledPlugins ${off.join(", ")})`);
3135
+ }
3136
+ return changes.length ? { abs, display, changes, sha: sha(after) } : null;
3137
+ }
3138
+ const HOOK_MODES = ["default", "acceptEdits", "plan", "auto", "dontAsk", "bypassPermissions"];
3139
+ function hookRepoName(url) {
3140
+ const u = url.trim();
3141
+ let host, path;
3142
+ const scheme = u.match(/^[a-z][a-z0-9+.-]*:\/\/(.*)$/i);
3143
+ if (scheme) {
3144
+ const rest = scheme[1];
3145
+ const slash = rest.indexOf("/");
3146
+ if (slash < 0)
3147
+ return null;
3148
+ const auth = rest.slice(0, slash);
3149
+ host = auth.slice(auth.lastIndexOf("@") + 1).replace(/:\d*$/, "");
3150
+ path = rest.slice(slash + 1);
3151
+ }
3152
+ else {
3153
+ const scp = u.match(/^(?:[^@/]+@)?([^:/\\]+):(?!\/\/)(.+)$/);
3154
+ if (!scp || /^[a-z]:[\\/]/i.test(u))
3155
+ return null;
3156
+ host = scp[1];
3157
+ path = scp[2];
3158
+ }
3159
+ path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
3160
+ if (!host || !path)
3161
+ return null;
3162
+ host = host.toLowerCase();
3163
+ return hookRedact(host === "github.com" ? path : `${host}/${path}`).slice(0, 200);
3164
+ }
3165
+ function hookCardGit(segs, at) {
3166
+ if (segs?.some((s) => ["cd", "pushd", "popd", "chdir", "set-location", "sl", "push-location", "pop-location"].includes(s.program) || (s.program === "git" && hookGitElsewhere(s, at.cwd))))
3167
+ return {};
3168
+ const g = hookGitRead(at.cwd);
3169
+ const repo = g.origin ? hookRepoName(g.origin) : null;
3170
+ return { ...(repo ? { repo } : {}), ...(g.branch && g.branch !== "HEAD" ? { branch: hookRedact(g.branch).slice(0, 200) } : {}), ...(g.head ? { commit: g.head.slice(0, 7) } : {}) };
3171
+ }
3172
+ const HOOK_ASK_TAIL = 512 * 1024;
3173
+ function hookAsked(transcriptPath) {
3174
+ if (process.env.DECIDIO_HOOK_SHARE_PROMPT === "0" || typeof transcriptPath !== "string" || !transcriptPath)
3175
+ return null;
3176
+ try {
3177
+ let text;
3178
+ const fd = openSync(transcriptPath, "r");
3179
+ try {
3180
+ const size = fstatSync(fd).size, start = Math.max(0, size - HOOK_ASK_TAIL);
3181
+ const buf = Buffer.alloc(size - start);
3182
+ let got = 0;
3183
+ while (got < buf.length) {
3184
+ const n = readSync(fd, buf, got, buf.length - got, start + got);
3185
+ if (!n)
3186
+ break;
3187
+ got += n;
3188
+ }
3189
+ text = buf.subarray(0, got).toString("utf8");
3190
+ if (start > 0)
3191
+ text = text.slice(text.indexOf("\n") + 1);
3192
+ }
3193
+ finally {
3194
+ closeSync(fd);
3195
+ }
3196
+ const notPrompt = /^\s*<(task-notification|bash-input|bash-stdout|bash-stderr|local-command-stdout|local-command-stderr)>/;
3197
+ const prompts = [];
3198
+ for (const line of text.split("\n")) {
3199
+ if (!line.includes('"last-prompt"') && !line.includes('"external"'))
3200
+ continue;
3201
+ let o;
3202
+ try {
3203
+ o = JSON.parse(line);
3204
+ }
3205
+ catch {
3206
+ continue;
3207
+ }
3208
+ if (o?.type === "last-prompt" && typeof o.lastPrompt === "string" && o.lastPrompt.trim()) {
3209
+ if (!notPrompt.test(o.lastPrompt))
3210
+ prompts.push(o.lastPrompt);
3211
+ }
3212
+ else if (o?.type === "user" && o.userType === "external" && !o.isMeta && !o.isSidechain && !o.isCompactSummary && !o.isVisibleInTranscriptOnly) {
3213
+ const c = o.message?.content;
3214
+ const said = typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n") : "";
3215
+ if (said.trim() && !notPrompt.test(said))
3216
+ prompts.push(said);
3217
+ }
3218
+ }
3219
+ const asked = prompts[prompts.length - 1];
3220
+ if (asked === undefined)
3221
+ return null;
3222
+ let before;
3223
+ if (asked.length < 40)
3224
+ for (let i = prompts.length - 2; i >= 0; i--)
3225
+ if (prompts[i] !== asked) {
3226
+ before = prompts[i];
3227
+ break;
3228
+ }
3229
+ const cut = (s) => { const r = hookRedact(s); const c = r.slice(0, 1000); return /[\uD800-\uDBFF]$/.test(c) ? c.slice(0, -1) : c; };
3230
+ return { asked: cut(asked), ...(before !== undefined ? { askedBefore: cut(before) } : {}), askedLength: asked.length };
3231
+ }
3232
+ catch {
3233
+ return null;
3234
+ }
3235
+ }
2547
3236
  async function readHookStdin() {
2548
3237
  if (process.stdin.isTTY)
2549
3238
  return { ok: false, why: "no hook input on stdin - this command is run BY Claude Code with the tool call as JSON on stdin (set it up with: npx @decidio/sdk hook install)" };
@@ -2694,51 +3383,81 @@ async function cmdHookPretool() {
2694
3383
  return deny(`Decidio hook: ${read.why} - refusing this tool call (fail closed).`);
2695
3384
  const input = read.input;
2696
3385
  const tool = typeof input.tool_name === "string" ? input.tool_name : "";
2697
- const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
2698
- if (!["Bash", "PowerShell"].includes(tool) || !command.trim())
2699
- return 0;
2700
- let rule;
2701
- try {
2702
- rule = hookMatch(command);
2703
- }
2704
- catch (e) {
2705
- return deny(`Decidio hook: ${e?.message ?? e} - every command is refused until it is fixed.`);
3386
+ const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
3387
+ const cwdAbs = resolvePath(typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd());
3388
+ const at = { cwd: cwdAbs, projectDir, home: homedir() };
3389
+ let g;
3390
+ if (tool === "Bash" || tool === "PowerShell") {
3391
+ const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
3392
+ if (!command.trim())
3393
+ return 0;
3394
+ if (hookSelfDecides(command))
3395
+ return deny("Decidio: an agent can't decide an approval - `approvals approve|reject` is the person's, and the person decides on their phone, the web app, Slack or Teams. Nothing ran. Tell the user which decision is waiting; never try another way to decide it.", "Decidio: the agent tried to decide an approval - refused");
3396
+ const segs = hookSegments(command);
3397
+ let rule, followed = null;
3398
+ try {
3399
+ rule = hookMatch(command, at);
3400
+ if (!rule) {
3401
+ followed = hookFollow(segs, at);
3402
+ rule = followed?.rule ?? null;
3403
+ }
3404
+ }
3405
+ catch (e) {
3406
+ return deny(`Decidio hook: ${e?.message ?? e} - every command is refused until it is fixed.`);
3407
+ }
3408
+ if (!rule)
3409
+ return 0;
3410
+ const canonical = command.replace(/\s+/g, " ").trim();
3411
+ const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
3412
+ const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
3413
+ g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs,
3414
+ ...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
3415
+ }
3416
+ else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
3417
+ const fc = hookFileGuard(tool, input.tool_input, at);
3418
+ if (!fc)
3419
+ return 0;
3420
+ const what = fc.changes.join("; ");
3421
+ g = { category: "decidio-guard", isFile: true, text: `${tool} ${fc.display} — ${what}`, binding: `${tool}\n${fc.abs}\n${fc.sha}`, subject: { fingerprint: fc.sha, display: `${fc.display}: ${what}` }, identity: { path: fc.abs, after: fc.sha }, segs: null };
2706
3422
  }
2707
- if (!rule)
3423
+ else
2708
3424
  return 0;
2709
- const short = hookShort(hookRedact(command.replace(/\s+/g, " ").trim()));
2710
- const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
3425
+ const w = g.isFile
3426
+ ? { noun: "edit", run: "make", ran: "Nothing was written.", anew: "If the user wants a different change to this file, that is a new request and may be asked." }
3427
+ : { noun: "command", run: "run", ran: "Nothing ran.", anew: "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." };
3428
+ const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
2711
3429
  const cfg = hookConfig();
2712
3430
  if ("missing" in cfg)
2713
- return deny(`Decidio: "${short}" is a ${rule.category} command that needs a person's approval, but this project has no Decidio agent credentials (${cfg.missing}). Nothing ran. Ask the user to run \`npx @decidio/sdk init\` in ${projectDir}, then retry the same command.`, `Decidio: no agent credentials in ${projectDir} - run npx @decidio/sdk init`);
2714
- const canonical = command.replace(/\s+/g, " ").trim();
2715
- const cwdAbs = resolvePath(typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd());
3431
+ return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project has no Decidio agent credentials (${cfg.missing}). ${w.ran} Ask the user to run \`npx @decidio/sdk init\` in ${projectDir}, then retry the same ${w.noun}.`, `Decidio: no agent credentials in ${projectDir} - run npx @decidio/sdk init`);
2716
3432
  const rel = relative(projectDir, cwdAbs);
2717
3433
  const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
2718
3434
  const session = typeof input.session_id === "string" ? input.session_id : "";
2719
- const subject = hookSubject(rule.category, hookSegments(command), cwdAbs, projectDir);
2720
3435
  let material;
2721
3436
  try {
2722
3437
  material = bindingMaterial(cfg);
2723
3438
  }
2724
3439
  catch (e) {
2725
- return deny(`Decidio hook: this command cannot be bound to an approval (${e?.message ?? e}) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3440
+ return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (${e?.message ?? e}) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2726
3441
  }
2727
3442
  if (!material)
2728
- return deny(`Decidio hook: this command cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2729
- const ctx = { action: "shellExec", amount: 0, scope: boundScope(material, canonical + "\n" + (subject.fingerprint ?? "")),
2730
- context: { command: hookRedact(canonical).slice(0, 400), cwd: cwdRel.slice(0, 200), category: rule.category, host: "claude-code", tool, subject: hookRedact(subject.display).slice(0, 200) } };
2731
- const request = adapterRequest(ctx);
3443
+ return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3444
+ const committed = { command: hookRedact(g.text).slice(0, 400), cwd: cwdRel.slice(0, 200), category: g.category, host: "claude-code", tool, subject: hookRedact(g.subject.display).slice(0, 200), ...(g.scriptLine ? { scriptLine: g.scriptLine } : {}) };
3445
+ const card = {
3446
+ ...(typeof input.permission_mode === "string" && HOOK_MODES.includes(input.permission_mode) ? { mode: input.permission_mode } : {}),
3447
+ ...hookCardGit(g.segs, at), ...(hookAsked(input.transcript_path) ?? {}),
3448
+ };
3449
+ const ctx = { action: "shellExec", amount: 0, scope: boundScope(material, g.binding), context: { ...committed, ...card } };
3450
+ const request = adapterRequest({ ...ctx, context: committed });
2732
3451
  let key;
2733
3452
  try {
2734
- key = idempotencyKey(material, { host: "claude-code", tool, command: canonical, cwd: cwdRel, session, subject: subject.fingerprint ?? "" });
3453
+ key = idempotencyKey(material, { host: "claude-code", tool, ...(g.isFile ? {} : { cwd: cwdRel }), session, ...g.identity });
2735
3454
  }
2736
3455
  catch (e) {
2737
- return deny(`Decidio hook: this command cannot be bound to an approval (${e?.message ?? e}) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3456
+ return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (${e?.message ?? e}) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2738
3457
  }
2739
3458
  let auth;
2740
3459
  try {
2741
- let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key });
3460
+ let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, request });
2742
3461
  if (a.decision === "duplicate") {
2743
3462
  const twin = a.duplicateOf;
2744
3463
  const label = twin.displayNumber ?? twin.decisionId;
@@ -2746,38 +3465,39 @@ async function cmdHookPretool() {
2746
3465
  const why = twin.reason ? ` ("${twin.reason}")` : "";
2747
3466
  const stored = readJustification(twin.decisionId);
2748
3467
  if (!stored) {
2749
- return deny(`Decidio: "${short}" looks like a duplicate of ${label}, which a person refused${when}${why}. Nothing ran. If this change is different, ask the user what changed, then run \`npx @decidio/sdk hook justify ${twin.decisionId} "<their words>"\` and retry EXACTLY the same command; the approver will see their justification beside the earlier refusal. A different commit or path is a new request on its own.`, `Decidio: ${label} was refused${when} - a justification is needed to ask again`);
3468
+ return deny(`Decidio: "${short}" looks like a duplicate of ${label}, which a person refused${when}${why}. ${w.ran} If this change is different, ask the user what changed, then run \`npx @decidio/sdk hook justify ${twin.decisionId} "<their words>"\` and retry EXACTLY the same ${w.noun}; the approver will see their justification beside the earlier refusal. A different commit or path is a new request on its own.`, `Decidio: ${label} was refused${when} - a justification is needed to ask again`);
2750
3469
  }
2751
- a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, justification: stored });
3470
+ a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, request, justification: stored });
2752
3471
  if (a.decision === "duplicate")
2753
- return deny(`Decidio: "${short}" is still a duplicate of ${label} and the stored justification was not accepted (${a.duplicateOf.reason ?? "no reason given"}). Nothing ran.`);
3472
+ return deny(`Decidio: "${short}" is still a duplicate of ${label} and the stored justification was not accepted (${a.duplicateOf.reason ?? "no reason given"}). ${w.ran}`);
2754
3473
  clearJustification(twin.decisionId);
2755
3474
  }
2756
3475
  auth = a;
2757
3476
  }
2758
3477
  catch (e) {
2759
- return deny(`Decidio hook: the approval gate could not be asked about "${short}" (${hookErr(e)}) - refusing to run it (fail closed). Nothing ran. ${e instanceof DecidioRateLimitError ? "The workspace's governed-action allowance for this hour is used up; try later." : `Check the network and ${projectDir}/.env (\`npx @decidio/sdk doctor\`), then retry the same command.`}`);
3478
+ return deny(`Decidio hook: the approval gate could not be asked about "${short}" (${hookErr(e)}) - refusing to ${w.run} it (fail closed). ${w.ran} ${e instanceof DecidioRateLimitError ? "The workspace's governed-action allowance for this hour is used up; try later." : `Check the network and ${projectDir}/.env (\`npx @decidio/sdk doctor\`), then retry the same ${w.noun}.`}`);
2760
3479
  }
2761
3480
  const where = decisionUrl(cfg.appUrl, auth.decisionId);
2762
3481
  if (auth.decision === "block")
2763
- return deny(`Decidio BLOCKED "${short}" by policy (decision ${auth.decisionId}${auth.reason ? `: ${auth.reason}` : ""}). Do not run it, or a variant of it; tell the user.`, `Decidio blocked ${auth.decisionId}`);
3482
+ return deny(`Decidio BLOCKED "${short}" by policy (decision ${auth.decisionId}${auth.reason ? `: ${auth.reason}` : ""}). Do not ${w.run} it, or a variant of it; tell the user.`, `Decidio blocked ${auth.decisionId}`);
2764
3483
  if (auth.decision === "proceed") {
2765
- writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: rule.category, at: new Date().toISOString() });
2766
- return allow(`Decidio: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed). This covers this one execution only.`);
3484
+ writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
3485
+ err(`decidio hook: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed) - no output, so Claude Code's own permission check still decides; this covers this one execution only.`);
3486
+ return 0;
2767
3487
  }
2768
3488
  const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
2769
3489
  const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
2770
3490
  const st = held.status;
2771
3491
  if (!st)
2772
- return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). Nothing ran. When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same command.`);
3492
+ return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). ${w.ran} When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same ${w.noun}.`);
2773
3493
  if (st.state === "pending")
2774
- return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. Nothing ran. To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same command - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId} awaits approval${where ? ` - ${where}` : ""}`);
3494
+ return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. ${w.ran} To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same ${w.noun} - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId} awaits approval${where ? ` - ${where}` : ""}`);
2775
3495
  if (st.state === "rejected" || st.state === "blocked")
2776
- return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(subject.display)}). Do not retry it or a variant of it; tell the user. ${subject.fingerprint ? "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
3496
+ return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(g.subject.display)}). Do not retry it or a variant of it; tell the user. ${g.subject.fingerprint ? w.anew : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
2777
3497
  const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
2778
3498
  if (!chk.ok)
2779
- return deny(`Decidio: "${short}" is NOT cleared to run - ${chk.reason ?? chk.code} (decision ${auth.decisionId}). Nothing ran.${chk.code === "spent" || chk.code === "expired" ? " Retry the same command to ask for a fresh approval." : ""}`);
2780
- writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: st.receiptId ?? auth.receiptId ?? null, tool, category: rule.category, at: new Date().toISOString() });
3499
+ return deny(`Decidio: "${short}" is NOT cleared to ${w.run} - ${chk.reason ?? chk.code} (decision ${auth.decisionId}). ${w.ran}${chk.code === "spent" || chk.code === "expired" ? ` Retry the same ${w.noun} to ask for a fresh approval.` : ""}`);
3500
+ writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: st.receiptId ?? auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
2781
3501
  return allow(`Decidio: "${short}" is approved (decision ${auth.decisionId}${st.receiptId ?? auth.receiptId ? `, receipt ${st.receiptId ?? auth.receiptId}` : ""}). This approval covers this one execution only; its record is sealed.`);
2782
3502
  }
2783
3503
  catch (e) {
@@ -2920,6 +3640,14 @@ function cmdHookRules() {
2920
3640
  log(`Add your own: DECIDIO_HOOK_EXTRA_RULES=<file.json> with [{ "category": "deploy", "pattern": "^make deploy\\\\b", "summary": "make deploy" }] - each a regex over the whole command.`);
2921
3641
  log(`The list is a client-side filter; your workspace's policy decides each request the hook sends. Hold: DECIDIO_HOOK_HOLD_MS (90000; capped at ${HOOK_HOLD_CAP_MS}, below the hook's 150 s timeout).`);
2922
3642
  log(`Each request is bound to its SUBJECT as well as its text: a push to the branch and commit it would push, a delete to the paths, a statement to its database - so a refusal means "not this", and a new commit or a different path is a new request.`);
3643
+ log(`A file the command runs is followed ONE level - inside the project only (its real path: a symlink out of it is not followed), text, at most ${HOOK_FOLLOW_MAX / 1024} KB - and an edited file is a new request:`);
3644
+ log(` shell scripts bash|sh|zsh|dash|ksh <f>, source <f>, . <f>, ./<f> - every line through the list above`);
3645
+ log(` SQL files psql -f <f>, <database client> ... < <f>, sqlite3 <db> ".read <f>" - DROP, TRUNCATE, DELETE FROM`);
3646
+ log(` package.json npm run <x>, npm test|start, pnpm [run] <x>, yarn [run] <x>, bun run <x> - the script's text through the list above`);
3647
+ log(` program files python <f>.py, node|deno|bun|tsx|ts-node <f>.js|mjs|cjs|ts, ruby <f>.rb run directly - DROP and TRUNCATE only, never DELETE FROM`);
3648
+ log(` Not followed: a file outside the project or a symlink out of it, a file that calls another file, binaries, files over ${HOOK_FOLLOW_MAX / 1024} KB, SQL built at run time, library calls (drop_all, dropDatabase). The file never leaves this machine: the request carries the matched line.`);
3649
+ log(`decidio-guard on Claude Code's Write, Edit and MultiEdit (.claude/settings*.json, ~/.claude/settings*.json, the Decidio plugin's files): asked only when the file after the change removes or alters a Decidio hook, sets disableAllHooks, turns the Decidio plugin off, touches the plugin's files or is not valid JSON - adding a permission or another hook is not asked. A SHELL write to those files is asked about even when it only adds a permission: its effect can't be computed.`);
3650
+ log(`A policy that auto-approves a request gets no output from the hook, so Claude Code's own permission check still decides; only a person's approval of the exact call answers "allow".`);
2923
3651
  return 0;
2924
3652
  }
2925
3653
  function hookRunner() {
@@ -2940,6 +3668,8 @@ function hookRunner() {
2940
3668
  return `npx tsx ${q(real)}`;
2941
3669
  return `node ${q(real)}`;
2942
3670
  }
3671
+ const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
3672
+ const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
2943
3673
  async function cmdHookInstall(opts) {
2944
3674
  const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
2945
3675
  let settings = {};
@@ -2963,23 +3693,42 @@ async function cmdHookInstall(opts) {
2963
3693
  const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
2964
3694
  const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
2965
3695
  const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
2966
- const place = (event, command, extra) => {
3696
+ const place = (event, matcher, iff, command, extra) => {
2967
3697
  const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
2968
- const entry = { matcher: "Bash|PowerShell", hooks: [{ type: "command", command, ...extra }] };
2969
- const i = list.findIndex((e) => Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command)));
2970
- if (i >= 0) {
2971
- const same = JSON.stringify(list[i]) === JSON.stringify(entry);
2972
- list[i] = entry;
2973
- return same ? "kept" : "updated";
2974
- }
2975
- list.push(entry);
2976
- return "added";
3698
+ const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
3699
+ let state = "added";
3700
+ for (const g of list) {
3701
+ if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
3702
+ continue;
3703
+ g.hooks = g.hooks.flatMap((h) => {
3704
+ if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
3705
+ return [h];
3706
+ if (state !== "added") {
3707
+ state = "updated";
3708
+ return [];
3709
+ }
3710
+ state = JSON.stringify(h) === JSON.stringify(want) ? "kept" : "updated";
3711
+ return [want];
3712
+ });
3713
+ }
3714
+ hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
3715
+ if (state === "added")
3716
+ hooks[event].push({ matcher, hooks: [want] });
3717
+ return state;
2977
3718
  };
2978
- const pre = place("PreToolUse", `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, { timeout: 150, onFailure: "block" });
2979
- const post = place("PostToolUse", `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`, { timeout: 30 });
3719
+ const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
3720
+ const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
3721
+ const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
3722
+ const guard = { added: 0, updated: 0, kept: 0 };
3723
+ for (const t of HOOK_FILE_TOOLS)
3724
+ for (const p of HOOK_GUARD_PATHS) {
3725
+ guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
3726
+ guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
3727
+ }
2980
3728
  mkdirSync(".claude", { recursive: true });
2981
3729
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2982
3730
  log(`✔ ${settingsPath.split(pathSep).join("/")}: PreToolUse hook ${pre}, PostToolUse hook ${post} (matcher Bash|PowerShell${opts.project ? "; this file is shared with everyone who clones the project" : "; this file is yours alone - Claude Code keeps it out of git"}).`);
3731
+ log(`✔ the guard on Claude Code's own settings - ${HOOK_FILE_TOOLS.join(", ")} on ${HOOK_GUARD_PATHS.join(", ")}, PreToolUse and PostToolUse: ${Object.entries(guard).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ")}.`);
2983
3732
  log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
2984
3733
  const env = readEnvFile();
2985
3734
  const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
@@ -3667,6 +4416,10 @@ async function cmdGithubRequest(environment, justification) {
3667
4416
  log(" An approval request for this commit was already open - that one stands.");
3668
4417
  return 0;
3669
4418
  }
4419
+ if (r.status === 200 && b.decision === "connected") {
4420
+ log(`✔ connected: Decidio heard this repository ask for ${environment}. This check asked for nothing; the next push asks a teammate.`);
4421
+ return 0;
4422
+ }
3670
4423
  if (r.status === 200 && b.decision === "duplicate") {
3671
4424
  err(`✘ ${String(b.error ?? "a person refused this commit before")}`);
3672
4425
  err(` To ask again, say what changed: npx @decidio/sdk github request --environment ${environment} --justification "<what changed>"`);
@@ -3679,6 +4432,303 @@ async function cmdGithubRequest(environment, justification) {
3679
4432
  err(`✘ Decidio refused the request (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
3680
4433
  return 1;
3681
4434
  }
4435
+ const DEPLOY_MARK = { deployed: "✔", refused: "✘", blocked: "✘", stopped: "✘", failed: "✘", checks_failed: "✘", checks_unreadable: "✘", superseded: "·", expired: "·", silent: "·" };
4436
+ export function deployLines(r) {
4437
+ const mark = DEPLOY_MARK[r.state] ?? "…";
4438
+ const when = (r.deployedAt ?? r.requestedAt).slice(0, 16).replace("T", " ");
4439
+ const head = `${mark} ${(r.dNumber ?? r.decisionId.slice(-6)).padEnd(8)} ${r.environment.padEnd(12)} ${r.sha.slice(0, 7)} ${r.ref ? `(${r.ref}) ` : ""}${r.line} ${when} UTC`;
4440
+ const out = [head];
4441
+ if (r.checks && r.state !== "deployed")
4442
+ out.push(` ${r.checks}`);
4443
+ if (r.range)
4444
+ out.push(r.range.fromSha ? ` changed since ${r.range.fromSha.slice(0, 7)}${r.range.compareUrl ? `: ${r.range.compareUrl}` : ""} (rollback target: ${r.range.fromSha.slice(0, 7)})` : " the first deploy through Decidio for this environment");
4445
+ return out;
4446
+ }
4447
+ export function deployChanges(before, after) {
4448
+ const was = new Map(before.map((r) => [r.decisionId, r]));
4449
+ const out = [];
4450
+ for (const r of [...after].reverse()) {
4451
+ const p = was.get(r.decisionId);
4452
+ const name = `${r.dNumber ?? r.decisionId.slice(-6)} ${r.environment} ${r.sha.slice(0, 7)}`;
4453
+ if (!p)
4454
+ out.push(`+ ${name}: ${r.line}`);
4455
+ else if (p.line !== r.line)
4456
+ out.push(`→ ${name}: ${p.line} → ${r.line}`);
4457
+ }
4458
+ return out;
4459
+ }
4460
+ async function cmdDeploys(ref, o) {
4461
+ if (ref && !/^(D[·.-]?\d{4,}|c[a-z0-9]{20,40})$/i.test(ref)) {
4462
+ err(`✘ "${ref}" is not a deploy's record id (D·0042) or decision id.`);
4463
+ return 2;
4464
+ }
4465
+ if (o.repo && !/^[\w.-]+\/[\w.-]+$/.test(o.repo)) {
4466
+ err(`✘ --repo takes owner/name.`);
4467
+ return 2;
4468
+ }
4469
+ if (o.limit && !/^\d{1,2}$/.test(o.limit)) {
4470
+ err(`✘ --limit takes a number from 1 to 50.`);
4471
+ return 2;
4472
+ }
4473
+ if (ref && (o.watch || o.environment || o.repo || o.limit)) {
4474
+ err(`✘ one deploy (${ref}) takes no --watch, --environment, --repo or --limit - refusing to ignore a flag you meant to matter.`);
4475
+ return 2;
4476
+ }
4477
+ if (o.watch && o.json) {
4478
+ err(`✘ --watch and --json do not go together (--json is a single read, for a script).`);
4479
+ return 2;
4480
+ }
4481
+ const qs = new URLSearchParams();
4482
+ if (o.environment)
4483
+ qs.set("environment", o.environment);
4484
+ if (o.repo)
4485
+ qs.set("repository", o.repo);
4486
+ if (o.limit)
4487
+ qs.set("limit", o.limit);
4488
+ const path = ref ? `/api/github/deploys/${encodeURIComponent(ref)}` : `/api/github/deploys${qs.size ? `?${qs}` : ""}`;
4489
+ let bearer;
4490
+ let r = await api(path);
4491
+ if (r.status === 401 || r.status === 403) {
4492
+ const session = await acquireSession("Reading deploys");
4493
+ if (!session)
4494
+ return 1;
4495
+ bearer = session;
4496
+ r = await api(path, {}, bearer);
4497
+ }
4498
+ if (r.status === 404) {
4499
+ err(`✘ ${ref ? `no deploy "${ref}" you can read in this workspace` : "GitHub deploy approvals are not set up here"}.`);
4500
+ return 1;
4501
+ }
4502
+ if (r.status !== 200) {
4503
+ err(`✘ could not read deploys (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4504
+ return 1;
4505
+ }
4506
+ if (ref) {
4507
+ const d = r.body?.deploy;
4508
+ if (o.json) {
4509
+ log(JSON.stringify(d, null, 2));
4510
+ return 0;
4511
+ }
4512
+ for (const l of deployLines(d))
4513
+ log(l);
4514
+ const s = d?.shipping;
4515
+ if (s) {
4516
+ log("");
4517
+ if (s.line)
4518
+ log(` ${s.line}`);
4519
+ for (const p of s.pulls ?? [])
4520
+ log(` #${p.number} ${s.redacted ? "pull request" : p.title}${p.author ? ` - ${p.author}` : ""} · ${p.approvedBy?.length ? `approved by ${p.approvedBy.join(", ")}` : p.approvals ? `approved on GitHub (${p.approvals})` : "no approving review on GitHub"}${p.risky?.length ? ` · ${p.risky.join(", ")}` : ""}`);
4521
+ if (s.redacted)
4522
+ log(dim(" titles and names are shown to the people on this request"));
4523
+ if (s.directPushes)
4524
+ log(` ${s.directPushes} direct push${s.directPushes === 1 ? "" : "es"} with no pull request - files not read`);
4525
+ }
4526
+ log(dim(` ${link(d.recordUrl)}${d.runUrl ? ` · run: ${link(d.runUrl)}` : ""}`));
4527
+ return 0;
4528
+ }
4529
+ let rows = Array.isArray(r.body?.deploys) ? r.body.deploys : [];
4530
+ if (o.json) {
4531
+ log(JSON.stringify(rows, null, 2));
4532
+ return 0;
4533
+ }
4534
+ if (!rows.length)
4535
+ log("No deploy requests you can read yet.");
4536
+ for (const row of rows)
4537
+ for (const l of deployLines(row))
4538
+ log(l);
4539
+ if (!o.watch)
4540
+ return 0;
4541
+ log(dim("\nWatching for changes every 10 s - Ctrl+C to stop."));
4542
+ for (;;) {
4543
+ await new Promise((res) => setTimeout(res, 10_000));
4544
+ const next = await api(path, {}, bearer, true);
4545
+ if (next.status !== 200 || !Array.isArray(next.body?.deploys))
4546
+ continue;
4547
+ for (const l of deployChanges(rows, next.body.deploys))
4548
+ log(`${new Date().toISOString().slice(11, 19)} ${l}`);
4549
+ rows = next.body.deploys;
4550
+ }
4551
+ }
4552
+ export function parseWhen(s, now = new Date()) {
4553
+ if (!s)
4554
+ return null;
4555
+ if (s === "now")
4556
+ return now;
4557
+ const m = /^(\d{1,4})([mhd])$/.exec(s);
4558
+ if (m)
4559
+ return new Date(now.getTime() + Number(m[1]) * (m[2] === "m" ? 60_000 : m[2] === "h" ? 3_600_000 : 86_400_000));
4560
+ return /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s)) ? new Date(s) : null;
4561
+ }
4562
+ export function controlsLines(b) {
4563
+ const t = (iso) => (iso ? iso.slice(0, 16).replace("T", " ") + " UTC" : "until lifted");
4564
+ const out = [];
4565
+ for (const h of b.halts ?? [])
4566
+ out.push(`${h.inForce ? "■" : "□"} ${h.kind.padEnd(6)} ${h.id} ${h.scope.join(", ")} ${h.inForce ? "in force" : `from ${t(h.startsAt)}`} - ${t(h.endsAt)} (${h.reasonWords ?? h.reason})`);
4567
+ for (const g of b.grants ?? [])
4568
+ out.push(`${g.state === "active" ? "▲" : "△"} grant ${g.id} ${g.scope.join(", ")} ${g.state === "active" ? `active until ${t(g.endsAt)}` : `asked for (${g.minutes} min) - ${g.dNumber ?? g.decisionId}`}`);
4569
+ if (!out.length)
4570
+ out.push("No halts, freezes or firefighter grants in force or scheduled.");
4571
+ return out;
4572
+ }
4573
+ async function sessionCall(purpose, path, init = {}) {
4574
+ let r = await api(path, init);
4575
+ if (r.status === 401 || (r.status === 403 && r.body?.code === "SIGN_IN")) {
4576
+ const session = await acquireSession(purpose);
4577
+ if (!session)
4578
+ return null;
4579
+ r = await api(path, init, session);
4580
+ }
4581
+ return r;
4582
+ }
4583
+ const scopeOf = (v) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : null);
4584
+ async function cmdControls(json) {
4585
+ const r = await sessionCall("Reading controls", "/api/controls");
4586
+ if (!r)
4587
+ return 1;
4588
+ if (r.status !== 200) {
4589
+ err(`✘ could not read controls (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4590
+ return 1;
4591
+ }
4592
+ if (json) {
4593
+ log(JSON.stringify(r.body, null, 2));
4594
+ return 0;
4595
+ }
4596
+ for (const l of controlsLines(r.body))
4597
+ log(l);
4598
+ return 0;
4599
+ }
4600
+ async function cmdHalt(kind, o) {
4601
+ const scope = scopeOf(o.scope);
4602
+ if (!scope) {
4603
+ err(`✘ ${kind} needs --scope (owner/repo, owner/repo:environment, agent:<id>, deploys or *; comma-separated).`);
4604
+ return 2;
4605
+ }
4606
+ if (!o.reason) {
4607
+ err(`✘ ${kind} needs --reason (incident, release_freeze, maintenance, security, audit or other).`);
4608
+ return 2;
4609
+ }
4610
+ const now = new Date();
4611
+ const from = kind === "freeze" ? parseWhen(o.from ?? "now", now) : null, until = parseWhen(o.until, now);
4612
+ if ((kind === "freeze" && (!from || !until)) || (o.until && !until)) {
4613
+ err(`✘ --from and --until take an ISO date, now, or a duration like 30m, 2h or 1d${kind === "freeze" ? " (a freeze needs both)" : ""}.`);
4614
+ return 2;
4615
+ }
4616
+ const r = await sessionCall(`Setting a ${kind}`, "/api/controls/halts", { method: "POST", body: JSON.stringify({ kind, scope, reason: o.reason, startsAt: from?.toISOString() ?? null, endsAt: until?.toISOString() ?? null }) });
4617
+ if (!r)
4618
+ return 1;
4619
+ if (r.status !== 201) {
4620
+ err(`✘ ${kind} not set (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4621
+ return 1;
4622
+ }
4623
+ log(`✔ ${kind} ${r.body.id} set: ${scope.join(", ")} - ${kind === "freeze" ? `from ${r.body.startsAt} ` : "now "}${r.body.endsAt ? `until ${r.body.endsAt}` : "until lifted"}. Lift it with: npx @decidio/sdk lift ${r.body.id}`);
4624
+ return 0;
4625
+ }
4626
+ async function cmdLift(id) {
4627
+ if (!id || !/^[a-z0-9]{10,40}$/.test(id)) {
4628
+ err("✘ lift takes a halt's or freeze's id (npx @decidio/sdk controls lists them).");
4629
+ return 2;
4630
+ }
4631
+ const r = await sessionCall("Lifting a halt", `/api/controls/halts/${encodeURIComponent(id)}/lift`, { method: "POST", body: "{}" });
4632
+ if (!r)
4633
+ return 1;
4634
+ if (r.status !== 200) {
4635
+ err(`✘ not lifted (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4636
+ return 1;
4637
+ }
4638
+ log(r.body.lifted ? `✔ ${id} lifted.` : `• ${id} was already lifted or over - nothing changed.`);
4639
+ return 0;
4640
+ }
4641
+ async function cmdFirefighter(sub, id, o) {
4642
+ if (sub === "end") {
4643
+ if (!id || !/^[a-z0-9]{10,40}$/.test(id)) {
4644
+ err("✘ firefighter end takes the grant's id.");
4645
+ return 2;
4646
+ }
4647
+ const r = await sessionCall("Ending a firefighter grant", `/api/firefighter/${encodeURIComponent(id)}/end`, { method: "POST", body: "{}" });
4648
+ if (!r)
4649
+ return 1;
4650
+ if (r.status !== 200) {
4651
+ err(`✘ not ended (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4652
+ return 1;
4653
+ }
4654
+ log(r.body.ended ? `✔ grant ${id} ended - its close-out review follows.` : `• grant ${id} was not active - nothing changed.`);
4655
+ return 0;
4656
+ }
4657
+ if (sub) {
4658
+ err(`✘ firefighter takes --scope --minutes --reason, or: firefighter end <id>.`);
4659
+ return 2;
4660
+ }
4661
+ const scope = scopeOf(o.scope), minutes = o.minutes && /^\d{1,3}$/.test(o.minutes) ? Number(o.minutes) : null;
4662
+ if (!scope || !minutes || !o.reason) {
4663
+ err("✘ firefighter needs --scope (systems), --minutes (15 to 240) and --reason (what is on fire).");
4664
+ return 2;
4665
+ }
4666
+ const r = await sessionCall("Asking for a firefighter grant", "/api/firefighter", { method: "POST", body: JSON.stringify({ scope, minutes, reason: o.reason }) });
4667
+ if (!r)
4668
+ return 1;
4669
+ if (r.status !== 201) {
4670
+ err(`✘ not asked (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4671
+ return 1;
4672
+ }
4673
+ log(`✔ firefighter grant asked for (${r.body.displayNumber ?? r.body.decisionId}): the executive role approves it - never you. Once approved it is active for ${minutes} minutes.`);
4674
+ log(dim(` ${link(r.body.url)}`));
4675
+ return 0;
4676
+ }
4677
+ function originIs(repo) {
4678
+ try {
4679
+ const url = execFileSync("git", ["remote", "get-url", "origin"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5_000 }).trim().toLowerCase().replace(/\.git$/, "");
4680
+ return url.endsWith(`/${repo.toLowerCase()}`) || url.endsWith(`:${repo.toLowerCase()}`);
4681
+ }
4682
+ catch {
4683
+ return false;
4684
+ }
4685
+ }
4686
+ async function cmdRollback(o) {
4687
+ if (!o.repo || !/^[\w.-]+\/[\w.-]+$/.test(o.repo) || !o.environment || !/^[A-Za-z0-9._-]{1,64}$/.test(o.environment)) {
4688
+ err("✘ rollback needs --repo owner/name and --environment <name>.");
4689
+ return 2;
4690
+ }
4691
+ const q = `repository=${encodeURIComponent(o.repo)}&environment=${encodeURIComponent(o.environment)}`;
4692
+ const t = await sessionCall("Asking for a rollback", `/api/github/rollback?${q}`);
4693
+ if (!t)
4694
+ return 1;
4695
+ if (t.status !== 200) {
4696
+ err(`✘ no rollback (${t.status}): ${String(t.body?.error ?? "no reason given")}`);
4697
+ return 1;
4698
+ }
4699
+ const sha = String(t.body.sha), branch = String(t.body.branch);
4700
+ if (!/^[0-9a-f]{40}$/.test(sha) || !/^decidio-rollback\/[A-Za-z0-9._-]+$/.test(branch)) {
4701
+ err("✘ the server's rollback target is not one this CLI can push.");
4702
+ return 1;
4703
+ }
4704
+ if (!o.noPush) {
4705
+ if (!originIs(o.repo)) {
4706
+ err(`✘ run this inside a checkout of ${o.repo}, or push the branch yourself and add --no-push:\n ${t.body.push}`);
4707
+ return 1;
4708
+ }
4709
+ const git = (args) => spawnSync("git", args, { stdio: ["ignore", "inherit", "inherit"], timeout: 120_000 });
4710
+ if (spawnSync("git", ["cat-file", "-e", `${sha}^{commit}`], { stdio: "ignore", timeout: 10_000 }).status !== 0)
4711
+ git(["fetch", "--quiet", "origin"]);
4712
+ const pushed = git(["push", "origin", `${sha}:refs/heads/${branch}`]);
4713
+ if (pushed.status !== 0) {
4714
+ err(`✘ git could not push ${branch} - nothing was asked. Push it yourself, then: npx @decidio/sdk rollback --repo ${o.repo} --environment ${o.environment} --no-push`);
4715
+ return 1;
4716
+ }
4717
+ log(dim(` pushed ${branch} at ${sha.slice(0, 7)}`));
4718
+ }
4719
+ const r = await sessionCall("Asking for a rollback", "/api/github/rollback", { method: "POST", body: JSON.stringify({ repository: o.repo, environment: o.environment }) });
4720
+ if (!r)
4721
+ return 1;
4722
+ if (r.status !== 201) {
4723
+ err(`✘ no rollback (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4724
+ return 1;
4725
+ }
4726
+ log(r.body.verdict === "proceed" ? `✔ rolling ${o.environment} back to ${sha.slice(0, 7)} under your firefighter grant (${r.body.displayNumber}).`
4727
+ : r.body.verdict === "block" ? `✘ the rollback was blocked (${r.body.displayNumber}) - a halt or a policy stops it.`
4728
+ : `✔ rollback of ${o.environment} to ${sha.slice(0, 7)} asked for (${r.body.displayNumber}) - it deploys from ${branch} once a teammate approves.`);
4729
+ log(dim(` ${link(r.body.url)}`));
4730
+ return r.body.verdict === "block" ? 1 : 0;
4731
+ }
3682
4732
  async function cmdGithubVerify(decisionArg) {
3683
4733
  const decision = (decisionArg ?? process.env.DECIDIO_DECISION ?? "").trim();
3684
4734
  if (!GH_DECISION_RE.test(decision)) {
@@ -3714,15 +4764,20 @@ function plainRepoPath(root, rel) {
3714
4764
  }
3715
4765
  return true;
3716
4766
  }
3717
- export const DECIDIO_ACTION = "OmniTwin/decidio-actions@e40ba8e75a223f2475038d38a16df5a5885afde4 # v1.0.0";
4767
+ export const DECIDIO_ACTION = "OmniTwin/decidio-actions@f0b43fcb8f8ac5e15d9ad43679e333ffa7038ae2 # v1.3.1";
3718
4768
  export const CHECKOUT_ACTION = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0";
3719
- export function githubRequestWorkflow(api, environment, branch = "main") {
4769
+ export function githubRequestWorkflow(api, environment, branch = "main", deployWorkflow = ".github/workflows/deploy.yml") {
3720
4770
  return `# Written by npx @decidio/sdk github init. Asks a teammate to approve each deploy; Decidio starts the deploy workflow once
3721
- # approved. No repository secret: the job's own OIDC token is the credential.
4771
+ # approved. No repository secret: the job's own OIDC token is the credential. A push that changes only the two Decidio workflow
4772
+ # files asks for nothing; a run started by hand (or by Decidio's Check the connection) is a connection check that asks for nothing.
3722
4773
  name: Ask Decidio to deploy
3723
4774
  on:
3724
4775
  push:
3725
4776
  branches: [${branch}]
4777
+ paths-ignore:
4778
+ - .github/workflows/decidio-request.yml
4779
+ - ${deployWorkflow}
4780
+ workflow_dispatch:
3726
4781
  permissions:
3727
4782
  id-token: write
3728
4783
  contents: read
@@ -3754,13 +4809,16 @@ export function githubDeployWorkflow(api, environment) {
3754
4809
  # approval (once, for this commit and environment) before anything deploys. Restrict who may run it to the Decidio app in the
3755
4810
  # repository's workflow execution protections.
3756
4811
  name: Deploy
3757
- run-name: Decidio \${{ inputs.decidio }}
4812
+ run-name: \${{ inputs.title || format('Decidio {0}', inputs.decidio) }}
3758
4813
  on:
3759
4814
  workflow_dispatch:
3760
4815
  inputs:
3761
4816
  decidio:
3762
4817
  description: Set by Decidio
3763
4818
  required: true
4819
+ title:
4820
+ description: Set by Decidio (the run's name - its record, environment and commit)
4821
+ required: false
3764
4822
  permissions:
3765
4823
  id-token: write
3766
4824
  contents: read
@@ -3843,7 +4901,7 @@ async function cmdGithubInit(opts) {
3843
4901
  };
3844
4902
  const said = (rel, w, what) => w === "written" ? log(`✔ wrote ${rel} - ${what}`) : w === "kept" ? log(`· ${rel} already exists - kept as it is`) : err(`✘ ${rel}: a symbolic link is on its path - nothing was written through it`);
3845
4903
  const reqRel = ".github/workflows/decidio-request.yml";
3846
- said(reqRel, write(reqRel, githubRequestWorkflow(apiUrl, environment, branch)), `asks for approval on every push to ${branch} (edit the trigger to suit)`);
4904
+ said(reqRel, write(reqRel, githubRequestWorkflow(apiUrl, environment, branch, workflow)), `asks for approval on every push to ${branch} (edit the trigger to suit)`);
3847
4905
  const dep = write(workflow, githubDeployWorkflow(apiUrl, environment));
3848
4906
  said(workflow, dep, "the deploy Decidio starts once approved (replace ./deploy.sh with your deploy)");
3849
4907
  if (dep === "kept") {
@@ -3874,7 +4932,19 @@ async function cmdGithubInit(opts) {
3874
4932
  log(` 1. In Decidio: Connect → GitHub approvals → bind this repository with ${workflow} and the environment ${environment}.`);
3875
4933
  log(` 2. In the repository's settings: restrict who may run ${workflow} to the Decidio app (workflow execution protections),`);
3876
4934
  log(` so a deploy runs only when Decidio starts it.`);
3877
- log(` 3. Commit the workflows and push: the first push to ${branch} asks a teammate.`);
4935
+ log(` 3. Commit the workflows and push - that push asks for nothing (it changes only these files).`);
4936
+ log(` 4. Check the connection: Check the connection in Decidio, or gh workflow run decidio-request.yml - it asks for nothing.`);
4937
+ log(` Your next push to ${branch} asks a teammate.`);
4938
+ const sameBox = apiUrl === API.replace(/\/$/, "");
4939
+ log(` 5. Approve from your phone: ${sameBox ? "" : `DECIDIO_API_URL=${apiUrl} `}npx @decidio/sdk phone ${dim("(a QR in your terminal signs it in - the Decidio lab)")}`);
4940
+ if (sameBox && process.stdin.isTTY && process.stdout.isTTY) {
4941
+ log("");
4942
+ log(` Set up your phone to approve now? ${dim("[y/n]")}`);
4943
+ if ((await ask(" › ")).trim().toLowerCase() === "y") {
4944
+ log("");
4945
+ await cmdPhone();
4946
+ }
4947
+ }
3878
4948
  return 0;
3879
4949
  }
3880
4950
  const USAGE = `✦ Decidio — the SDK command line
@@ -3916,6 +4986,12 @@ SIGN IN (once)
3916
4986
  npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
3917
4987
  until it expires — there is no server-side revocation yet)
3918
4988
 
4989
+ YOUR PHONE
4990
+ npx @decidio/sdk phone put Decidio on your phone: sign in (once), then - only on your y - a QR in this
4991
+ terminal signs the phone in (one time, 5 minutes). iPhone: the app opens, or the page
4992
+ walks you through installing it; Android: the phone's browser. Enter = a fresh QR.
4993
+ Terminal only; the Decidio lab for now (on your company's Decidio: SSO on the phone)
4994
+
3919
4995
  YOUR AGENT AND ITS TOKENS
3920
4996
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
3921
4997
  (agentId defaults to this folder's name; any name works - it is how the
@@ -4004,7 +5080,7 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
4004
5080
  in a repository's checkout: writes the workflow that asks for approval
4005
5081
  (.github/workflows/decidio-request.yml) and the deploy workflow when there is none
4006
5082
  (an existing one is never changed: init prints the steps to add), both using
4007
- Decidio's GitHub Action pinned to its v1.0.0 commit; adds a link to Decidio for agents to AGENTS.md (--agents-md creates it) -
5083
+ Decidio's GitHub Action pinned to its v1.3.1 commit; adds a link to Decidio for agents to AGENTS.md (--agents-md creates it) -
4008
5084
  never a decision's text
4009
5085
  npx @decidio/sdk github request --environment <name> [--justification "<what changed>"]
4010
5086
  inside a GitHub Actions job (permissions id-token: write): ask a teammate to approve
@@ -4013,6 +5089,28 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
4013
5089
  commit and environment, and writes sha= to $GITHUB_OUTPUT; anything else exits 1 -
4014
5090
  do not deploy (DECIDIO_DECISION carries the decidio input)
4015
5091
 
5092
+ npx @decidio/sdk deploys [D·0042] [--environment <name>] [--repo <owner/name>] [--limit N] [--watch] [--json]
5093
+ what's deployed where: the recent deploy requests you may read, each with the words its
5094
+ commit shows on GitHub; a deployed one names the previous deployed commit and a compare
5095
+ link - what that deploy changed, and the rollback target; one deploy (its D-number) adds
5096
+ the pull requests it shipped and who approved them; --watch follows changes
5097
+
5098
+ EMERGENCY CONTROLS
5099
+ npx @decidio/sdk controls [--json] the halts, freezes and firefighter grants in force or scheduled
5100
+ npx @decidio/sdk halt --scope <systems> --reason <incident|release_freeze|maintenance|security|audit|other> [--until 2h|ISO]
5101
+ stop everything in scope now (owner/repo, owner/repo:environment, agent:<id>, deploys, *)
5102
+ - the workspace owner or the executive role only; until lifted unless --until
5103
+ npx @decidio/sdk freeze --scope <systems> --reason <...> --from now|ISO --until 2d|ISO
5104
+ a change freeze for a window (a halt scheduled ahead)
5105
+ npx @decidio/sdk lift <id> lift a halt or end a freeze now
5106
+ npx @decidio/sdk firefighter --scope <systems> --minutes <15-240> --reason "<what is on fire>"
5107
+ ask for break-glass authority: the executive role approves it (never you); while active,
5108
+ your own requests to those systems go ahead and are reviewed when it ends
5109
+ npx @decidio/sdk firefighter end <id> end your grant early
5110
+ npx @decidio/sdk rollback --repo <owner/name> --environment <name> [--no-push]
5111
+ ask to deploy the environment's previous approved commit (approved like any deploy):
5112
+ run it in a checkout - your git pushes a branch at that commit, the deploy runs there
5113
+
4016
5114
  DEVELOPMENT
4017
5115
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
4018
5116
  npx @decidio/sdk --version print the CLI version
@@ -4148,6 +5246,8 @@ async function main(argv) {
4148
5246
  return pick.code;
4149
5247
  if (pick.kind === "logout")
4150
5248
  return cmdLogout();
5249
+ if (pick.kind === "phone")
5250
+ return cmdPhone();
4151
5251
  if (pick.kind === "init")
4152
5252
  return cmdInit(pick.agentId, {});
4153
5253
  return cmdQuickstart({ demo: pick.kind === "demo" });
@@ -4169,6 +5269,7 @@ async function main(argv) {
4169
5269
  quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent", "--demo"], valued: ["--phone"] },
4170
5270
  login: { flags: [] },
4171
5271
  logout: { flags: [] },
5272
+ phone: { flags: [] },
4172
5273
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
4173
5274
  doctor: { flags: [] },
4174
5275
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
@@ -4178,6 +5279,13 @@ async function main(argv) {
4178
5279
  dev: { flags: [], valued: ["--target", "--port"] },
4179
5280
  hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
4180
5281
  github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
5282
+ deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
5283
+ controls: { flags: ["--json"] },
5284
+ halt: { flags: [], valued: ["--scope", "--reason", "--until"] },
5285
+ freeze: { flags: [], valued: ["--scope", "--reason", "--from", "--until"] },
5286
+ lift: { flags: [] },
5287
+ firefighter: { flags: [], valued: ["--scope", "--minutes", "--reason"] },
5288
+ rollback: { flags: ["--no-push"], valued: ["--repo", "--environment"] },
4181
5289
  decisions: { flags: ["--dry-run"], valued: ["--adr"] },
4182
5290
  };
4183
5291
  const spec = KNOWN[cmd];
@@ -4212,7 +5320,7 @@ async function main(argv) {
4212
5320
  return 2;
4213
5321
  }
4214
5322
  }
4215
- const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
5323
+ const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
4216
5324
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
4217
5325
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
4218
5326
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
@@ -4237,6 +5345,7 @@ async function main(argv) {
4237
5345
  switch (cmd) {
4238
5346
  case "login": return cmdLogin(argsIn.find((x) => !x.startsWith("-")));
4239
5347
  case "logout": return cmdLogout();
5348
+ case "phone": return cmdPhone();
4240
5349
  case "quickstart": {
4241
5350
  if (argsIn.includes("cleanup")) {
4242
5351
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
@@ -4401,6 +5510,31 @@ async function main(argv) {
4401
5510
  return 2;
4402
5511
  }
4403
5512
  }
5513
+ case "deploys": {
5514
+ const VALUED = ["--environment", "--repo", "--limit"];
5515
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
5516
+ if (pos.length > 1) {
5517
+ err(`✘ deploys takes at most one deploy id.\n\n${USAGE}`);
5518
+ return 2;
5519
+ }
5520
+ return cmdDeploys(pos[0], { environment: valuedOpt(argsIn, "--environment"), repo: valuedOpt(argsIn, "--repo"), limit: valuedOpt(argsIn, "--limit"), watch: argsIn.includes("--watch"), json: argsIn.includes("--json") });
5521
+ }
5522
+ case "controls": return cmdControls(argsIn.includes("--json"));
5523
+ case "halt":
5524
+ case "freeze": {
5525
+ if (argsIn.some((x, i) => !x.startsWith("-") && !["--scope", "--reason", "--until", "--from"].includes(argsIn[i - 1] ?? ""))) {
5526
+ err(`✘ ${cmd} takes only flags.\n\n${USAGE}`);
5527
+ return 2;
5528
+ }
5529
+ return cmdHalt(cmd, { scope: valuedOpt(argsIn, "--scope"), reason: valuedOpt(argsIn, "--reason"), from: valuedOpt(argsIn, "--from"), until: valuedOpt(argsIn, "--until") });
5530
+ }
5531
+ case "lift": return cmdLift(argsIn.find((x) => !x.startsWith("-")));
5532
+ case "firefighter": {
5533
+ const VALUED = ["--scope", "--minutes", "--reason"];
5534
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
5535
+ return cmdFirefighter(pos[0], pos[1], { scope: valuedOpt(argsIn, "--scope"), minutes: valuedOpt(argsIn, "--minutes"), reason: valuedOpt(argsIn, "--reason") });
5536
+ }
5537
+ case "rollback": return cmdRollback({ repo: valuedOpt(argsIn, "--repo"), environment: valuedOpt(argsIn, "--environment"), noPush: argsIn.includes("--no-push") });
4404
5538
  case "decisions": {
4405
5539
  const sub = argsIn.find((x, i) => !x.startsWith("-") && argsIn[i - 1] !== "--adr");
4406
5540
  if (sub !== "import") {