@decidio/sdk 0.8.1 → 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/CHANGELOG.md +41 -0
- package/README.md +2 -1
- package/dist/cli.d.ts +33 -2
- package/dist/cli.js +1200 -77
- package/openapi.yaml +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2491
|
-
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
let
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
-
|
|
3423
|
+
else
|
|
2708
3424
|
return 0;
|
|
2709
|
-
const
|
|
2710
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
2729
|
-
const
|
|
2730
|
-
|
|
2731
|
-
|
|
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,
|
|
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
|
|
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}.
|
|
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"}).
|
|
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).
|
|
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:
|
|
2766
|
-
|
|
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"}).
|
|
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})` : ""}.
|
|
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 ?
|
|
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}).
|
|
2780
|
-
writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: st.receiptId ?? auth.receiptId ?? null, tool, category:
|
|
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
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
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
|
|
2979
|
-
const
|
|
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;
|
|
@@ -3683,6 +4432,303 @@ async function cmdGithubRequest(environment, justification) {
|
|
|
3683
4432
|
err(`✘ Decidio refused the request (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
|
|
3684
4433
|
return 1;
|
|
3685
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
|
+
}
|
|
3686
4732
|
async function cmdGithubVerify(decisionArg) {
|
|
3687
4733
|
const decision = (decisionArg ?? process.env.DECIDIO_DECISION ?? "").trim();
|
|
3688
4734
|
if (!GH_DECISION_RE.test(decision)) {
|
|
@@ -3718,7 +4764,7 @@ function plainRepoPath(root, rel) {
|
|
|
3718
4764
|
}
|
|
3719
4765
|
return true;
|
|
3720
4766
|
}
|
|
3721
|
-
export const DECIDIO_ACTION = "OmniTwin/decidio-actions@
|
|
4767
|
+
export const DECIDIO_ACTION = "OmniTwin/decidio-actions@f0b43fcb8f8ac5e15d9ad43679e333ffa7038ae2 # v1.3.1";
|
|
3722
4768
|
export const CHECKOUT_ACTION = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0";
|
|
3723
4769
|
export function githubRequestWorkflow(api, environment, branch = "main", deployWorkflow = ".github/workflows/deploy.yml") {
|
|
3724
4770
|
return `# Written by npx @decidio/sdk github init. Asks a teammate to approve each deploy; Decidio starts the deploy workflow once
|
|
@@ -3763,13 +4809,16 @@ export function githubDeployWorkflow(api, environment) {
|
|
|
3763
4809
|
# approval (once, for this commit and environment) before anything deploys. Restrict who may run it to the Decidio app in the
|
|
3764
4810
|
# repository's workflow execution protections.
|
|
3765
4811
|
name: Deploy
|
|
3766
|
-
run-name:
|
|
4812
|
+
run-name: \${{ inputs.title || format('Decidio {0}', inputs.decidio) }}
|
|
3767
4813
|
on:
|
|
3768
4814
|
workflow_dispatch:
|
|
3769
4815
|
inputs:
|
|
3770
4816
|
decidio:
|
|
3771
4817
|
description: Set by Decidio
|
|
3772
4818
|
required: true
|
|
4819
|
+
title:
|
|
4820
|
+
description: Set by Decidio (the run's name - its record, environment and commit)
|
|
4821
|
+
required: false
|
|
3773
4822
|
permissions:
|
|
3774
4823
|
id-token: write
|
|
3775
4824
|
contents: read
|
|
@@ -3886,6 +4935,16 @@ async function cmdGithubInit(opts) {
|
|
|
3886
4935
|
log(` 3. Commit the workflows and push - that push asks for nothing (it changes only these files).`);
|
|
3887
4936
|
log(` 4. Check the connection: Check the connection in Decidio, or gh workflow run decidio-request.yml - it asks for nothing.`);
|
|
3888
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
|
+
}
|
|
3889
4948
|
return 0;
|
|
3890
4949
|
}
|
|
3891
4950
|
const USAGE = `✦ Decidio — the SDK command line
|
|
@@ -3927,6 +4986,12 @@ SIGN IN (once)
|
|
|
3927
4986
|
npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
|
|
3928
4987
|
until it expires — there is no server-side revocation yet)
|
|
3929
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
|
+
|
|
3930
4995
|
YOUR AGENT AND ITS TOKENS
|
|
3931
4996
|
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
3932
4997
|
(agentId defaults to this folder's name; any name works - it is how the
|
|
@@ -4015,7 +5080,7 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
|
|
|
4015
5080
|
in a repository's checkout: writes the workflow that asks for approval
|
|
4016
5081
|
(.github/workflows/decidio-request.yml) and the deploy workflow when there is none
|
|
4017
5082
|
(an existing one is never changed: init prints the steps to add), both using
|
|
4018
|
-
Decidio's GitHub Action pinned to its v1.
|
|
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) -
|
|
4019
5084
|
never a decision's text
|
|
4020
5085
|
npx @decidio/sdk github request --environment <name> [--justification "<what changed>"]
|
|
4021
5086
|
inside a GitHub Actions job (permissions id-token: write): ask a teammate to approve
|
|
@@ -4024,6 +5089,28 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
|
|
|
4024
5089
|
commit and environment, and writes sha= to $GITHUB_OUTPUT; anything else exits 1 -
|
|
4025
5090
|
do not deploy (DECIDIO_DECISION carries the decidio input)
|
|
4026
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
|
+
|
|
4027
5114
|
DEVELOPMENT
|
|
4028
5115
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
4029
5116
|
npx @decidio/sdk --version print the CLI version
|
|
@@ -4159,6 +5246,8 @@ async function main(argv) {
|
|
|
4159
5246
|
return pick.code;
|
|
4160
5247
|
if (pick.kind === "logout")
|
|
4161
5248
|
return cmdLogout();
|
|
5249
|
+
if (pick.kind === "phone")
|
|
5250
|
+
return cmdPhone();
|
|
4162
5251
|
if (pick.kind === "init")
|
|
4163
5252
|
return cmdInit(pick.agentId, {});
|
|
4164
5253
|
return cmdQuickstart({ demo: pick.kind === "demo" });
|
|
@@ -4180,6 +5269,7 @@ async function main(argv) {
|
|
|
4180
5269
|
quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent", "--demo"], valued: ["--phone"] },
|
|
4181
5270
|
login: { flags: [] },
|
|
4182
5271
|
logout: { flags: [] },
|
|
5272
|
+
phone: { flags: [] },
|
|
4183
5273
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
4184
5274
|
doctor: { flags: [] },
|
|
4185
5275
|
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
@@ -4189,6 +5279,13 @@ async function main(argv) {
|
|
|
4189
5279
|
dev: { flags: [], valued: ["--target", "--port"] },
|
|
4190
5280
|
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
|
|
4191
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"] },
|
|
4192
5289
|
decisions: { flags: ["--dry-run"], valued: ["--adr"] },
|
|
4193
5290
|
};
|
|
4194
5291
|
const spec = KNOWN[cmd];
|
|
@@ -4223,7 +5320,7 @@ async function main(argv) {
|
|
|
4223
5320
|
return 2;
|
|
4224
5321
|
}
|
|
4225
5322
|
}
|
|
4226
|
-
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 };
|
|
4227
5324
|
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
|
|
4228
5325
|
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
4229
5326
|
err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
|
|
@@ -4248,6 +5345,7 @@ async function main(argv) {
|
|
|
4248
5345
|
switch (cmd) {
|
|
4249
5346
|
case "login": return cmdLogin(argsIn.find((x) => !x.startsWith("-")));
|
|
4250
5347
|
case "logout": return cmdLogout();
|
|
5348
|
+
case "phone": return cmdPhone();
|
|
4251
5349
|
case "quickstart": {
|
|
4252
5350
|
if (argsIn.includes("cleanup")) {
|
|
4253
5351
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
@@ -4412,6 +5510,31 @@ async function main(argv) {
|
|
|
4412
5510
|
return 2;
|
|
4413
5511
|
}
|
|
4414
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") });
|
|
4415
5538
|
case "decisions": {
|
|
4416
5539
|
const sub = argsIn.find((x, i) => !x.startsWith("-") && argsIn[i - 1] !== "--adr");
|
|
4417
5540
|
if (sub !== "import") {
|