@decidio/sdk 0.8.1 → 0.10.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 +76 -0
- package/README.md +4 -3
- package/dist/cli.d.ts +34 -2
- package/dist/cli.js +1609 -92
- package/openapi.yaml +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
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
|
-
import { homedir, tmpdir } from "node:os";
|
|
5
|
-
import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, relative, isAbsolute } from "node:path";
|
|
4
|
+
import { homedir, tmpdir, hostname } from "node:os";
|
|
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
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
|
+
import { execFileSync, spawnSync, spawn } from "node:child_process";
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
|
|
11
11
|
import { realpathSync as realPath } from "node:fs";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
|
|
13
|
+
import { generateKeypair, assertSafeTransport, redactUrl, signBoundProof } from "./signing.js";
|
|
14
14
|
import { hardenFile } from "./resume.js";
|
|
15
15
|
import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
|
|
16
16
|
import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
|
|
@@ -76,6 +76,14 @@ if (!OFFLINE_CMD)
|
|
|
76
76
|
}
|
|
77
77
|
const DEFAULT_API = "https://decidio-api.onrender.com";
|
|
78
78
|
let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
79
|
+
let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
|
|
80
|
+
if (!OFFLINE_CMD && !["init", "quickstart", "login", "logout", "phone"].includes(ARGV_TAIL[0] ?? "") && !process.env.DECIDIO_API_TOKEN && !process.env.DECIDIO_AGENT_ID) {
|
|
81
|
+
const c = loadConnection(projectRoot(), API);
|
|
82
|
+
if (c) {
|
|
83
|
+
promoteConnection(c);
|
|
84
|
+
CREDS_FROM = "connection";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
79
87
|
let DEMO = false;
|
|
80
88
|
function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
|
|
81
89
|
const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
|
|
@@ -90,7 +98,7 @@ let hostAnnounced = false;
|
|
|
90
98
|
async function api(path, init = {}, bearer, soft = false) {
|
|
91
99
|
if (!hostAnnounced) {
|
|
92
100
|
hostAnnounced = true;
|
|
93
|
-
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
101
|
+
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}${APP_URL_OVERRIDDEN ? ` · links → ${APP_URL} (DECIDIO_APP_URL)` : ""}`);
|
|
94
102
|
}
|
|
95
103
|
const auth = bearer ?? TOKEN();
|
|
96
104
|
if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
|
|
@@ -462,7 +470,24 @@ function block(n, title, rows, tone) {
|
|
|
462
470
|
}
|
|
463
471
|
log(f("└" + "─".repeat(width - 2) + "┘"));
|
|
464
472
|
}
|
|
465
|
-
const
|
|
473
|
+
const APP_URL_SHAPE = /^https?:\/\/[\x21-\x7e]+$/;
|
|
474
|
+
const asciiTrim = (v) => v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
|
|
475
|
+
export function appUrlFor(api, override = process.env.DECIDIO_APP_URL) {
|
|
476
|
+
const o = asciiTrim(override ?? "");
|
|
477
|
+
if (APP_URL_SHAPE.test(o))
|
|
478
|
+
return o.replace(/\/+$/, "");
|
|
479
|
+
const base = api.replace(/\/+$/, "");
|
|
480
|
+
const known = {
|
|
481
|
+
"https://decidio-api.onrender.com": "https://staging.decidioai.com",
|
|
482
|
+
"https://mcp-staging.decidioai.com": "https://staging.decidioai.com",
|
|
483
|
+
"https://mcp.decidioai.com": "https://app.decidioai.com",
|
|
484
|
+
};
|
|
485
|
+
return known[base] ?? base;
|
|
486
|
+
}
|
|
487
|
+
const APP_URL = appUrlFor(API);
|
|
488
|
+
const APP_URL_OVERRIDDEN = APP_URL !== appUrlFor(API, "");
|
|
489
|
+
if (asciiTrim(process.env.DECIDIO_APP_URL ?? "") && !APP_URL_SHAPE.test(asciiTrim(process.env.DECIDIO_APP_URL ?? "")))
|
|
490
|
+
console.error("⚠ ignoring DECIDIO_APP_URL: it must be a plain http(s) address (printable characters only)");
|
|
466
491
|
let stdinReadAbandoned = false;
|
|
467
492
|
let EXPLAIN = false;
|
|
468
493
|
let currentRl = null;
|
|
@@ -835,11 +860,12 @@ async function firstRunFork(signedInAs = null) {
|
|
|
835
860
|
return { kind: "stop", code: 1 };
|
|
836
861
|
}
|
|
837
862
|
log("");
|
|
838
|
-
log(` What next? ${dim("[1/2/3]")}`);
|
|
863
|
+
log(` What next? ${dim("[1/2/3/4]")}`);
|
|
839
864
|
log(` ${bold("1")} the two-minute tour against your workspace ${dim("npx @decidio/sdk quickstart")}`);
|
|
840
865
|
log(` ${bold("2")} wire up your own agent now (register, token, .env) ${dim("npx @decidio/sdk init <agentId>")}`);
|
|
841
866
|
log(` ${bold("3")} sign out of this computer ${dim("npx @decidio/sdk logout")}`);
|
|
842
|
-
|
|
867
|
+
log(` ${bold("4")} put Decidio on your phone (scan a QR here) ${dim("npx @decidio/sdk phone")}`);
|
|
868
|
+
const b = await pick("1, 2, 3 or 4", /^[1234]$/);
|
|
843
869
|
if (!b) {
|
|
844
870
|
log(` No answer — you are signed in; run any command above when you are ready.`);
|
|
845
871
|
return { kind: "stop", code: 0 };
|
|
@@ -854,6 +880,11 @@ async function firstRunFork(signedInAs = null) {
|
|
|
854
880
|
log("");
|
|
855
881
|
return { kind: "logout" };
|
|
856
882
|
}
|
|
883
|
+
if (b === "4") {
|
|
884
|
+
log(` → running: npx @decidio/sdk phone`);
|
|
885
|
+
log("");
|
|
886
|
+
return { kind: "phone" };
|
|
887
|
+
}
|
|
857
888
|
const here = readEnvFile();
|
|
858
889
|
if (["DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN"].some((k) => (here[k] ?? "").trim()) && here.DECIDIO_AGENT_ID) {
|
|
859
890
|
const tour = /^quickstart-/.test(here.DECIDIO_AGENT_ID);
|
|
@@ -888,8 +919,73 @@ async function cmdLogin(emailArg) {
|
|
|
888
919
|
return 0;
|
|
889
920
|
}
|
|
890
921
|
const token = await loginFlow("Signing in", emailArg);
|
|
922
|
+
if (token)
|
|
923
|
+
log(dim(` next: approve from your phone — npx @decidio/sdk phone`));
|
|
891
924
|
return token ? 0 : 1;
|
|
892
925
|
}
|
|
926
|
+
const PHONE_MAX_QRS = 3;
|
|
927
|
+
async function cmdPhone() {
|
|
928
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
929
|
+
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).`);
|
|
930
|
+
err(` Run it in a terminal: npx @decidio/sdk phone`);
|
|
931
|
+
return 2;
|
|
932
|
+
}
|
|
933
|
+
const session = await acquireSession("Setting up your phone");
|
|
934
|
+
if (!session)
|
|
935
|
+
return 1;
|
|
936
|
+
const who = process.env.DECIDIO_SESSION_TOKEN ? null : loadSession()?.email ?? null;
|
|
937
|
+
log("");
|
|
938
|
+
log(` Put Decidio on your phone? ${dim("[y/n]")}`);
|
|
939
|
+
log(` The QR signs that phone in${who ? ` as ${who}` : ""} - once, within 5 minutes. Whoever scans it first is signed in`);
|
|
940
|
+
log(` as you, so don't show it on a shared screen or a projector.`);
|
|
941
|
+
const yes = (await ask(" › ")).trim().toLowerCase();
|
|
942
|
+
if (yes !== "y") {
|
|
943
|
+
log(` Nothing was made. Run npx @decidio/sdk phone when you are ready.`);
|
|
944
|
+
return 0;
|
|
945
|
+
}
|
|
946
|
+
for (let n = 1; n <= PHONE_MAX_QRS; n++) {
|
|
947
|
+
const r = await api("/demo/session-link", { method: "POST", body: JSON.stringify({ setup: true }) }, session, true);
|
|
948
|
+
if (r.status === 404) {
|
|
949
|
+
log(` ✘ QR phone setup is on the Decidio lab for now. On your company's Decidio, open ${APP_URL}/phone on the phone`);
|
|
950
|
+
log(` and sign in with your company's sign-in (SSO).`);
|
|
951
|
+
return 1;
|
|
952
|
+
}
|
|
953
|
+
if (r.status === 401) {
|
|
954
|
+
log(` ✘ this computer's sign-in is no longer valid - run npx @decidio/sdk logout, then npx @decidio/sdk phone again.`);
|
|
955
|
+
return 1;
|
|
956
|
+
}
|
|
957
|
+
const link = r.status >= 200 && r.status < 300 && typeof r.body?.link === "string" ? String(r.body.link) : null;
|
|
958
|
+
if (!link) {
|
|
959
|
+
log(` ✘ no QR this time (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. Nothing was signed in.`);
|
|
960
|
+
return 1;
|
|
961
|
+
}
|
|
962
|
+
const qr = typeof r.body?.qr === "string" ? String(r.body.qr) : null;
|
|
963
|
+
log("");
|
|
964
|
+
if (qr) {
|
|
965
|
+
log(` Scan with the phone's camera ${dim("(one time · 5 minutes)")}:`);
|
|
966
|
+
for (const l of qr.split("\n"))
|
|
967
|
+
if (l.trim().length)
|
|
968
|
+
log(" " + l);
|
|
969
|
+
}
|
|
970
|
+
else
|
|
971
|
+
log(` Open this on the phone ${dim("(one time · 5 minutes - no QR came back this time)")}:`);
|
|
972
|
+
log(` ${dim(link)}`);
|
|
973
|
+
log(` ${bold("iPhone")} the camera opens the Decidio app, signed in. No app yet? The page walks you through installing it,`);
|
|
974
|
+
log(` then scan again.`);
|
|
975
|
+
log(` ${bold("Android")} opens in the phone's browser, signed in (phone alerts come with the app, iPhone only for now).`);
|
|
976
|
+
log("");
|
|
977
|
+
if (n === PHONE_MAX_QRS) {
|
|
978
|
+
log(` That's ${PHONE_MAX_QRS} - run npx @decidio/sdk phone again for more.`);
|
|
979
|
+
return 0;
|
|
980
|
+
}
|
|
981
|
+
const next = (await ask(` Enter = a fresh QR (this one stops working) · q = done: `)).trim().toLowerCase();
|
|
982
|
+
if (next !== "" || !sharedRlOpen || process.stdin.readableEnded || process.stdin.destroyed) {
|
|
983
|
+
log(` Done.`);
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return 0;
|
|
988
|
+
}
|
|
893
989
|
const TRUTH_SESSION = "The session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet";
|
|
894
990
|
function cmdLogout() {
|
|
895
991
|
if (endSession()) {
|
|
@@ -2370,14 +2466,198 @@ function hookSegments(command) {
|
|
|
2370
2466
|
while (tokens.length && tokens[0].startsWith("-"))
|
|
2371
2467
|
tokens.shift();
|
|
2372
2468
|
}
|
|
2373
|
-
const
|
|
2374
|
-
return { program, args: tokens.slice(1), raw };
|
|
2469
|
+
const bin = (tokens[0] ?? "").replace(/^["']|["']$/g, "");
|
|
2470
|
+
return { program: basename(bin).toLowerCase(), bin, args: tokens.slice(1), raw };
|
|
2375
2471
|
});
|
|
2376
2472
|
}
|
|
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
|
|
2473
|
+
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]));
|
|
2474
|
+
const HOOK_GIT_VALUED = ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"];
|
|
2475
|
+
function hookGitArgs(s) {
|
|
2476
|
+
let i = 0;
|
|
2477
|
+
while (i < s.args.length && s.args[i].startsWith("-"))
|
|
2478
|
+
i += HOOK_GIT_VALUED.includes(s.args[i]) ? 2 : 1;
|
|
2479
|
+
return s.args.slice(i);
|
|
2480
|
+
}
|
|
2481
|
+
function hookGitElsewhere(s, cwdAbs) {
|
|
2482
|
+
for (let i = 0; i < s.args.length && s.args[i].startsWith("-"); i++) {
|
|
2483
|
+
const f = s.args[i];
|
|
2484
|
+
if (f === "-C") {
|
|
2485
|
+
if (resolvePath(cwdAbs, (s.args[i + 1] ?? "").replace(/^["']|["']$/g, "")) !== resolvePath(cwdAbs))
|
|
2486
|
+
return true;
|
|
2487
|
+
i++;
|
|
2488
|
+
}
|
|
2489
|
+
else if (/^--(git-dir|work-tree)(=|$)/.test(f))
|
|
2490
|
+
return true;
|
|
2491
|
+
else if (HOOK_GIT_VALUED.includes(f))
|
|
2492
|
+
i++;
|
|
2493
|
+
}
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
const hookSub = (s, i = 0) => ((s.program === "git" ? hookGitArgs(s) : s.args).filter((a) => !a.startsWith("-"))[i] ?? "").toLowerCase();
|
|
2379
2497
|
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"];
|
|
2498
|
+
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);
|
|
2499
|
+
const hookUnq = (a) => a.replace(/^["']|["']$/g, "");
|
|
2500
|
+
const HOOK_INTERP = /^(python[0-9.]*|node|deno|bun|ruby|perl|php|tsx|ts-node)(\.exe)?$/;
|
|
2501
|
+
const hookInterp = (s) => HOOK_INTERP.test(s.program) || s.program === "npx" || (s.program === "uv" && s.args[0] === "run")
|
|
2502
|
+
|| s.program === "manage.py" || (s.program === "rails" && hookSub(s) === "runner");
|
|
2503
|
+
function hookUnwrap(s) {
|
|
2504
|
+
let args;
|
|
2505
|
+
if (s.program === "uv" && s.args[0] === "run")
|
|
2506
|
+
args = s.args.slice(1);
|
|
2507
|
+
else if (s.program === "npx")
|
|
2508
|
+
args = s.args;
|
|
2509
|
+
else
|
|
2510
|
+
return s;
|
|
2511
|
+
while (args.length && args[0].startsWith("-"))
|
|
2512
|
+
args = args.slice(1);
|
|
2513
|
+
if (!args.length)
|
|
2514
|
+
return s;
|
|
2515
|
+
const bin = hookUnq(args[0]);
|
|
2516
|
+
return { program: basename(bin).toLowerCase(), bin, args: args.slice(1), raw: s.raw };
|
|
2517
|
+
}
|
|
2518
|
+
function hookInline(s0) {
|
|
2519
|
+
const s = hookUnwrap(s0);
|
|
2520
|
+
const django = s.program === "manage.py" || (/^python[0-9.]*(\.exe)?$/.test(s.program) && hookUnq(s.args.find((a) => !a.startsWith("-")) ?? "").endsWith("manage.py"));
|
|
2521
|
+
if (django || (s.program === "rails" && hookSub(s) === "runner"))
|
|
2522
|
+
return /<</.test(s.raw) || s.args.some((a) => ["-c", "--command", "-e", "-"].includes(a));
|
|
2523
|
+
if (!HOOK_INTERP.test(s.program))
|
|
2524
|
+
return false;
|
|
2525
|
+
if (/<</.test(s.raw))
|
|
2526
|
+
return true;
|
|
2527
|
+
if (s.program.startsWith("deno") && s.args[0] === "eval")
|
|
2528
|
+
return true;
|
|
2529
|
+
for (const a of s.args) {
|
|
2530
|
+
if (a === "-")
|
|
2531
|
+
return true;
|
|
2532
|
+
if (a === "-m" || /^-(V|h|-version|-help)$/.test(a))
|
|
2533
|
+
return false;
|
|
2534
|
+
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"))
|
|
2535
|
+
return true;
|
|
2536
|
+
if (!a.startsWith("-"))
|
|
2537
|
+
return false;
|
|
2538
|
+
}
|
|
2539
|
+
return true;
|
|
2540
|
+
}
|
|
2541
|
+
function hookExpand(p, at) {
|
|
2542
|
+
const t = hookUnq(p);
|
|
2543
|
+
const m = t.match(/^(~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|\$env:HOME|\$CLAUDE_PROJECT_DIR|\$\{CLAUDE_PROJECT_DIR\}|\$PWD|\$\{PWD\})(?=$|[\\/])/i);
|
|
2544
|
+
const root = !m ? "" : /PROJECT/i.test(m[1]) ? at.projectDir : /PWD/i.test(m[1]) ? at.cwd : at.home;
|
|
2545
|
+
const out = m ? root + t.slice(m[0].length) : t;
|
|
2546
|
+
return /[$`]/.test(out) ? null : out;
|
|
2547
|
+
}
|
|
2548
|
+
function hookSegDirs(segs, at) {
|
|
2549
|
+
let dir = at.cwd;
|
|
2550
|
+
return segs.map((s) => {
|
|
2551
|
+
const here = dir;
|
|
2552
|
+
if (["cd", "pushd", "chdir", "set-location", "sl", "push-location"].includes(s.program)) {
|
|
2553
|
+
const t = s.args.filter((a) => a !== "-P" && a !== "-L" && !/^-(Path|LiteralPath)$/i.test(a))[0];
|
|
2554
|
+
const e = t === undefined ? at.home : t === "-" ? null : hookExpand(t, at);
|
|
2555
|
+
dir = dir === null || e === null ? null : resolvePath(dir, e);
|
|
2556
|
+
}
|
|
2557
|
+
else if (s.program === "popd" || s.program === "pop-location")
|
|
2558
|
+
dir = null;
|
|
2559
|
+
return here;
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
const hookNorm = (p) => resolvePath(p).split(pathSep).join("/").toLowerCase().replace(/\/+$/, "");
|
|
2563
|
+
function hookGuardKind(abs, at) {
|
|
2564
|
+
const real = (p) => { try {
|
|
2565
|
+
return realPath(p);
|
|
2566
|
+
}
|
|
2567
|
+
catch {
|
|
2568
|
+
return p;
|
|
2569
|
+
} };
|
|
2570
|
+
const paths = [...new Set([hookNorm(abs), hookNorm(joinPath(real(dirname(abs)), basename(abs)))])];
|
|
2571
|
+
const homes = [...new Set([hookNorm(at.home), hookNorm(real(at.home))])], projects = [...new Set([hookNorm(at.projectDir), hookNorm(real(at.projectDir))])];
|
|
2572
|
+
const settings = (p) => /\/\.claude\/settings[^/]*\.json$/.test(p);
|
|
2573
|
+
for (const p of paths) {
|
|
2574
|
+
for (const h of homes) {
|
|
2575
|
+
if (settings(p) && p.slice(0, p.lastIndexOf("/")) === `${h}/.claude`)
|
|
2576
|
+
return "user settings";
|
|
2577
|
+
if (p.startsWith(`${h}/.claude/plugins/`) && p.slice(h.length + 17).includes("decidio"))
|
|
2578
|
+
return "Decidio plugin";
|
|
2579
|
+
}
|
|
2580
|
+
if (settings(p) && projects.some((r) => p.startsWith(r + "/")))
|
|
2581
|
+
return "project settings";
|
|
2582
|
+
}
|
|
2583
|
+
return null;
|
|
2584
|
+
}
|
|
2585
|
+
function hookGuardDir(abs, at) {
|
|
2586
|
+
const d = hookNorm(abs), h = hookNorm(at.home);
|
|
2587
|
+
return d === `${h}/.claude` || d === `${h}/.claude/plugins` || (basename(d) === ".claude" && d.startsWith(hookNorm(at.projectDir) + "/"));
|
|
2588
|
+
}
|
|
2589
|
+
function hookShowPath(abs, at) {
|
|
2590
|
+
const inside = (root) => { const r = relative(root, abs); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : null; };
|
|
2591
|
+
const p = inside(at.projectDir);
|
|
2592
|
+
if (p !== null)
|
|
2593
|
+
return p;
|
|
2594
|
+
const h = inside(at.home);
|
|
2595
|
+
if (h !== null)
|
|
2596
|
+
return "~/" + h;
|
|
2597
|
+
return abs.split(pathSep).join("/");
|
|
2598
|
+
}
|
|
2599
|
+
function hookPlainArgs(args) {
|
|
2600
|
+
const out = [];
|
|
2601
|
+
for (let i = 0; i < args.length; i++) {
|
|
2602
|
+
const a = args[i];
|
|
2603
|
+
if (/^(\d+|&)?(>>?|<)\|?$/.test(a)) {
|
|
2604
|
+
i++;
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
if (/^(\d+|&)?[<>]/.test(a))
|
|
2608
|
+
continue;
|
|
2609
|
+
out.push(a);
|
|
2610
|
+
}
|
|
2611
|
+
return out;
|
|
2612
|
+
}
|
|
2613
|
+
function hookWriteTargets(s) {
|
|
2614
|
+
const out = [...s.raw.matchAll(/(?:\d+|&)?>>?\|?\s*([^\s;&|<>()]+)/g)].map((m) => m[1]).filter((t) => !t.startsWith("&"));
|
|
2615
|
+
const files = hookPlainArgs(s.args).filter((a) => !a.startsWith("-"));
|
|
2616
|
+
const p = s.program;
|
|
2617
|
+
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))
|
|
2618
|
+
out.push(...files);
|
|
2619
|
+
else if (p === "sed" && s.args.some((a) => /^-[A-Za-z]*i/.test(a) || /^--in-place(=|$)/.test(a)))
|
|
2620
|
+
out.push(...files);
|
|
2621
|
+
else if (["mv", "move", "move-item", "mi", "cp", "copy", "copy-item", "cpi"].includes(p)) {
|
|
2622
|
+
const t = s.args.findIndex((a) => a === "-t" || a === "--target-directory");
|
|
2623
|
+
const tdir = t >= 0 ? s.args[t + 1] : s.args.find((a) => a.startsWith("--target-directory="))?.slice(19);
|
|
2624
|
+
const srcs = tdir !== undefined ? files.filter((f) => f !== tdir) : files.slice(0, -1);
|
|
2625
|
+
const dest = tdir ?? files[files.length - 1];
|
|
2626
|
+
if (dest !== undefined)
|
|
2627
|
+
out.push(dest, ...srcs.map((f) => joinPath(hookUnq(dest), basename(hookUnq(f)))));
|
|
2628
|
+
if (["mv", "move", "move-item", "mi"].includes(p))
|
|
2629
|
+
out.push(...srcs);
|
|
2630
|
+
}
|
|
2631
|
+
else if (p === "dd")
|
|
2632
|
+
out.push(...s.args.filter((a) => a.startsWith("of=")).map((a) => a.slice(3)));
|
|
2633
|
+
return out;
|
|
2634
|
+
}
|
|
2635
|
+
function hookGuardHits(segs, at) {
|
|
2636
|
+
const hits = [];
|
|
2637
|
+
const dirs = hookSegDirs(segs, at);
|
|
2638
|
+
segs.forEach((s, i) => {
|
|
2639
|
+
for (const t of hookWriteTargets(s)) {
|
|
2640
|
+
const e = hookExpand(t.replace(/\\/g, "/"), at);
|
|
2641
|
+
const dir = dirs[i];
|
|
2642
|
+
if (e !== null && (dir !== null || isAbsolute(e))) {
|
|
2643
|
+
const abs = resolvePath(dir ?? at.cwd, e);
|
|
2644
|
+
if (hookGuardKind(abs, at) || hookGuardDir(abs, at))
|
|
2645
|
+
hits.push({ key: `path:${abs}`, display: hookShowPath(abs, at) });
|
|
2646
|
+
}
|
|
2647
|
+
else if (/(^|[\\/])\.claude[\\/](settings[^\\/]*\.json|plugins[\\/].*decidio)/i.test(hookUnq(t))) {
|
|
2648
|
+
hits.push({ key: `text:${t}`, display: hookUnq(t) });
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
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)))
|
|
2652
|
+
hits.push({ key: `plugin:${s.args.join(" ")}`, display: `claude ${s.args.join(" ")}` });
|
|
2653
|
+
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=")))
|
|
2654
|
+
hits.push({ key: `install:${s.args.join(" ")}`, display: `hook install ${s.args.slice(s.args.indexOf("install") + 1).join(" ")}` });
|
|
2655
|
+
});
|
|
2656
|
+
return hits;
|
|
2657
|
+
}
|
|
2380
2658
|
const HOOK_RULES = [
|
|
2659
|
+
{ 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",
|
|
2660
|
+
matches: (segs, _cmd, at) => hookGuardHits(segs, at).length > 0 },
|
|
2381
2661
|
{ category: "delete-recursive", summary: "rm -r/-rf, find -delete / -exec rm, rmdir|rd|del /s, Remove-Item -Recurse, shred",
|
|
2382
2662
|
matches: (segs) => segs.some((s) => (s.program === "rm" && hookFlag(s.args, "rR", ["--recursive"]))
|
|
2383
2663
|
|| (s.program === "find" && (s.args.includes("-delete") || s.args.some((a, i) => i > 0 && s.args[i - 1] === "-exec" && basename(a) === "rm")))
|
|
@@ -2394,10 +2674,10 @@ const HOOK_RULES = [
|
|
|
2394
2674
|
|| (hookSub(s) === "update-ref" && s.args.includes("-d"))
|
|
2395
2675
|
|| (hookSub(s) === "reflog" && hookSub(s, 1) === "expire")
|
|
2396
2676
|
|| (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",
|
|
2677
|
+
{ 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
2678
|
matches: (segs, cmd) => {
|
|
2399
2679
|
const toDb = segs.some((s) => HOOK_DB_CLIENTS.includes(s.program) || /\bprisma\b/.test(s.raw) || /\.sql\b/.test(s.raw));
|
|
2400
|
-
return (toDb
|
|
2680
|
+
return ((toDb || segs.some(hookInline)) && hookSqlStmt(cmd)) || segs.some((s) => hookInterp(s) && hookSqlStmt(s.raw))
|
|
2401
2681
|
|| segs.some((s) => (/\bprisma\b/.test(s.raw) && (/\bmigrate\s+reset\b/.test(s.raw) || /--force-reset\b/.test(s.raw)))
|
|
2402
2682
|
|| (["rails", "rake"].includes(s.program) && /\bdb:(drop|reset|schema:load)\b/.test(s.raw)));
|
|
2403
2683
|
} },
|
|
@@ -2471,11 +2751,207 @@ function hookExtraRules() {
|
|
|
2471
2751
|
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
2752
|
});
|
|
2473
2753
|
}
|
|
2474
|
-
function hookMatch(command) {
|
|
2754
|
+
function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], depth = 0) {
|
|
2475
2755
|
const segs = hookSegments(command);
|
|
2476
|
-
for (const r of
|
|
2477
|
-
if (r.matches(segs, command))
|
|
2756
|
+
for (const r of rules)
|
|
2757
|
+
if (r.matches(segs, command, at))
|
|
2478
2758
|
return r;
|
|
2759
|
+
if (depth < 2)
|
|
2760
|
+
for (const inner of hookShellInner(command)) {
|
|
2761
|
+
const r = hookMatch(inner, at, rules, depth + 1);
|
|
2762
|
+
if (r)
|
|
2763
|
+
return r;
|
|
2764
|
+
}
|
|
2765
|
+
return null;
|
|
2766
|
+
}
|
|
2767
|
+
function hookSelfDecides(command, depth = 0) {
|
|
2768
|
+
const hit = hookSegments(command).some((s) => {
|
|
2769
|
+
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
2770
|
+
const cli = (x) => /^@decidio\/sdk(@\S+)?$/i.test(x) || /^decidio(@\S+)?$/i.test(x)
|
|
2771
|
+
|| /(^|[\\/])(decidio-guard|@decidio[\\/]sdk)[\\/](dist[\\/])?cli\.(js|ts)$/i.test(x);
|
|
2772
|
+
const runsCli = s.program === "decidio" || s.program === "decidio.js"
|
|
2773
|
+
|| ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
|
|
2774
|
+
const i = a.indexOf("approvals");
|
|
2775
|
+
return runsCli && i >= 0 && a.slice(i + 1).some((x) => /^(approve|reject)$/i.test(x));
|
|
2776
|
+
});
|
|
2777
|
+
return hit || (depth < 2 && hookShellInner(command).some((c) => hookSelfDecides(c, depth + 1)));
|
|
2778
|
+
}
|
|
2779
|
+
function hookShellInner(command) {
|
|
2780
|
+
const out = [];
|
|
2781
|
+
const re = /(?:^|[\s;&|(`])(?:bash|sh|zsh|dash|ksh|pwsh|powershell)(?:\.exe)?((?:\s+-[A-Za-z]+)*?)\s+(?:-[A-Za-z]*c|-Command|-command)\s+/g;
|
|
2782
|
+
for (let m = re.exec(command); m; m = re.exec(command)) {
|
|
2783
|
+
let i = m.index + m[0].length, arg = "";
|
|
2784
|
+
const q = command[i];
|
|
2785
|
+
if (q === "'") {
|
|
2786
|
+
const end = command.indexOf("'", i + 1);
|
|
2787
|
+
arg = command.slice(i + 1, end < 0 ? undefined : end);
|
|
2788
|
+
}
|
|
2789
|
+
else if (q === '"') {
|
|
2790
|
+
for (i += 1; i < command.length && command[i] !== '"'; i++)
|
|
2791
|
+
arg += command[i] === "\\" && (command[i + 1] === '"' || command[i + 1] === "\\") ? command[++i] : command[i];
|
|
2792
|
+
}
|
|
2793
|
+
else
|
|
2794
|
+
arg = command.slice(i).split(/\s/, 1)[0] ?? "";
|
|
2795
|
+
if (arg.trim())
|
|
2796
|
+
out.push(arg);
|
|
2797
|
+
}
|
|
2798
|
+
return out;
|
|
2799
|
+
}
|
|
2800
|
+
const HOOK_FOLLOW_MAX = 256 * 1024;
|
|
2801
|
+
const HOOK_SHELLS = ["bash", "sh", "zsh", "dash", "ksh"];
|
|
2802
|
+
const HOOK_PROGRAM_EXT = /\.(py|js|mjs|cjs|ts|rb)$/i;
|
|
2803
|
+
function hookProjectFile(p, dir, at) {
|
|
2804
|
+
try {
|
|
2805
|
+
const e = hookExpand(p, at);
|
|
2806
|
+
if (e === null)
|
|
2807
|
+
return null;
|
|
2808
|
+
const real = realPath(resolvePath(dir, e));
|
|
2809
|
+
let root = at.projectDir;
|
|
2810
|
+
try {
|
|
2811
|
+
root = realPath(at.projectDir);
|
|
2812
|
+
}
|
|
2813
|
+
catch { }
|
|
2814
|
+
const r = relative(root, real);
|
|
2815
|
+
if (!r || r.startsWith("..") || isAbsolute(r))
|
|
2816
|
+
return null;
|
|
2817
|
+
const st = statSync(real);
|
|
2818
|
+
if (!st.isFile() || st.size > HOOK_FOLLOW_MAX)
|
|
2819
|
+
return null;
|
|
2820
|
+
const buf = readFileSync(real);
|
|
2821
|
+
if (buf.length > HOOK_FOLLOW_MAX || buf.subarray(0, 8192).includes(0))
|
|
2822
|
+
return null;
|
|
2823
|
+
return { rel: r.split(pathSep).join("/"), text: buf.toString("utf8"), sha: createHash("sha256").update(buf).digest("hex") };
|
|
2824
|
+
}
|
|
2825
|
+
catch {
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
const hookLines = (text) => {
|
|
2830
|
+
const phys = text.split(/\r?\n/);
|
|
2831
|
+
const out = [];
|
|
2832
|
+
for (let i = 0; i < phys.length; i++) {
|
|
2833
|
+
const n = i + 1;
|
|
2834
|
+
let t = phys[i];
|
|
2835
|
+
while (/\\$/.test(t) && i + 1 < phys.length)
|
|
2836
|
+
t = t.slice(0, -1) + " " + phys[++i];
|
|
2837
|
+
out.push({ n, t });
|
|
2838
|
+
}
|
|
2839
|
+
return out;
|
|
2840
|
+
};
|
|
2841
|
+
function hookScanShell(f, at, rules) {
|
|
2842
|
+
const lines = hookLines(f.text).filter((l) => l.t.trim() && !/^\s*#/.test(l.t));
|
|
2843
|
+
for (const l of lines) {
|
|
2844
|
+
const r = hookMatch(l.t, at, rules);
|
|
2845
|
+
if (r)
|
|
2846
|
+
return { rule: r, rel: f.rel, line: l.n, text: l.t, sha: f.sha };
|
|
2847
|
+
}
|
|
2848
|
+
const r = hookMatch(lines.map((l) => l.t).join("\n"), at, rules);
|
|
2849
|
+
if (!r)
|
|
2850
|
+
return null;
|
|
2851
|
+
const l = lines.find((x) => hookSqlStmt(x.t)) ?? lines[0];
|
|
2852
|
+
return { rule: r, rel: f.rel, line: l.n, text: l.t, sha: f.sha };
|
|
2853
|
+
}
|
|
2854
|
+
function hookScanSql(f, rule) {
|
|
2855
|
+
const lines = hookLines(f.text).filter((l) => l.t.trim() && !/^\s*--/.test(l.t));
|
|
2856
|
+
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);
|
|
2857
|
+
return l ? { rule, rel: f.rel, line: l.n, text: l.t, sha: f.sha } : null;
|
|
2858
|
+
}
|
|
2859
|
+
function hookScanProgram(f, rule) {
|
|
2860
|
+
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));
|
|
2861
|
+
return l ? { rule, rel: f.rel, line: l.n, text: l.t, sha: f.sha } : null;
|
|
2862
|
+
}
|
|
2863
|
+
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"]);
|
|
2864
|
+
function hookPackageScript(s) {
|
|
2865
|
+
const a = hookPlainArgs(s.args).filter((x) => !x.startsWith("-"));
|
|
2866
|
+
if (s.program === "npm") {
|
|
2867
|
+
if (["run", "run-script", "rum", "urn"].includes(a[0] ?? ""))
|
|
2868
|
+
return a[1] ?? null;
|
|
2869
|
+
return { test: "test", t: "test", tst: "test", start: "start", stop: "stop", restart: "restart" }[a[0] ?? ""] ?? null;
|
|
2870
|
+
}
|
|
2871
|
+
if (s.program === "bun")
|
|
2872
|
+
return a[0] === "run" ? a[1] ?? null : null;
|
|
2873
|
+
if (s.program === "pnpm" || s.program === "yarn")
|
|
2874
|
+
return a[0] === "run" ? a[1] ?? null : a[0] && !HOOK_PM_OWN.has(a[0]) ? a[0] : null;
|
|
2875
|
+
return null;
|
|
2876
|
+
}
|
|
2877
|
+
function hookProgramFile(s) {
|
|
2878
|
+
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;
|
|
2879
|
+
if (!ext)
|
|
2880
|
+
return null;
|
|
2881
|
+
let a = hookPlainArgs(s.args);
|
|
2882
|
+
if (/^(deno|bun)/.test(s.program) && a[0] === "run")
|
|
2883
|
+
a = a.slice(1);
|
|
2884
|
+
for (const x of a) {
|
|
2885
|
+
if (x === "-" || x === "-m" || /^-[A-Za-z]*[ce]$/.test(x) || x === "--eval" || x === "-p" || x === "--print")
|
|
2886
|
+
return null;
|
|
2887
|
+
if (x.startsWith("-"))
|
|
2888
|
+
continue;
|
|
2889
|
+
return ext.test(hookUnq(x)) ? hookUnq(x) : null;
|
|
2890
|
+
}
|
|
2891
|
+
return null;
|
|
2892
|
+
}
|
|
2893
|
+
function hookFollow(segs, at) {
|
|
2894
|
+
const rules = [...HOOK_RULES, ...hookExtraRules()];
|
|
2895
|
+
const sql = HOOK_RULES.find((r) => r.category === "sql-destructive");
|
|
2896
|
+
const dirs = hookSegDirs(segs, at);
|
|
2897
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2898
|
+
const dir = dirs[i];
|
|
2899
|
+
if (dir === null || dir === undefined)
|
|
2900
|
+
continue;
|
|
2901
|
+
const s = hookUnwrap(segs[i]);
|
|
2902
|
+
const file = (p) => (p ? hookProjectFile(p, dir, at) : null);
|
|
2903
|
+
const redirIn = s.raw.match(/(?:^|[^<])<(?![<(])\s*([^\s<>|&;()]+)/)?.[1];
|
|
2904
|
+
let sh;
|
|
2905
|
+
if (HOOK_SHELLS.includes(s.program)) {
|
|
2906
|
+
const a = hookPlainArgs(s.args);
|
|
2907
|
+
sh = a.some((x) => /^-[A-Za-z]*c[A-Za-z]*$/.test(x)) ? undefined : a.find((x) => !x.startsWith("-")) ?? redirIn;
|
|
2908
|
+
}
|
|
2909
|
+
else if (s.program === "source" || s.program === ".")
|
|
2910
|
+
sh = hookPlainArgs(s.args)[0];
|
|
2911
|
+
else if (/[\\/]/.test(s.bin) && !HOOK_PROGRAM_EXT.test(s.bin) && !/\.sql$/i.test(s.bin))
|
|
2912
|
+
sh = s.bin;
|
|
2913
|
+
if (sh !== undefined) {
|
|
2914
|
+
const f = file(sh);
|
|
2915
|
+
const hit = f && hookScanShell(f, at, rules);
|
|
2916
|
+
if (hit)
|
|
2917
|
+
return hit;
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
if (HOOK_DB_CLIENTS.includes(s.program)) {
|
|
2921
|
+
const k = s.args.findIndex((x) => x === "-f" || x === "--file");
|
|
2922
|
+
const sqlFile = (s.program === "psql" ? (k >= 0 ? s.args[k + 1] : s.args.find((x) => x.startsWith("--file="))?.slice(7)) : undefined)
|
|
2923
|
+
?? redirIn ?? (["sqlite3", "sqlite", "duckdb"].includes(s.program) ? s.raw.match(/\.read\s+["']?([^"'\s]+)/)?.[1] : undefined);
|
|
2924
|
+
const f = file(sqlFile);
|
|
2925
|
+
const hit = f && hookScanSql(f, sql);
|
|
2926
|
+
if (hit)
|
|
2927
|
+
return hit;
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
const script = hookPackageScript(s);
|
|
2931
|
+
if (script) {
|
|
2932
|
+
const f = file("package.json");
|
|
2933
|
+
let body;
|
|
2934
|
+
try {
|
|
2935
|
+
body = f ? JSON.parse(f.text)?.scripts?.[script] : undefined;
|
|
2936
|
+
}
|
|
2937
|
+
catch {
|
|
2938
|
+
body = undefined;
|
|
2939
|
+
}
|
|
2940
|
+
if (f && typeof body === "string") {
|
|
2941
|
+
const r = hookMatch(body, at, rules);
|
|
2942
|
+
if (r) {
|
|
2943
|
+
const n = f.text.split(/\r?\n/).findIndex((l) => l.replace(/\s+/g, "").includes(JSON.stringify(script) + ":"));
|
|
2944
|
+
return { rule: r, rel: f.rel, line: n + 1 || 1, text: body, sha: f.sha };
|
|
2945
|
+
}
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
const prog = hookProgramFile(s) ?? (/[\\/]/.test(s.bin) && HOOK_PROGRAM_EXT.test(s.bin) ? s.bin : null);
|
|
2950
|
+
const f = file(prog ?? undefined);
|
|
2951
|
+
const hit = f && hookScanProgram(f, sql);
|
|
2952
|
+
if (hit)
|
|
2953
|
+
return hit;
|
|
2954
|
+
}
|
|
2479
2955
|
return null;
|
|
2480
2956
|
}
|
|
2481
2957
|
function hookRedact(command) {
|
|
@@ -2487,21 +2963,29 @@ function hookRedact(command) {
|
|
|
2487
2963
|
.replace(/((?:-H|--header)\s+["']?(?:authorization|x-api-key|cookie)\s*:\s*)[^"']+/gi, "$1[REDACTED]")
|
|
2488
2964
|
.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
2965
|
}
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
|
|
2966
|
+
const hookGitMemo = new Map();
|
|
2967
|
+
function hookGitRead(cwdAbs) {
|
|
2968
|
+
const hit = hookGitMemo.get(cwdAbs);
|
|
2969
|
+
if (hit)
|
|
2970
|
+
return hit;
|
|
2971
|
+
const run = (args) => {
|
|
2495
2972
|
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;
|
|
2973
|
+
return execFileSync("git", args, { cwd: cwdAbs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
|
|
2500
2974
|
}
|
|
2501
2975
|
catch {
|
|
2502
2976
|
return null;
|
|
2503
2977
|
}
|
|
2504
2978
|
};
|
|
2979
|
+
const head = run(["rev-parse", "HEAD"]);
|
|
2980
|
+
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"]) };
|
|
2981
|
+
hookGitMemo.set(cwdAbs, out);
|
|
2982
|
+
return out;
|
|
2983
|
+
}
|
|
2984
|
+
function hookSubject(category, segs, cwdAbs, projectDir, at) {
|
|
2985
|
+
const textOnly = (why) => ({ fingerprint: null, display: `text-only (${why})` });
|
|
2986
|
+
const relOrAbs = (p) => { const r = relative(projectDir, p); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : p; };
|
|
2987
|
+
const nonFlags = (s) => s.args.filter((a) => !a.startsWith("-"));
|
|
2988
|
+
const git = () => { const g = hookGitRead(cwdAbs); return g.head && g.branch ? { head: g.head, branch: g.branch } : null; };
|
|
2505
2989
|
const firstOf = (programs) => segs.findIndex((s) => programs.includes(s.program));
|
|
2506
2990
|
const movedTree = (i) => segs.slice(0, i).some((s) => s.program === "cd" || s.program === "pushd");
|
|
2507
2991
|
if (category === "git-rewrite" || category === "infra-apply") {
|
|
@@ -2510,11 +2994,13 @@ function hookSubject(category, segs, cwdAbs, projectDir) {
|
|
|
2510
2994
|
return textOnly("no subject");
|
|
2511
2995
|
if (movedTree(i))
|
|
2512
2996
|
return textOnly("a cd on the same line moves the call to another directory");
|
|
2997
|
+
if (category === "git-rewrite" && hookGitElsewhere(segs[i], cwdAbs))
|
|
2998
|
+
return textOnly("git -C names another directory");
|
|
2513
2999
|
const g = git();
|
|
2514
3000
|
if (!g)
|
|
2515
3001
|
return textOnly("not a git repository here");
|
|
2516
3002
|
const seg = segs[i];
|
|
2517
|
-
const args = nonFlags(seg);
|
|
3003
|
+
const args = category === "git-rewrite" ? hookGitArgs(seg).filter((a) => !a.startsWith("-")) : nonFlags(seg);
|
|
2518
3004
|
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
3005
|
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
3006
|
}
|
|
@@ -2533,17 +3019,245 @@ function hookSubject(category, segs, cwdAbs, projectDir) {
|
|
|
2533
3019
|
if (category === "sql-destructive") {
|
|
2534
3020
|
const stIdx = segs.findIndex((s) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(s.raw));
|
|
2535
3021
|
const dbIdx = firstOf(HOOK_DB_CLIENTS);
|
|
2536
|
-
const
|
|
3022
|
+
const own = segs.findIndex((s) => hookInterp(s) && hookSqlStmt(s.raw));
|
|
3023
|
+
const inIdx = dbIdx >= 0 ? -1 : own >= 0 ? own : segs.findIndex(hookInline);
|
|
3024
|
+
const client = dbIdx >= 0 ? segs[dbIdx] : inIdx >= 0 ? segs[inIdx] : stIdx >= 0 ? segs[stIdx] : null;
|
|
2537
3025
|
if (!client)
|
|
2538
3026
|
return textOnly("no statement");
|
|
2539
3027
|
const stmtSeg = stIdx >= 0 ? segs[stIdx] : client;
|
|
2540
3028
|
const stmt = (stmtSeg.raw.match(/\b(DROP\s+\w+[^;'"]*|TRUNCATE[^;'"]*|DELETE\s+FROM[^;'"]*)/i)?.[1] ?? stmtSeg.raw).replace(/\s+/g, " ").trim();
|
|
2541
3029
|
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(" ");
|
|
3030
|
+
const target = (stmtAt >= 0 ? client.args.slice(0, stmtAt) : client.args).filter((a) => !a.startsWith("-") && !/^["'@]+$/.test(a)).join(" ");
|
|
2543
3031
|
return { fingerprint: `sql:${client.program}:${target}:${stmt.toUpperCase()}`, display: hookRedact(`${client.program}${target ? ` ${target}` : ""}: ${stmt}`).slice(0, 160) };
|
|
2544
3032
|
}
|
|
3033
|
+
if (category === "decidio-guard" && at) {
|
|
3034
|
+
const hits = hookGuardHits(segs, at);
|
|
3035
|
+
if (!hits.length)
|
|
3036
|
+
return textOnly("no subject");
|
|
3037
|
+
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) };
|
|
3038
|
+
}
|
|
2545
3039
|
return textOnly("text binds this category");
|
|
2546
3040
|
}
|
|
3041
|
+
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3042
|
+
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;
|
|
3043
|
+
function hookOurHandlers(settings) {
|
|
3044
|
+
const out = [];
|
|
3045
|
+
const hooks = settings && typeof settings === "object" ? settings.hooks : undefined;
|
|
3046
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
3047
|
+
return out;
|
|
3048
|
+
for (const [event, list] of Object.entries(hooks))
|
|
3049
|
+
if (Array.isArray(list))
|
|
3050
|
+
for (const g of list)
|
|
3051
|
+
if (g && typeof g === "object" && Array.isArray(g.hooks))
|
|
3052
|
+
for (const h of g.hooks)
|
|
3053
|
+
if (hookIsOurs(h)) {
|
|
3054
|
+
const matcher = typeof g.matcher === "string" ? g.matcher : "";
|
|
3055
|
+
out.push({ event, matcher, iff: typeof h.if === "string" ? h.if : "", key: `${event}\0${matcher}\0${JSON.stringify(hookSorted(h))}` });
|
|
3056
|
+
}
|
|
3057
|
+
return out;
|
|
3058
|
+
}
|
|
3059
|
+
function hookFindEdit(text, from) {
|
|
3060
|
+
if (text.includes(from))
|
|
3061
|
+
return from;
|
|
3062
|
+
const fold = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"');
|
|
3063
|
+
const i = fold(text).indexOf(fold(from));
|
|
3064
|
+
return i < 0 ? null : text.slice(i, i + from.length);
|
|
3065
|
+
}
|
|
3066
|
+
function hookFileGuard(tool, ti, at) {
|
|
3067
|
+
const t = (ti && typeof ti === "object" ? ti : {});
|
|
3068
|
+
if (typeof t.file_path !== "string" || !t.file_path)
|
|
3069
|
+
return null;
|
|
3070
|
+
const abs = resolvePath(at.cwd, t.file_path);
|
|
3071
|
+
const kind = hookGuardKind(abs, at);
|
|
3072
|
+
if (!kind)
|
|
3073
|
+
return null;
|
|
3074
|
+
const display = hookShowPath(abs, at);
|
|
3075
|
+
const sha = (s) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
3076
|
+
let before = null, unreadable = false;
|
|
3077
|
+
try {
|
|
3078
|
+
before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
3079
|
+
}
|
|
3080
|
+
catch {
|
|
3081
|
+
unreadable = true;
|
|
3082
|
+
}
|
|
3083
|
+
let after;
|
|
3084
|
+
if (tool === "Write") {
|
|
3085
|
+
if (typeof t.content !== "string")
|
|
3086
|
+
return null;
|
|
3087
|
+
after = t.content;
|
|
3088
|
+
}
|
|
3089
|
+
else {
|
|
3090
|
+
const edits = tool === "Edit" ? [t] : Array.isArray(t.edits) ? t.edits : null;
|
|
3091
|
+
if (!edits || !edits.length)
|
|
3092
|
+
return null;
|
|
3093
|
+
if (unreadable)
|
|
3094
|
+
return { abs, display, changes: ["the current file could not be read, so the change cannot be checked"], sha: sha(`unreadable\n${JSON.stringify(edits)}`) };
|
|
3095
|
+
let text = before;
|
|
3096
|
+
for (const raw of edits) {
|
|
3097
|
+
const e = (raw && typeof raw === "object" ? raw : {});
|
|
3098
|
+
const from = e.old_string, to = e.new_string;
|
|
3099
|
+
if (typeof from !== "string" || typeof to !== "string")
|
|
3100
|
+
return null;
|
|
3101
|
+
if (text === null) {
|
|
3102
|
+
if (from !== "")
|
|
3103
|
+
return null;
|
|
3104
|
+
text = to;
|
|
3105
|
+
continue;
|
|
3106
|
+
}
|
|
3107
|
+
if (from === "") {
|
|
3108
|
+
text = text.trim() === "" ? to : to + text;
|
|
3109
|
+
continue;
|
|
3110
|
+
}
|
|
3111
|
+
const found = hookFindEdit(text, from);
|
|
3112
|
+
if (found === null)
|
|
3113
|
+
return null;
|
|
3114
|
+
text = e.replace_all === true ? text.split(found).join(to) : text.replace(found, () => to);
|
|
3115
|
+
}
|
|
3116
|
+
after = text ?? "";
|
|
3117
|
+
}
|
|
3118
|
+
const changes = [];
|
|
3119
|
+
if (kind === "Decidio plugin")
|
|
3120
|
+
changes.push("changes the Decidio plugin's own files");
|
|
3121
|
+
else {
|
|
3122
|
+
let parsed, valid = true;
|
|
3123
|
+
try {
|
|
3124
|
+
parsed = JSON.parse(after);
|
|
3125
|
+
}
|
|
3126
|
+
catch {
|
|
3127
|
+
valid = false;
|
|
3128
|
+
}
|
|
3129
|
+
const obj = valid && !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
|
3130
|
+
if (!obj)
|
|
3131
|
+
changes.push(valid ? "leaves the file something other than a JSON object" : "leaves the file invalid JSON");
|
|
3132
|
+
let prev = {};
|
|
3133
|
+
if (unreadable)
|
|
3134
|
+
changes.push("the current file could not be read, so what it removes cannot be checked");
|
|
3135
|
+
else if (before !== null) {
|
|
3136
|
+
try {
|
|
3137
|
+
prev = JSON.parse(before);
|
|
3138
|
+
}
|
|
3139
|
+
catch {
|
|
3140
|
+
prev = {};
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
const now = obj ? parsed : {};
|
|
3144
|
+
const nowHandlers = hookOurHandlers(now);
|
|
3145
|
+
const kept = new Set(nowHandlers.map((h) => h.key)), slots = new Set(nowHandlers.map((h) => `${h.event}\0${h.matcher}\0${h.iff}`));
|
|
3146
|
+
const lost = hookOurHandlers(prev).filter((h) => !kept.has(h.key));
|
|
3147
|
+
const verb = (h) => slots.has(`${h.event}\0${h.matcher}\0${h.iff}`) ? "changes" : "removes";
|
|
3148
|
+
if (lost.length === 1)
|
|
3149
|
+
changes.push(`${verb(lost[0])} Decidio's ${lost[0].event} hook${lost[0].matcher ? ` (${lost[0].matcher}${lost[0].iff ? `, ${lost[0].iff}` : ""})` : ""}`);
|
|
3150
|
+
else if (lost.length) {
|
|
3151
|
+
const removed = lost.filter((h) => verb(h) === "removes").length;
|
|
3152
|
+
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(", ")})`);
|
|
3153
|
+
}
|
|
3154
|
+
if (now.disableAllHooks === true && prev?.disableAllHooks !== true)
|
|
3155
|
+
changes.push("sets disableAllHooks");
|
|
3156
|
+
const ep = (o) => (o && typeof o === "object" && o.enabledPlugins && typeof o.enabledPlugins === "object" && !Array.isArray(o.enabledPlugins) ? o.enabledPlugins : {});
|
|
3157
|
+
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)));
|
|
3158
|
+
if (off.length)
|
|
3159
|
+
changes.push(`turns the Decidio plugin off (enabledPlugins ${off.join(", ")})`);
|
|
3160
|
+
}
|
|
3161
|
+
return changes.length ? { abs, display, changes, sha: sha(after) } : null;
|
|
3162
|
+
}
|
|
3163
|
+
const HOOK_MODES = ["default", "acceptEdits", "plan", "auto", "dontAsk", "bypassPermissions"];
|
|
3164
|
+
function hookRepoName(url) {
|
|
3165
|
+
const u = url.trim();
|
|
3166
|
+
let host, path;
|
|
3167
|
+
const scheme = u.match(/^[a-z][a-z0-9+.-]*:\/\/(.*)$/i);
|
|
3168
|
+
if (scheme) {
|
|
3169
|
+
const rest = scheme[1];
|
|
3170
|
+
const slash = rest.indexOf("/");
|
|
3171
|
+
if (slash < 0)
|
|
3172
|
+
return null;
|
|
3173
|
+
const auth = rest.slice(0, slash);
|
|
3174
|
+
host = auth.slice(auth.lastIndexOf("@") + 1).replace(/:\d*$/, "");
|
|
3175
|
+
path = rest.slice(slash + 1);
|
|
3176
|
+
}
|
|
3177
|
+
else {
|
|
3178
|
+
const scp = u.match(/^(?:[^@/]+@)?([^:/\\]+):(?!\/\/)(.+)$/);
|
|
3179
|
+
if (!scp || /^[a-z]:[\\/]/i.test(u))
|
|
3180
|
+
return null;
|
|
3181
|
+
host = scp[1];
|
|
3182
|
+
path = scp[2];
|
|
3183
|
+
}
|
|
3184
|
+
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
3185
|
+
if (!host || !path)
|
|
3186
|
+
return null;
|
|
3187
|
+
host = host.toLowerCase();
|
|
3188
|
+
return hookRedact(host === "github.com" ? path : `${host}/${path}`).slice(0, 200);
|
|
3189
|
+
}
|
|
3190
|
+
function hookCardGit(segs, at) {
|
|
3191
|
+
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))))
|
|
3192
|
+
return {};
|
|
3193
|
+
const g = hookGitRead(at.cwd);
|
|
3194
|
+
const repo = g.origin ? hookRepoName(g.origin) : null;
|
|
3195
|
+
return { ...(repo ? { repo } : {}), ...(g.branch && g.branch !== "HEAD" ? { branch: hookRedact(g.branch).slice(0, 200) } : {}), ...(g.head ? { commit: g.head.slice(0, 7) } : {}) };
|
|
3196
|
+
}
|
|
3197
|
+
const HOOK_ASK_TAIL = 512 * 1024;
|
|
3198
|
+
function hookAsked(transcriptPath) {
|
|
3199
|
+
if (process.env.DECIDIO_HOOK_SHARE_PROMPT === "0" || typeof transcriptPath !== "string" || !transcriptPath)
|
|
3200
|
+
return null;
|
|
3201
|
+
try {
|
|
3202
|
+
let text;
|
|
3203
|
+
const fd = openSync(transcriptPath, "r");
|
|
3204
|
+
try {
|
|
3205
|
+
const size = fstatSync(fd).size, start = Math.max(0, size - HOOK_ASK_TAIL);
|
|
3206
|
+
const buf = Buffer.alloc(size - start);
|
|
3207
|
+
let got = 0;
|
|
3208
|
+
while (got < buf.length) {
|
|
3209
|
+
const n = readSync(fd, buf, got, buf.length - got, start + got);
|
|
3210
|
+
if (!n)
|
|
3211
|
+
break;
|
|
3212
|
+
got += n;
|
|
3213
|
+
}
|
|
3214
|
+
text = buf.subarray(0, got).toString("utf8");
|
|
3215
|
+
if (start > 0)
|
|
3216
|
+
text = text.slice(text.indexOf("\n") + 1);
|
|
3217
|
+
}
|
|
3218
|
+
finally {
|
|
3219
|
+
closeSync(fd);
|
|
3220
|
+
}
|
|
3221
|
+
const notPrompt = /^\s*<(task-notification|bash-input|bash-stdout|bash-stderr|local-command-stdout|local-command-stderr)>/;
|
|
3222
|
+
const prompts = [];
|
|
3223
|
+
for (const line of text.split("\n")) {
|
|
3224
|
+
if (!line.includes('"last-prompt"') && !line.includes('"external"'))
|
|
3225
|
+
continue;
|
|
3226
|
+
let o;
|
|
3227
|
+
try {
|
|
3228
|
+
o = JSON.parse(line);
|
|
3229
|
+
}
|
|
3230
|
+
catch {
|
|
3231
|
+
continue;
|
|
3232
|
+
}
|
|
3233
|
+
if (o?.type === "last-prompt" && typeof o.lastPrompt === "string" && o.lastPrompt.trim()) {
|
|
3234
|
+
if (!notPrompt.test(o.lastPrompt))
|
|
3235
|
+
prompts.push(o.lastPrompt);
|
|
3236
|
+
}
|
|
3237
|
+
else if (o?.type === "user" && o.userType === "external" && !o.isMeta && !o.isSidechain && !o.isCompactSummary && !o.isVisibleInTranscriptOnly) {
|
|
3238
|
+
const c = o.message?.content;
|
|
3239
|
+
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") : "";
|
|
3240
|
+
if (said.trim() && !notPrompt.test(said))
|
|
3241
|
+
prompts.push(said);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const asked = prompts[prompts.length - 1];
|
|
3245
|
+
if (asked === undefined)
|
|
3246
|
+
return null;
|
|
3247
|
+
let before;
|
|
3248
|
+
if (asked.length < 40)
|
|
3249
|
+
for (let i = prompts.length - 2; i >= 0; i--)
|
|
3250
|
+
if (prompts[i] !== asked) {
|
|
3251
|
+
before = prompts[i];
|
|
3252
|
+
break;
|
|
3253
|
+
}
|
|
3254
|
+
const cut = (s) => { const r = hookRedact(s); const c = r.slice(0, 1000); return /[\uD800-\uDBFF]$/.test(c) ? c.slice(0, -1) : c; };
|
|
3255
|
+
return { asked: cut(asked), ...(before !== undefined ? { askedBefore: cut(before) } : {}), askedLength: asked.length };
|
|
3256
|
+
}
|
|
3257
|
+
catch {
|
|
3258
|
+
return null;
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
2547
3261
|
async function readHookStdin() {
|
|
2548
3262
|
if (process.stdin.isTTY)
|
|
2549
3263
|
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)" };
|
|
@@ -2574,11 +3288,11 @@ async function readHookStdin() {
|
|
|
2574
3288
|
function hookConfig() {
|
|
2575
3289
|
const token = process.env.DECIDIO_API_TOKEN;
|
|
2576
3290
|
if (!token)
|
|
2577
|
-
return { missing: "
|
|
3291
|
+
return { missing: "this project is not connected to Decidio (no connection, no agent token in this folder's .env or the environment)" };
|
|
2578
3292
|
return {
|
|
2579
3293
|
apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
|
|
2580
3294
|
agentKey: process.env.DECIDIO_AGENT_KEY || undefined, agentDid: process.env.DECIDIO_AGENT_DID || undefined, workspaceId: process.env.DECIDIO_WORKSPACE_ID || undefined,
|
|
2581
|
-
appUrl:
|
|
3295
|
+
appUrl: APP_URL,
|
|
2582
3296
|
};
|
|
2583
3297
|
}
|
|
2584
3298
|
const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
|
|
@@ -2694,51 +3408,81 @@ async function cmdHookPretool() {
|
|
|
2694
3408
|
return deny(`Decidio hook: ${read.why} - refusing this tool call (fail closed).`);
|
|
2695
3409
|
const input = read.input;
|
|
2696
3410
|
const tool = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
2697
|
-
const
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
let
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
3411
|
+
const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
3412
|
+
const cwdAbs = resolvePath(typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd());
|
|
3413
|
+
const at = { cwd: cwdAbs, projectDir, home: homedir() };
|
|
3414
|
+
let g;
|
|
3415
|
+
if (tool === "Bash" || tool === "PowerShell") {
|
|
3416
|
+
const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
|
|
3417
|
+
if (!command.trim())
|
|
3418
|
+
return 0;
|
|
3419
|
+
if (hookSelfDecides(command))
|
|
3420
|
+
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");
|
|
3421
|
+
const segs = hookSegments(command);
|
|
3422
|
+
let rule, followed = null;
|
|
3423
|
+
try {
|
|
3424
|
+
rule = hookMatch(command, at);
|
|
3425
|
+
if (!rule) {
|
|
3426
|
+
followed = hookFollow(segs, at);
|
|
3427
|
+
rule = followed?.rule ?? null;
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
catch (e) {
|
|
3431
|
+
return deny(`Decidio hook: ${e?.message ?? e} - every command is refused until it is fixed.`);
|
|
3432
|
+
}
|
|
3433
|
+
if (!rule)
|
|
3434
|
+
return 0;
|
|
3435
|
+
const canonical = command.replace(/\s+/g, " ").trim();
|
|
3436
|
+
const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
|
|
3437
|
+
const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
|
|
3438
|
+
g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs,
|
|
3439
|
+
...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
|
|
3440
|
+
}
|
|
3441
|
+
else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
|
|
3442
|
+
const fc = hookFileGuard(tool, input.tool_input, at);
|
|
3443
|
+
if (!fc)
|
|
3444
|
+
return 0;
|
|
3445
|
+
const what = fc.changes.join("; ");
|
|
3446
|
+
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
3447
|
}
|
|
2707
|
-
|
|
3448
|
+
else
|
|
2708
3449
|
return 0;
|
|
2709
|
-
const
|
|
2710
|
-
|
|
3450
|
+
const w = g.isFile
|
|
3451
|
+
? { 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." }
|
|
3452
|
+
: { 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." };
|
|
3453
|
+
const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
|
|
2711
3454
|
const cfg = hookConfig();
|
|
2712
3455
|
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());
|
|
3456
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project is not connected to Decidio yet. ${w.ran} Run \`npx @decidio/sdk connect\` in ${projectDir} and show the user the link it prints (they allow it on the web), then run \`npx @decidio/sdk connect --wait\`; once it says connected, retry the same ${w.noun}.`, `Decidio: this project is not connected - run npx @decidio/sdk connect`);
|
|
2716
3457
|
const rel = relative(projectDir, cwdAbs);
|
|
2717
3458
|
const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
|
|
2718
3459
|
const session = typeof input.session_id === "string" ? input.session_id : "";
|
|
2719
|
-
const subject = hookSubject(rule.category, hookSegments(command), cwdAbs, projectDir);
|
|
2720
3460
|
let material;
|
|
2721
3461
|
try {
|
|
2722
3462
|
material = bindingMaterial(cfg);
|
|
2723
3463
|
}
|
|
2724
3464
|
catch (e) {
|
|
2725
|
-
return deny(`Decidio hook: this
|
|
3465
|
+
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
3466
|
}
|
|
2727
3467
|
if (!material)
|
|
2728
|
-
return deny(`Decidio hook: this
|
|
2729
|
-
const
|
|
2730
|
-
|
|
2731
|
-
|
|
3468
|
+
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\`.`);
|
|
3469
|
+
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 } : {}) };
|
|
3470
|
+
const card = {
|
|
3471
|
+
...(typeof input.permission_mode === "string" && HOOK_MODES.includes(input.permission_mode) ? { mode: input.permission_mode } : {}),
|
|
3472
|
+
...hookCardGit(g.segs, at), ...(hookAsked(input.transcript_path) ?? {}),
|
|
3473
|
+
};
|
|
3474
|
+
const ctx = { action: "shellExec", amount: 0, scope: boundScope(material, g.binding), context: { ...committed, ...card } };
|
|
3475
|
+
const request = adapterRequest({ ...ctx, context: committed });
|
|
2732
3476
|
let key;
|
|
2733
3477
|
try {
|
|
2734
|
-
key = idempotencyKey(material, { host: "claude-code", tool,
|
|
3478
|
+
key = idempotencyKey(material, { host: "claude-code", tool, ...(g.isFile ? {} : { cwd: cwdRel }), session, ...g.identity });
|
|
2735
3479
|
}
|
|
2736
3480
|
catch (e) {
|
|
2737
|
-
return deny(`Decidio hook: this
|
|
3481
|
+
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
3482
|
}
|
|
2739
3483
|
let auth;
|
|
2740
3484
|
try {
|
|
2741
|
-
let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key });
|
|
3485
|
+
let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, request });
|
|
2742
3486
|
if (a.decision === "duplicate") {
|
|
2743
3487
|
const twin = a.duplicateOf;
|
|
2744
3488
|
const label = twin.displayNumber ?? twin.decisionId;
|
|
@@ -2746,38 +3490,39 @@ async function cmdHookPretool() {
|
|
|
2746
3490
|
const why = twin.reason ? ` ("${twin.reason}")` : "";
|
|
2747
3491
|
const stored = readJustification(twin.decisionId);
|
|
2748
3492
|
if (!stored) {
|
|
2749
|
-
return deny(`Decidio: "${short}" looks like a duplicate of ${label}, which a person refused${when}${why}.
|
|
3493
|
+
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
3494
|
}
|
|
2751
|
-
a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, justification: stored });
|
|
3495
|
+
a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, request, justification: stored });
|
|
2752
3496
|
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"}).
|
|
3497
|
+
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
3498
|
clearJustification(twin.decisionId);
|
|
2755
3499
|
}
|
|
2756
3500
|
auth = a;
|
|
2757
3501
|
}
|
|
2758
3502
|
catch (e) {
|
|
2759
|
-
return deny(`Decidio hook: the approval gate could not be asked about "${short}" (${hookErr(e)}) - refusing to run it (fail closed).
|
|
3503
|
+
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
3504
|
}
|
|
2761
3505
|
const where = decisionUrl(cfg.appUrl, auth.decisionId);
|
|
2762
3506
|
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}`);
|
|
3507
|
+
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
3508
|
if (auth.decision === "proceed") {
|
|
2765
|
-
writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category:
|
|
2766
|
-
|
|
3509
|
+
writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
|
|
3510
|
+
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.`);
|
|
3511
|
+
return 0;
|
|
2767
3512
|
}
|
|
2768
3513
|
const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
|
|
2769
3514
|
const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
|
|
2770
3515
|
const st = held.status;
|
|
2771
3516
|
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"}).
|
|
3517
|
+
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
3518
|
if (st.state === "pending")
|
|
2774
|
-
return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}.
|
|
3519
|
+
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
3520
|
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 ?
|
|
3521
|
+
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
3522
|
const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
|
|
2778
3523
|
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:
|
|
3524
|
+
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.` : ""}`);
|
|
3525
|
+
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
3526
|
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
3527
|
}
|
|
2783
3528
|
catch (e) {
|
|
@@ -2849,7 +3594,7 @@ async function cmdHookWait(id, timeoutSec) {
|
|
|
2849
3594
|
}
|
|
2850
3595
|
const cfg = hookConfig();
|
|
2851
3596
|
if ("missing" in cfg) {
|
|
2852
|
-
err(`✘ ${cfg.missing} -
|
|
3597
|
+
err(`✘ ${cfg.missing} - connect this project first: npx @decidio/sdk connect`);
|
|
2853
3598
|
return 1;
|
|
2854
3599
|
}
|
|
2855
3600
|
const where = decisionUrl(cfg.appUrl, id);
|
|
@@ -2920,6 +3665,14 @@ function cmdHookRules() {
|
|
|
2920
3665
|
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
3666
|
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
3667
|
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.`);
|
|
3668
|
+
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:`);
|
|
3669
|
+
log(` shell scripts bash|sh|zsh|dash|ksh <f>, source <f>, . <f>, ./<f> - every line through the list above`);
|
|
3670
|
+
log(` SQL files psql -f <f>, <database client> ... < <f>, sqlite3 <db> ".read <f>" - DROP, TRUNCATE, DELETE FROM`);
|
|
3671
|
+
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`);
|
|
3672
|
+
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`);
|
|
3673
|
+
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.`);
|
|
3674
|
+
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.`);
|
|
3675
|
+
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
3676
|
return 0;
|
|
2924
3677
|
}
|
|
2925
3678
|
function hookRunner() {
|
|
@@ -2940,8 +3693,10 @@ function hookRunner() {
|
|
|
2940
3693
|
return `npx tsx ${q(real)}`;
|
|
2941
3694
|
return `node ${q(real)}`;
|
|
2942
3695
|
}
|
|
3696
|
+
const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
|
|
3697
|
+
const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
|
|
2943
3698
|
async function cmdHookInstall(opts) {
|
|
2944
|
-
const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
|
|
3699
|
+
const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
|
|
2945
3700
|
let settings = {};
|
|
2946
3701
|
if (existsSync(settingsPath)) {
|
|
2947
3702
|
try {
|
|
@@ -2963,40 +3718,412 @@ async function cmdHookInstall(opts) {
|
|
|
2963
3718
|
const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
|
|
2964
3719
|
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
|
|
2965
3720
|
const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
|
|
2966
|
-
const place = (event, command, extra) => {
|
|
3721
|
+
const place = (event, matcher, iff, command, extra) => {
|
|
2967
3722
|
const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
|
|
2968
|
-
const
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
3723
|
+
const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
|
|
3724
|
+
let state = "added";
|
|
3725
|
+
for (const g of list) {
|
|
3726
|
+
if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
|
|
3727
|
+
continue;
|
|
3728
|
+
g.hooks = g.hooks.flatMap((h) => {
|
|
3729
|
+
if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
|
|
3730
|
+
return [h];
|
|
3731
|
+
if (state !== "added") {
|
|
3732
|
+
state = "updated";
|
|
3733
|
+
return [];
|
|
3734
|
+
}
|
|
3735
|
+
state = JSON.stringify(h) === JSON.stringify(want) ? "kept" : "updated";
|
|
3736
|
+
return [want];
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
3740
|
+
if (state === "added")
|
|
3741
|
+
hooks[event].push({ matcher, hooks: [want] });
|
|
3742
|
+
return state;
|
|
2977
3743
|
};
|
|
2978
|
-
const
|
|
2979
|
-
const
|
|
2980
|
-
|
|
3744
|
+
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3745
|
+
const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
|
|
3746
|
+
const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
|
|
3747
|
+
const guard = { added: 0, updated: 0, kept: 0 };
|
|
3748
|
+
for (const t of HOOK_FILE_TOOLS)
|
|
3749
|
+
for (const p of HOOK_GUARD_PATHS) {
|
|
3750
|
+
guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
|
|
3751
|
+
guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
|
|
3752
|
+
}
|
|
3753
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
2981
3754
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
3755
|
+
lastHookInstall = { settingsPath, pre, post };
|
|
3756
|
+
if (opts.quiet)
|
|
3757
|
+
return 0;
|
|
2982
3758
|
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"}).`);
|
|
3759
|
+
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
3760
|
log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
|
|
2984
3761
|
const env = readEnvFile();
|
|
2985
3762
|
const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
|
|
2986
|
-
if (token)
|
|
3763
|
+
if (token && CREDS_FROM === "connection")
|
|
3764
|
+
log(`✔ credentials: this project's Decidio connection (${process.env.DECIDIO_AGENT_ID}) - kept outside the repository, owner-only.`);
|
|
3765
|
+
else if (token)
|
|
2987
3766
|
log(`✔ agent credentials: ${ENV_PATH} holds DECIDIO_API_TOKEN${(process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) ? " and the agent's signing key" : ""} for "${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || defaultAgentId()}".`);
|
|
2988
3767
|
else
|
|
2989
|
-
log(`⚠
|
|
3768
|
+
log(`⚠ not connected yet - until this project is, EVERY gated command is refused. Run: npx @decidio/sdk connect (or, for your own agent: npx @decidio/sdk init ${defaultAgentId()})`);
|
|
2990
3769
|
log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
|
|
2991
3770
|
log(` Approver: anyone in your workspace with a role other than the agent's owner (the person who ran init) - on the phone app, the web app, Slack or Teams.`);
|
|
2992
3771
|
log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
|
|
2993
3772
|
log(` Flow: a gated command is held up to ${Math.min(Number(process.env.DECIDIO_HOOK_HOLD_MS ?? 90_000) || 90_000, HOOK_HOLD_CAP_MS) / 1000} s for the approver, then refused with its decision id; \`npx @decidio/sdk hook wait <id>\` returns when they decide,`);
|
|
2994
3773
|
log(` and the SAME command retried then runs - once. A lab workspace allows 30 governed actions an hour; a gated command spends two (ask, retry).`);
|
|
2995
|
-
log(` Reload:
|
|
3774
|
+
log(` Reload: none needed - Claude Code picks up the settings file by itself. If a gated command is not asked about, open /hooks once (or restart Claude Code).`);
|
|
2996
3775
|
if (opts.approver !== undefined)
|
|
2997
3776
|
return inviteApprover(opts.approver);
|
|
2998
3777
|
return 0;
|
|
2999
3778
|
}
|
|
3779
|
+
function connectHostLabel(h) { return h === "claude-code" ? "Claude Code" : h; }
|
|
3780
|
+
function connectDir() { return joinPath(sessionDir(), "connections"); }
|
|
3781
|
+
function realOrSame(p) { try {
|
|
3782
|
+
return realPath(p);
|
|
3783
|
+
}
|
|
3784
|
+
catch {
|
|
3785
|
+
return resolvePath(p);
|
|
3786
|
+
} }
|
|
3787
|
+
const sameDir = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
3788
|
+
function projectRoot() {
|
|
3789
|
+
try {
|
|
3790
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
3791
|
+
const top = String(r.stdout ?? "").trim();
|
|
3792
|
+
if (r.status === 0 && top)
|
|
3793
|
+
return realOrSame(top);
|
|
3794
|
+
}
|
|
3795
|
+
catch { }
|
|
3796
|
+
return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
|
|
3797
|
+
}
|
|
3798
|
+
function connectionKey(root) { return createHash("sha256").update(`${API}\n${process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
|
|
3799
|
+
function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
|
|
3800
|
+
function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
|
|
3801
|
+
function readJsonQuiet(f) {
|
|
3802
|
+
try {
|
|
3803
|
+
return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
|
|
3804
|
+
}
|
|
3805
|
+
catch {
|
|
3806
|
+
return null;
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
function loadConnection(root, api) {
|
|
3810
|
+
const c = connectionRecord(root, api);
|
|
3811
|
+
return c && !c.disconnected && c.token ? c : null;
|
|
3812
|
+
}
|
|
3813
|
+
function connectionRecord(root, api) {
|
|
3814
|
+
const c = readJsonQuiet(connectionFile(root));
|
|
3815
|
+
return c && c.v === 1 && c.api === api && c.root === root && typeof c.token === "string" && typeof c.agentId === "string" && typeof c.key === "string" && typeof c.did === "string" ? c : null;
|
|
3816
|
+
}
|
|
3817
|
+
function promoteConnection(c) {
|
|
3818
|
+
process.env.DECIDIO_API_TOKEN = c.token;
|
|
3819
|
+
process.env.DECIDIO_AGENT_ID = c.agentId;
|
|
3820
|
+
process.env.DECIDIO_AGENT_DID = c.did;
|
|
3821
|
+
process.env.DECIDIO_AGENT_KEY = c.key;
|
|
3822
|
+
process.env.DECIDIO_WORKSPACE_ID = c.workspaceId;
|
|
3823
|
+
process.env.DECIDIO_TOKEN_JTI = c.jti;
|
|
3824
|
+
if (c.appUrl && process.env.DECIDIO_APP_URL === undefined)
|
|
3825
|
+
process.env.DECIDIO_APP_URL = c.appUrl;
|
|
3826
|
+
}
|
|
3827
|
+
function inCloudSession() { return SHELL_ENV.CLAUDE_CODE_REMOTE === "true"; }
|
|
3828
|
+
function machineId() {
|
|
3829
|
+
const f = joinPath(sessionDir(), "machine-id");
|
|
3830
|
+
let cur = "";
|
|
3831
|
+
try {
|
|
3832
|
+
cur = existsSync(f) ? readFileSync(f, "utf8").trim() : "";
|
|
3833
|
+
}
|
|
3834
|
+
catch { }
|
|
3835
|
+
if (/^[0-9a-f]{8,32}$/.test(cur))
|
|
3836
|
+
return cur;
|
|
3837
|
+
const id = randomBytes(8).toString("hex");
|
|
3838
|
+
mkdirSync(sessionDir(), { recursive: true });
|
|
3839
|
+
writeFileSync(f, id + "\n");
|
|
3840
|
+
return id;
|
|
3841
|
+
}
|
|
3842
|
+
function machineLabel(cloud) {
|
|
3843
|
+
if (cloud)
|
|
3844
|
+
return `cloud-${machineId().slice(0, 4)}`;
|
|
3845
|
+
const h = (() => { try {
|
|
3846
|
+
return hostname();
|
|
3847
|
+
}
|
|
3848
|
+
catch {
|
|
3849
|
+
return "";
|
|
3850
|
+
} })().split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3851
|
+
return (h || "machine").slice(0, 40);
|
|
3852
|
+
}
|
|
3853
|
+
function projectLabel(root) {
|
|
3854
|
+
try {
|
|
3855
|
+
const r = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
3856
|
+
const m = String(r.stdout ?? "").trim().match(/([^/:\\]+?)(?:\.git)?\/?$/);
|
|
3857
|
+
if (r.status === 0 && m?.[1])
|
|
3858
|
+
return m[1].slice(0, 80);
|
|
3859
|
+
}
|
|
3860
|
+
catch { }
|
|
3861
|
+
try {
|
|
3862
|
+
const pj = JSON.parse(readFileSync(joinPath(root, "package.json"), "utf8"));
|
|
3863
|
+
if (typeof pj?.name === "string" && pj.name)
|
|
3864
|
+
return pj.name.replace(/^@[^/]+\//, "").slice(0, 80);
|
|
3865
|
+
}
|
|
3866
|
+
catch { }
|
|
3867
|
+
return (basename(root) || "project").slice(0, 80);
|
|
3868
|
+
}
|
|
3869
|
+
function localDesktop() {
|
|
3870
|
+
if (inCloudSession() || SHELL_ENV.CI || SHELL_ENV.SSH_CONNECTION || SHELL_ENV.SSH_TTY || SHELL_ENV.CODESPACES || SHELL_ENV.GITPOD_WORKSPACE_ID || SHELL_ENV.DECIDIO_NO_BROWSER)
|
|
3871
|
+
return false;
|
|
3872
|
+
if (process.platform === "darwin" || process.platform === "win32")
|
|
3873
|
+
return true;
|
|
3874
|
+
return !!(SHELL_ENV.DISPLAY || SHELL_ENV.WAYLAND_DISPLAY);
|
|
3875
|
+
}
|
|
3876
|
+
function openInBrowser(url) {
|
|
3877
|
+
if (!/^https?:\/\/[^\s"'<>`]+$/.test(url))
|
|
3878
|
+
return false;
|
|
3879
|
+
try {
|
|
3880
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
3881
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
3882
|
+
child.on("error", () => { });
|
|
3883
|
+
child.unref();
|
|
3884
|
+
return true;
|
|
3885
|
+
}
|
|
3886
|
+
catch {
|
|
3887
|
+
return false;
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
const CONNECT_WAIT_S = () => { const n = Number(process.env.DECIDIO_CONNECT_WAIT_S ?? 90); return Number.isFinite(n) && n >= 1 && n <= 110 ? n : 90; };
|
|
3891
|
+
let lastHookInstall = null;
|
|
3892
|
+
async function startConnect(root, host) {
|
|
3893
|
+
const prior = connectionRecord(root, API);
|
|
3894
|
+
const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
|
|
3895
|
+
const cloud = inCloudSession();
|
|
3896
|
+
const project = projectLabel(root).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
|
|
3897
|
+
const proof = signBoundProof(kp.privateKeyBase64, { agentId: "connect", did: kp.did, action: "connect.start", amount: 0, scope: [host, project, machine, cloud ? "cloud" : "local"].join("\n") });
|
|
3898
|
+
const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
|
|
3899
|
+
if (r.status === 0) {
|
|
3900
|
+
err(`✘ couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
|
|
3901
|
+
return null;
|
|
3902
|
+
}
|
|
3903
|
+
if (r.status === 404 || r.status === 401) {
|
|
3904
|
+
err(`✘ ${r.body?.message ?? "Connecting a coding assistant is available in the Decidio lab only."} (${API})`);
|
|
3905
|
+
return null;
|
|
3906
|
+
}
|
|
3907
|
+
if (r.status === 429) {
|
|
3908
|
+
err(`✘ ${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
|
|
3909
|
+
return null;
|
|
3910
|
+
}
|
|
3911
|
+
if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string") {
|
|
3912
|
+
err(`✘ couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
|
|
3913
|
+
return null;
|
|
3914
|
+
}
|
|
3915
|
+
const p = { v: 1, api: API, root, project, host, machine, cloud, did: kp.did, key: kp.privateKeyBase64, code: String(r.body.code), link: String(r.body.link),
|
|
3916
|
+
pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
|
|
3917
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
3918
|
+
writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
|
|
3919
|
+
if (typeof r.body.qr === "string")
|
|
3920
|
+
pendingQr = r.body.qr;
|
|
3921
|
+
return p;
|
|
3922
|
+
}
|
|
3923
|
+
let pendingQr = null;
|
|
3924
|
+
function readPendingConnect(root) {
|
|
3925
|
+
const p = readJsonQuiet(pendingConnectFile(root));
|
|
3926
|
+
return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
|
|
3927
|
+
}
|
|
3928
|
+
function dropFile(f) { try {
|
|
3929
|
+
rmSync(f, { force: true });
|
|
3930
|
+
}
|
|
3931
|
+
catch { } }
|
|
3932
|
+
const minutesLeft = (iso) => Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
|
|
3933
|
+
function showConnectLink(p, opened, tty) {
|
|
3934
|
+
const title = `Connect ${connectHostLabel(p.host)} to Decidio`;
|
|
3935
|
+
log(`${brand()} ${bold(title)}`);
|
|
3936
|
+
log(` Open: ${link(p.link)}`);
|
|
3937
|
+
log(` Code: ${bold(p.code)} ${dim("(the page shows the same code - only allow it if they match)")}`);
|
|
3938
|
+
log(dim(` ${p.project} · ${p.machine} · the link works for ${minutesLeft(p.expiresAt) || 1} more minute${minutesLeft(p.expiresAt) === 1 ? "" : "s"}${opened ? " · opened in your browser" : ""}`));
|
|
3939
|
+
if (tty && pendingQr) {
|
|
3940
|
+
log("");
|
|
3941
|
+
for (const line of pendingQr.split("\n"))
|
|
3942
|
+
if (line.trim())
|
|
3943
|
+
log(` ${line}`);
|
|
3944
|
+
log(dim(" Scan with your phone's camera to allow it there."));
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
async function cmdConnect(opts) {
|
|
3948
|
+
const host = (opts.host ?? "claude-code");
|
|
3949
|
+
if (host !== "claude-code") {
|
|
3950
|
+
err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
|
|
3951
|
+
return 2;
|
|
3952
|
+
}
|
|
3953
|
+
try {
|
|
3954
|
+
assertSafeTransport(API, true);
|
|
3955
|
+
}
|
|
3956
|
+
catch (e) {
|
|
3957
|
+
err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
|
|
3958
|
+
return 1;
|
|
3959
|
+
}
|
|
3960
|
+
const root = projectRoot();
|
|
3961
|
+
const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
3962
|
+
if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
|
|
3963
|
+
log(`• this project already has Decidio credentials ${CREDS_FROM === "shell" ? "from your shell" : `in ${ENV_PATH}`} for "${process.env.DECIDIO_AGENT_ID ?? defaultAgentId()}" - the hook uses those, so connect leaves them alone.`);
|
|
3964
|
+
log(dim(` To use a connection instead, remove DECIDIO_API_TOKEN / DECIDIO_AGENT_ID from ${CREDS_FROM === "shell" ? "your shell" : ENV_PATH} and run npx @decidio/sdk connect again.`));
|
|
3965
|
+
return finishConnect(root, null, opts, false);
|
|
3966
|
+
}
|
|
3967
|
+
const existing = loadConnection(root, API);
|
|
3968
|
+
if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
|
|
3969
|
+
return finishConnect(root, existing, opts, false);
|
|
3970
|
+
if (opts.wait)
|
|
3971
|
+
return waitConnect(root, opts, false);
|
|
3972
|
+
let p = readPendingConnect(root);
|
|
3973
|
+
if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
|
|
3974
|
+
p = await startConnect(root, host);
|
|
3975
|
+
if (!p)
|
|
3976
|
+
return 1;
|
|
3977
|
+
const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
|
|
3978
|
+
showConnectLink(p, opened, tty);
|
|
3979
|
+
if (tty) {
|
|
3980
|
+
log("");
|
|
3981
|
+
return waitConnect(root, opts, true);
|
|
3982
|
+
}
|
|
3983
|
+
log("");
|
|
3984
|
+
log(`Next: allow it at the link above, then run ${bold("npx @decidio/sdk connect --wait")} - it finishes by itself (nothing else to type).`);
|
|
3985
|
+
log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
|
|
3986
|
+
return 0;
|
|
3987
|
+
}
|
|
3988
|
+
async function waitConnect(root, opts, interactive) {
|
|
3989
|
+
const p = readPendingConnect(root);
|
|
3990
|
+
if (!p) {
|
|
3991
|
+
const c = loadConnection(root, API);
|
|
3992
|
+
if (c)
|
|
3993
|
+
return finishConnect(root, c, opts, false);
|
|
3994
|
+
err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
|
|
3995
|
+
return 1;
|
|
3996
|
+
}
|
|
3997
|
+
const deadline = interactive ? new Date(p.expiresAt).getTime() + 5_000 : Math.min(new Date(p.expiresAt).getTime() + 5_000, Date.now() + CONNECT_WAIT_S() * 1000);
|
|
3998
|
+
const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3999
|
+
let i = 0, misses = 0;
|
|
4000
|
+
if (!interactive)
|
|
4001
|
+
err(`· waiting for the connection to be allowed at ${p.link} (code ${p.code}; up to ${Math.round((deadline - Date.now()) / 1000)} s)`);
|
|
4002
|
+
while (Date.now() < deadline) {
|
|
4003
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
|
|
4004
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4005
|
+
if (interactive && TTY_OUT)
|
|
4006
|
+
process.stderr.write("\r\x1b[K");
|
|
4007
|
+
const c = { v: 1, api: API, root, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(r.body.agentId), did: p.did, key: p.key,
|
|
4008
|
+
token: r.body.token, jti: String(r.body.jti), expiresAt: String(r.body.expiresAt), workspaceId: String(r.body.workspaceId), workspaceName: r.body.workspace?.name ?? null,
|
|
4009
|
+
approver: r.body.approver?.name ?? null, approvedBy: r.body.approvedBy?.name ?? null, appUrl: typeof r.body.appUrl === "string" ? r.body.appUrl : null, connectedAt: new Date().toISOString() };
|
|
4010
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4011
|
+
try {
|
|
4012
|
+
writeSecretFile(connectionFile(root), JSON.stringify(c) + "\n");
|
|
4013
|
+
}
|
|
4014
|
+
catch (e) {
|
|
4015
|
+
err(`✘ connected, but this machine's credentials could not be written (${e?.message ?? e}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
|
|
4016
|
+
return 1;
|
|
4017
|
+
}
|
|
4018
|
+
dropFile(pendingConnectFile(root));
|
|
4019
|
+
return finishConnect(root, c, opts, true);
|
|
4020
|
+
}
|
|
4021
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4022
|
+
dropFile(pendingConnectFile(root));
|
|
4023
|
+
if (interactive && TTY_OUT)
|
|
4024
|
+
process.stderr.write("\r\x1b[K");
|
|
4025
|
+
err("✘ the connection was refused on the web (Not me). Nothing was saved.");
|
|
4026
|
+
return 3;
|
|
4027
|
+
}
|
|
4028
|
+
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4029
|
+
dropFile(pendingConnectFile(root));
|
|
4030
|
+
if (interactive && TTY_OUT)
|
|
4031
|
+
process.stderr.write("\r\x1b[K");
|
|
4032
|
+
err(r.body.status === "collected" ? "✘ this connection was already collected by another run - start a fresh one: npx @decidio/sdk connect" : "✘ the link expired before it was allowed - start a fresh one: npx @decidio/sdk connect");
|
|
4033
|
+
return 3;
|
|
4034
|
+
}
|
|
4035
|
+
if (r.status === 0 && ++misses > 10) {
|
|
4036
|
+
err(`✘ lost touch with Decidio at ${API} - check your connection, then run npx @decidio/sdk connect --wait again.`);
|
|
4037
|
+
return 1;
|
|
4038
|
+
}
|
|
4039
|
+
if (interactive && TTY_OUT)
|
|
4040
|
+
process.stderr.write(`\r${spin[i++ % spin.length]} waiting for you to allow it in the browser… ${dim(`(${minutesLeft(p.expiresAt)} min left · Ctrl-C stops waiting; npx @decidio/sdk connect --wait picks it up again)`)}`);
|
|
4041
|
+
await new Promise((res) => setTimeout(res, (r.status === 429 ? 2 : 1) * Math.max(1, p.interval) * 1000));
|
|
4042
|
+
}
|
|
4043
|
+
if (interactive && TTY_OUT)
|
|
4044
|
+
process.stderr.write("\r\x1b[K");
|
|
4045
|
+
log(`• still waiting for it to be allowed: ${link(p.link)} (code ${p.code}, ${minutesLeft(p.expiresAt)} min left). Run npx @decidio/sdk connect --wait again once it is.`);
|
|
4046
|
+
return 1;
|
|
4047
|
+
}
|
|
4048
|
+
async function finishConnect(root, c, opts, fresh) {
|
|
4049
|
+
if (c) {
|
|
4050
|
+
promoteConnection(c);
|
|
4051
|
+
CREDS_FROM = "connection";
|
|
4052
|
+
}
|
|
4053
|
+
const cloud = inCloudSession();
|
|
4054
|
+
const shared = opts.team || cloud;
|
|
4055
|
+
const plugin = !cloud && decidioPluginEnabled(root);
|
|
4056
|
+
if (!plugin) {
|
|
4057
|
+
const rc = await cmdHookInstall({ project: shared, dir: root, quiet: true });
|
|
4058
|
+
if (rc !== 0)
|
|
4059
|
+
return rc;
|
|
4060
|
+
}
|
|
4061
|
+
const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
|
|
4062
|
+
const verified = probe.status === 404;
|
|
4063
|
+
const settings = lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
|
|
4064
|
+
const hostName = connectHostLabel(c?.host ?? "claude-code");
|
|
4065
|
+
if (c) {
|
|
4066
|
+
log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${c.workspaceName ? ` - workspace ${c.workspaceName}` : ""}${fresh && c.approvedBy ? `, allowed by ${c.approvedBy}` : ""}`);
|
|
4067
|
+
log(`✔ Approvals go to ${c.approver ?? "the approver you picked"} - on the phone app, Slack, Teams or the web`);
|
|
4068
|
+
}
|
|
4069
|
+
if (!verified) {
|
|
4070
|
+
err(probe.status === 401 || probe.status === 403
|
|
4071
|
+
? `✘ Decidio refused this connection's token (${probe.status}) - it was revoked or has expired. Reconnect: npx @decidio/sdk connect --force`
|
|
4072
|
+
: `⚠ couldn't check the token just now (${probe.status || "no answer"}) - the hooks are on; a gated command will say if anything is wrong.`);
|
|
4073
|
+
if (probe.status === 401 || probe.status === 403)
|
|
4074
|
+
return 1;
|
|
4075
|
+
}
|
|
4076
|
+
const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
|
|
4077
|
+
log(plugin
|
|
4078
|
+
? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
|
|
4079
|
+
: `✔ ${hostName} asks before destructive commands in this project (${settings})`);
|
|
4080
|
+
if (changed)
|
|
4081
|
+
log(dim(` Claude Code picks the settings change up by itself - if a destructive command is not asked about, open /hooks once (or restart it).`));
|
|
4082
|
+
if (c)
|
|
4083
|
+
log(dim(` Credentials: ${connectionFile(root)} (outside the repository, owner-only) · until ${c.expiresAt.slice(0, 10)}${cloud ? " · this cloud machine only - a fresh one refuses destructive commands until it connects again" : ""}`));
|
|
4084
|
+
if (!plugin && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
|
|
4085
|
+
log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
|
|
4086
|
+
if (cloud && !opts.team)
|
|
4087
|
+
log(`• A cloud session reads only the repository's ${settings}, so the hooks are there - not committed. Ask the person before committing it: committed, every cloud session of this repository asks too, and so does every teammate's clone (a teammate connects with npx @decidio/sdk connect).`);
|
|
4088
|
+
else if (shared)
|
|
4089
|
+
log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
|
|
4090
|
+
if (fresh)
|
|
4091
|
+
log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${c?.approver ?? "the approver"} on their phone.`));
|
|
4092
|
+
return 0;
|
|
4093
|
+
}
|
|
4094
|
+
const DECIDIO_PLUGIN = "decidio-approvals";
|
|
4095
|
+
function decidioPluginEnabled(root) {
|
|
4096
|
+
const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
4097
|
+
for (const f of [joinPath(root, ".claude", "settings.local.json"), joinPath(root, ".claude", "settings.json"), joinPath(home, ".claude", "settings.json")]) {
|
|
4098
|
+
const ep = readJsonQuiet(f)?.enabledPlugins;
|
|
4099
|
+
if (!ep || typeof ep !== "object" || Array.isArray(ep))
|
|
4100
|
+
continue;
|
|
4101
|
+
const named = Object.entries(ep).find(([k]) => k.split("@")[0] === DECIDIO_PLUGIN);
|
|
4102
|
+
if (named)
|
|
4103
|
+
return named[1] === true;
|
|
4104
|
+
}
|
|
4105
|
+
return false;
|
|
4106
|
+
}
|
|
4107
|
+
async function cmdDisconnect() {
|
|
4108
|
+
const root = projectRoot();
|
|
4109
|
+
const c = loadConnection(root, API);
|
|
4110
|
+
dropFile(pendingConnectFile(root));
|
|
4111
|
+
if (!c) {
|
|
4112
|
+
log("• this project isn't connected on this machine - nothing to do.");
|
|
4113
|
+
return 0;
|
|
4114
|
+
}
|
|
4115
|
+
try {
|
|
4116
|
+
writeSecretFile(connectionFile(root), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
|
|
4117
|
+
}
|
|
4118
|
+
catch (e) {
|
|
4119
|
+
err(`✘ couldn't update ${connectionFile(root)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
|
|
4120
|
+
return 1;
|
|
4121
|
+
}
|
|
4122
|
+
log(`✔ disconnected this project on this machine - its token is deleted.`);
|
|
4123
|
+
log(` Destructive commands are now refused until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
|
|
4124
|
+
log(dim(` "${c.agentId}" stays listed in Decidio until its token expires (${c.expiresAt.slice(0, 10)}); to end it now, revoke it on the Agents page${c.appUrl ? `: ${c.appUrl.replace(/\/+$/, "")}/connect/agents` : ""}.`));
|
|
4125
|
+
return 0;
|
|
4126
|
+
}
|
|
3000
4127
|
async function inviteApprover(email) {
|
|
3001
4128
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
3002
4129
|
err(`✘ --approver needs an email address (got "${email}")`);
|
|
@@ -3683,6 +4810,303 @@ async function cmdGithubRequest(environment, justification) {
|
|
|
3683
4810
|
err(`✘ Decidio refused the request (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
|
|
3684
4811
|
return 1;
|
|
3685
4812
|
}
|
|
4813
|
+
const DEPLOY_MARK = { deployed: "✔", refused: "✘", blocked: "✘", stopped: "✘", failed: "✘", checks_failed: "✘", checks_unreadable: "✘", superseded: "·", expired: "·", silent: "·" };
|
|
4814
|
+
export function deployLines(r) {
|
|
4815
|
+
const mark = DEPLOY_MARK[r.state] ?? "…";
|
|
4816
|
+
const when = (r.deployedAt ?? r.requestedAt).slice(0, 16).replace("T", " ");
|
|
4817
|
+
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`;
|
|
4818
|
+
const out = [head];
|
|
4819
|
+
if (r.checks && r.state !== "deployed")
|
|
4820
|
+
out.push(` ${r.checks}`);
|
|
4821
|
+
if (r.range)
|
|
4822
|
+
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");
|
|
4823
|
+
return out;
|
|
4824
|
+
}
|
|
4825
|
+
export function deployChanges(before, after) {
|
|
4826
|
+
const was = new Map(before.map((r) => [r.decisionId, r]));
|
|
4827
|
+
const out = [];
|
|
4828
|
+
for (const r of [...after].reverse()) {
|
|
4829
|
+
const p = was.get(r.decisionId);
|
|
4830
|
+
const name = `${r.dNumber ?? r.decisionId.slice(-6)} ${r.environment} ${r.sha.slice(0, 7)}`;
|
|
4831
|
+
if (!p)
|
|
4832
|
+
out.push(`+ ${name}: ${r.line}`);
|
|
4833
|
+
else if (p.line !== r.line)
|
|
4834
|
+
out.push(`→ ${name}: ${p.line} → ${r.line}`);
|
|
4835
|
+
}
|
|
4836
|
+
return out;
|
|
4837
|
+
}
|
|
4838
|
+
async function cmdDeploys(ref, o) {
|
|
4839
|
+
if (ref && !/^(D[·.-]?\d{4,}|c[a-z0-9]{20,40})$/i.test(ref)) {
|
|
4840
|
+
err(`✘ "${ref}" is not a deploy's record id (D·0042) or decision id.`);
|
|
4841
|
+
return 2;
|
|
4842
|
+
}
|
|
4843
|
+
if (o.repo && !/^[\w.-]+\/[\w.-]+$/.test(o.repo)) {
|
|
4844
|
+
err(`✘ --repo takes owner/name.`);
|
|
4845
|
+
return 2;
|
|
4846
|
+
}
|
|
4847
|
+
if (o.limit && !/^\d{1,2}$/.test(o.limit)) {
|
|
4848
|
+
err(`✘ --limit takes a number from 1 to 50.`);
|
|
4849
|
+
return 2;
|
|
4850
|
+
}
|
|
4851
|
+
if (ref && (o.watch || o.environment || o.repo || o.limit)) {
|
|
4852
|
+
err(`✘ one deploy (${ref}) takes no --watch, --environment, --repo or --limit - refusing to ignore a flag you meant to matter.`);
|
|
4853
|
+
return 2;
|
|
4854
|
+
}
|
|
4855
|
+
if (o.watch && o.json) {
|
|
4856
|
+
err(`✘ --watch and --json do not go together (--json is a single read, for a script).`);
|
|
4857
|
+
return 2;
|
|
4858
|
+
}
|
|
4859
|
+
const qs = new URLSearchParams();
|
|
4860
|
+
if (o.environment)
|
|
4861
|
+
qs.set("environment", o.environment);
|
|
4862
|
+
if (o.repo)
|
|
4863
|
+
qs.set("repository", o.repo);
|
|
4864
|
+
if (o.limit)
|
|
4865
|
+
qs.set("limit", o.limit);
|
|
4866
|
+
const path = ref ? `/api/github/deploys/${encodeURIComponent(ref)}` : `/api/github/deploys${qs.size ? `?${qs}` : ""}`;
|
|
4867
|
+
let bearer;
|
|
4868
|
+
let r = await api(path);
|
|
4869
|
+
if (r.status === 401 || r.status === 403) {
|
|
4870
|
+
const session = await acquireSession("Reading deploys");
|
|
4871
|
+
if (!session)
|
|
4872
|
+
return 1;
|
|
4873
|
+
bearer = session;
|
|
4874
|
+
r = await api(path, {}, bearer);
|
|
4875
|
+
}
|
|
4876
|
+
if (r.status === 404) {
|
|
4877
|
+
err(`✘ ${ref ? `no deploy "${ref}" you can read in this workspace` : "GitHub deploy approvals are not set up here"}.`);
|
|
4878
|
+
return 1;
|
|
4879
|
+
}
|
|
4880
|
+
if (r.status !== 200) {
|
|
4881
|
+
err(`✘ could not read deploys (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
4882
|
+
return 1;
|
|
4883
|
+
}
|
|
4884
|
+
if (ref) {
|
|
4885
|
+
const d = r.body?.deploy;
|
|
4886
|
+
if (o.json) {
|
|
4887
|
+
log(JSON.stringify(d, null, 2));
|
|
4888
|
+
return 0;
|
|
4889
|
+
}
|
|
4890
|
+
for (const l of deployLines(d))
|
|
4891
|
+
log(l);
|
|
4892
|
+
const s = d?.shipping;
|
|
4893
|
+
if (s) {
|
|
4894
|
+
log("");
|
|
4895
|
+
if (s.line)
|
|
4896
|
+
log(` ${s.line}`);
|
|
4897
|
+
for (const p of s.pulls ?? [])
|
|
4898
|
+
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(", ")}` : ""}`);
|
|
4899
|
+
if (s.redacted)
|
|
4900
|
+
log(dim(" titles and names are shown to the people on this request"));
|
|
4901
|
+
if (s.directPushes)
|
|
4902
|
+
log(` ${s.directPushes} direct push${s.directPushes === 1 ? "" : "es"} with no pull request - files not read`);
|
|
4903
|
+
}
|
|
4904
|
+
log(dim(` ${link(d.recordUrl)}${d.runUrl ? ` · run: ${link(d.runUrl)}` : ""}`));
|
|
4905
|
+
return 0;
|
|
4906
|
+
}
|
|
4907
|
+
let rows = Array.isArray(r.body?.deploys) ? r.body.deploys : [];
|
|
4908
|
+
if (o.json) {
|
|
4909
|
+
log(JSON.stringify(rows, null, 2));
|
|
4910
|
+
return 0;
|
|
4911
|
+
}
|
|
4912
|
+
if (!rows.length)
|
|
4913
|
+
log("No deploy requests you can read yet.");
|
|
4914
|
+
for (const row of rows)
|
|
4915
|
+
for (const l of deployLines(row))
|
|
4916
|
+
log(l);
|
|
4917
|
+
if (!o.watch)
|
|
4918
|
+
return 0;
|
|
4919
|
+
log(dim("\nWatching for changes every 10 s - Ctrl+C to stop."));
|
|
4920
|
+
for (;;) {
|
|
4921
|
+
await new Promise((res) => setTimeout(res, 10_000));
|
|
4922
|
+
const next = await api(path, {}, bearer, true);
|
|
4923
|
+
if (next.status !== 200 || !Array.isArray(next.body?.deploys))
|
|
4924
|
+
continue;
|
|
4925
|
+
for (const l of deployChanges(rows, next.body.deploys))
|
|
4926
|
+
log(`${new Date().toISOString().slice(11, 19)} ${l}`);
|
|
4927
|
+
rows = next.body.deploys;
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4930
|
+
export function parseWhen(s, now = new Date()) {
|
|
4931
|
+
if (!s)
|
|
4932
|
+
return null;
|
|
4933
|
+
if (s === "now")
|
|
4934
|
+
return now;
|
|
4935
|
+
const m = /^(\d{1,4})([mhd])$/.exec(s);
|
|
4936
|
+
if (m)
|
|
4937
|
+
return new Date(now.getTime() + Number(m[1]) * (m[2] === "m" ? 60_000 : m[2] === "h" ? 3_600_000 : 86_400_000));
|
|
4938
|
+
return /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s)) ? new Date(s) : null;
|
|
4939
|
+
}
|
|
4940
|
+
export function controlsLines(b) {
|
|
4941
|
+
const t = (iso) => (iso ? iso.slice(0, 16).replace("T", " ") + " UTC" : "until lifted");
|
|
4942
|
+
const out = [];
|
|
4943
|
+
for (const h of b.halts ?? [])
|
|
4944
|
+
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})`);
|
|
4945
|
+
for (const g of b.grants ?? [])
|
|
4946
|
+
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}`}`);
|
|
4947
|
+
if (!out.length)
|
|
4948
|
+
out.push("No halts, freezes or firefighter grants in force or scheduled.");
|
|
4949
|
+
return out;
|
|
4950
|
+
}
|
|
4951
|
+
async function sessionCall(purpose, path, init = {}) {
|
|
4952
|
+
let r = await api(path, init);
|
|
4953
|
+
if (r.status === 401 || (r.status === 403 && r.body?.code === "SIGN_IN")) {
|
|
4954
|
+
const session = await acquireSession(purpose);
|
|
4955
|
+
if (!session)
|
|
4956
|
+
return null;
|
|
4957
|
+
r = await api(path, init, session);
|
|
4958
|
+
}
|
|
4959
|
+
return r;
|
|
4960
|
+
}
|
|
4961
|
+
const scopeOf = (v) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : null);
|
|
4962
|
+
async function cmdControls(json) {
|
|
4963
|
+
const r = await sessionCall("Reading controls", "/api/controls");
|
|
4964
|
+
if (!r)
|
|
4965
|
+
return 1;
|
|
4966
|
+
if (r.status !== 200) {
|
|
4967
|
+
err(`✘ could not read controls (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
4968
|
+
return 1;
|
|
4969
|
+
}
|
|
4970
|
+
if (json) {
|
|
4971
|
+
log(JSON.stringify(r.body, null, 2));
|
|
4972
|
+
return 0;
|
|
4973
|
+
}
|
|
4974
|
+
for (const l of controlsLines(r.body))
|
|
4975
|
+
log(l);
|
|
4976
|
+
return 0;
|
|
4977
|
+
}
|
|
4978
|
+
async function cmdHalt(kind, o) {
|
|
4979
|
+
const scope = scopeOf(o.scope);
|
|
4980
|
+
if (!scope) {
|
|
4981
|
+
err(`✘ ${kind} needs --scope (owner/repo, owner/repo:environment, agent:<id>, deploys or *; comma-separated).`);
|
|
4982
|
+
return 2;
|
|
4983
|
+
}
|
|
4984
|
+
if (!o.reason) {
|
|
4985
|
+
err(`✘ ${kind} needs --reason (incident, release_freeze, maintenance, security, audit or other).`);
|
|
4986
|
+
return 2;
|
|
4987
|
+
}
|
|
4988
|
+
const now = new Date();
|
|
4989
|
+
const from = kind === "freeze" ? parseWhen(o.from ?? "now", now) : null, until = parseWhen(o.until, now);
|
|
4990
|
+
if ((kind === "freeze" && (!from || !until)) || (o.until && !until)) {
|
|
4991
|
+
err(`✘ --from and --until take an ISO date, now, or a duration like 30m, 2h or 1d${kind === "freeze" ? " (a freeze needs both)" : ""}.`);
|
|
4992
|
+
return 2;
|
|
4993
|
+
}
|
|
4994
|
+
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 }) });
|
|
4995
|
+
if (!r)
|
|
4996
|
+
return 1;
|
|
4997
|
+
if (r.status !== 201) {
|
|
4998
|
+
err(`✘ ${kind} not set (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
4999
|
+
return 1;
|
|
5000
|
+
}
|
|
5001
|
+
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}`);
|
|
5002
|
+
return 0;
|
|
5003
|
+
}
|
|
5004
|
+
async function cmdLift(id) {
|
|
5005
|
+
if (!id || !/^[a-z0-9]{10,40}$/.test(id)) {
|
|
5006
|
+
err("✘ lift takes a halt's or freeze's id (npx @decidio/sdk controls lists them).");
|
|
5007
|
+
return 2;
|
|
5008
|
+
}
|
|
5009
|
+
const r = await sessionCall("Lifting a halt", `/api/controls/halts/${encodeURIComponent(id)}/lift`, { method: "POST", body: "{}" });
|
|
5010
|
+
if (!r)
|
|
5011
|
+
return 1;
|
|
5012
|
+
if (r.status !== 200) {
|
|
5013
|
+
err(`✘ not lifted (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
5014
|
+
return 1;
|
|
5015
|
+
}
|
|
5016
|
+
log(r.body.lifted ? `✔ ${id} lifted.` : `• ${id} was already lifted or over - nothing changed.`);
|
|
5017
|
+
return 0;
|
|
5018
|
+
}
|
|
5019
|
+
async function cmdFirefighter(sub, id, o) {
|
|
5020
|
+
if (sub === "end") {
|
|
5021
|
+
if (!id || !/^[a-z0-9]{10,40}$/.test(id)) {
|
|
5022
|
+
err("✘ firefighter end takes the grant's id.");
|
|
5023
|
+
return 2;
|
|
5024
|
+
}
|
|
5025
|
+
const r = await sessionCall("Ending a firefighter grant", `/api/firefighter/${encodeURIComponent(id)}/end`, { method: "POST", body: "{}" });
|
|
5026
|
+
if (!r)
|
|
5027
|
+
return 1;
|
|
5028
|
+
if (r.status !== 200) {
|
|
5029
|
+
err(`✘ not ended (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
5030
|
+
return 1;
|
|
5031
|
+
}
|
|
5032
|
+
log(r.body.ended ? `✔ grant ${id} ended - its close-out review follows.` : `• grant ${id} was not active - nothing changed.`);
|
|
5033
|
+
return 0;
|
|
5034
|
+
}
|
|
5035
|
+
if (sub) {
|
|
5036
|
+
err(`✘ firefighter takes --scope --minutes --reason, or: firefighter end <id>.`);
|
|
5037
|
+
return 2;
|
|
5038
|
+
}
|
|
5039
|
+
const scope = scopeOf(o.scope), minutes = o.minutes && /^\d{1,3}$/.test(o.minutes) ? Number(o.minutes) : null;
|
|
5040
|
+
if (!scope || !minutes || !o.reason) {
|
|
5041
|
+
err("✘ firefighter needs --scope (systems), --minutes (15 to 240) and --reason (what is on fire).");
|
|
5042
|
+
return 2;
|
|
5043
|
+
}
|
|
5044
|
+
const r = await sessionCall("Asking for a firefighter grant", "/api/firefighter", { method: "POST", body: JSON.stringify({ scope, minutes, reason: o.reason }) });
|
|
5045
|
+
if (!r)
|
|
5046
|
+
return 1;
|
|
5047
|
+
if (r.status !== 201) {
|
|
5048
|
+
err(`✘ not asked (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
5049
|
+
return 1;
|
|
5050
|
+
}
|
|
5051
|
+
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.`);
|
|
5052
|
+
log(dim(` ${link(r.body.url)}`));
|
|
5053
|
+
return 0;
|
|
5054
|
+
}
|
|
5055
|
+
function originIs(repo) {
|
|
5056
|
+
try {
|
|
5057
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5_000 }).trim().toLowerCase().replace(/\.git$/, "");
|
|
5058
|
+
return url.endsWith(`/${repo.toLowerCase()}`) || url.endsWith(`:${repo.toLowerCase()}`);
|
|
5059
|
+
}
|
|
5060
|
+
catch {
|
|
5061
|
+
return false;
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
async function cmdRollback(o) {
|
|
5065
|
+
if (!o.repo || !/^[\w.-]+\/[\w.-]+$/.test(o.repo) || !o.environment || !/^[A-Za-z0-9._-]{1,64}$/.test(o.environment)) {
|
|
5066
|
+
err("✘ rollback needs --repo owner/name and --environment <name>.");
|
|
5067
|
+
return 2;
|
|
5068
|
+
}
|
|
5069
|
+
const q = `repository=${encodeURIComponent(o.repo)}&environment=${encodeURIComponent(o.environment)}`;
|
|
5070
|
+
const t = await sessionCall("Asking for a rollback", `/api/github/rollback?${q}`);
|
|
5071
|
+
if (!t)
|
|
5072
|
+
return 1;
|
|
5073
|
+
if (t.status !== 200) {
|
|
5074
|
+
err(`✘ no rollback (${t.status}): ${String(t.body?.error ?? "no reason given")}`);
|
|
5075
|
+
return 1;
|
|
5076
|
+
}
|
|
5077
|
+
const sha = String(t.body.sha), branch = String(t.body.branch);
|
|
5078
|
+
if (!/^[0-9a-f]{40}$/.test(sha) || !/^decidio-rollback\/[A-Za-z0-9._-]+$/.test(branch)) {
|
|
5079
|
+
err("✘ the server's rollback target is not one this CLI can push.");
|
|
5080
|
+
return 1;
|
|
5081
|
+
}
|
|
5082
|
+
if (!o.noPush) {
|
|
5083
|
+
if (!originIs(o.repo)) {
|
|
5084
|
+
err(`✘ run this inside a checkout of ${o.repo}, or push the branch yourself and add --no-push:\n ${t.body.push}`);
|
|
5085
|
+
return 1;
|
|
5086
|
+
}
|
|
5087
|
+
const git = (args) => spawnSync("git", args, { stdio: ["ignore", "inherit", "inherit"], timeout: 120_000 });
|
|
5088
|
+
if (spawnSync("git", ["cat-file", "-e", `${sha}^{commit}`], { stdio: "ignore", timeout: 10_000 }).status !== 0)
|
|
5089
|
+
git(["fetch", "--quiet", "origin"]);
|
|
5090
|
+
const pushed = git(["push", "origin", `${sha}:refs/heads/${branch}`]);
|
|
5091
|
+
if (pushed.status !== 0) {
|
|
5092
|
+
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`);
|
|
5093
|
+
return 1;
|
|
5094
|
+
}
|
|
5095
|
+
log(dim(` pushed ${branch} at ${sha.slice(0, 7)}`));
|
|
5096
|
+
}
|
|
5097
|
+
const r = await sessionCall("Asking for a rollback", "/api/github/rollback", { method: "POST", body: JSON.stringify({ repository: o.repo, environment: o.environment }) });
|
|
5098
|
+
if (!r)
|
|
5099
|
+
return 1;
|
|
5100
|
+
if (r.status !== 201) {
|
|
5101
|
+
err(`✘ no rollback (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
|
|
5102
|
+
return 1;
|
|
5103
|
+
}
|
|
5104
|
+
log(r.body.verdict === "proceed" ? `✔ rolling ${o.environment} back to ${sha.slice(0, 7)} under your firefighter grant (${r.body.displayNumber}).`
|
|
5105
|
+
: r.body.verdict === "block" ? `✘ the rollback was blocked (${r.body.displayNumber}) - a halt or a policy stops it.`
|
|
5106
|
+
: `✔ rollback of ${o.environment} to ${sha.slice(0, 7)} asked for (${r.body.displayNumber}) - it deploys from ${branch} once a teammate approves.`);
|
|
5107
|
+
log(dim(` ${link(r.body.url)}`));
|
|
5108
|
+
return r.body.verdict === "block" ? 1 : 0;
|
|
5109
|
+
}
|
|
3686
5110
|
async function cmdGithubVerify(decisionArg) {
|
|
3687
5111
|
const decision = (decisionArg ?? process.env.DECIDIO_DECISION ?? "").trim();
|
|
3688
5112
|
if (!GH_DECISION_RE.test(decision)) {
|
|
@@ -3718,7 +5142,7 @@ function plainRepoPath(root, rel) {
|
|
|
3718
5142
|
}
|
|
3719
5143
|
return true;
|
|
3720
5144
|
}
|
|
3721
|
-
export const DECIDIO_ACTION = "OmniTwin/decidio-actions@
|
|
5145
|
+
export const DECIDIO_ACTION = "OmniTwin/decidio-actions@f0b43fcb8f8ac5e15d9ad43679e333ffa7038ae2 # v1.3.1";
|
|
3722
5146
|
export const CHECKOUT_ACTION = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0";
|
|
3723
5147
|
export function githubRequestWorkflow(api, environment, branch = "main", deployWorkflow = ".github/workflows/deploy.yml") {
|
|
3724
5148
|
return `# Written by npx @decidio/sdk github init. Asks a teammate to approve each deploy; Decidio starts the deploy workflow once
|
|
@@ -3763,13 +5187,16 @@ export function githubDeployWorkflow(api, environment) {
|
|
|
3763
5187
|
# approval (once, for this commit and environment) before anything deploys. Restrict who may run it to the Decidio app in the
|
|
3764
5188
|
# repository's workflow execution protections.
|
|
3765
5189
|
name: Deploy
|
|
3766
|
-
run-name:
|
|
5190
|
+
run-name: \${{ inputs.title || format('Decidio {0}', inputs.decidio) }}
|
|
3767
5191
|
on:
|
|
3768
5192
|
workflow_dispatch:
|
|
3769
5193
|
inputs:
|
|
3770
5194
|
decidio:
|
|
3771
5195
|
description: Set by Decidio
|
|
3772
5196
|
required: true
|
|
5197
|
+
title:
|
|
5198
|
+
description: Set by Decidio (the run's name - its record, environment and commit)
|
|
5199
|
+
required: false
|
|
3773
5200
|
permissions:
|
|
3774
5201
|
id-token: write
|
|
3775
5202
|
contents: read
|
|
@@ -3862,7 +5289,7 @@ async function cmdGithubInit(opts) {
|
|
|
3862
5289
|
log(` ${line}`);
|
|
3863
5290
|
}
|
|
3864
5291
|
const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
|
|
3865
|
-
const appUrl = (
|
|
5292
|
+
const appUrl = appUrlFor(apiUrl);
|
|
3866
5293
|
const section = agentsMdSection(appUrl);
|
|
3867
5294
|
if (!plainRepoPath(root, agentsRel))
|
|
3868
5295
|
err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
|
|
@@ -3886,6 +5313,16 @@ async function cmdGithubInit(opts) {
|
|
|
3886
5313
|
log(` 3. Commit the workflows and push - that push asks for nothing (it changes only these files).`);
|
|
3887
5314
|
log(` 4. Check the connection: Check the connection in Decidio, or gh workflow run decidio-request.yml - it asks for nothing.`);
|
|
3888
5315
|
log(` Your next push to ${branch} asks a teammate.`);
|
|
5316
|
+
const sameBox = apiUrl === API.replace(/\/$/, "");
|
|
5317
|
+
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)")}`);
|
|
5318
|
+
if (sameBox && process.stdin.isTTY && process.stdout.isTTY) {
|
|
5319
|
+
log("");
|
|
5320
|
+
log(` Set up your phone to approve now? ${dim("[y/n]")}`);
|
|
5321
|
+
if ((await ask(" › ")).trim().toLowerCase() === "y") {
|
|
5322
|
+
log("");
|
|
5323
|
+
await cmdPhone();
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
3889
5326
|
return 0;
|
|
3890
5327
|
}
|
|
3891
5328
|
const USAGE = `✦ Decidio — the SDK command line
|
|
@@ -3927,6 +5364,12 @@ SIGN IN (once)
|
|
|
3927
5364
|
npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
|
|
3928
5365
|
until it expires — there is no server-side revocation yet)
|
|
3929
5366
|
|
|
5367
|
+
YOUR PHONE
|
|
5368
|
+
npx @decidio/sdk phone put Decidio on your phone: sign in (once), then - only on your y - a QR in this
|
|
5369
|
+
terminal signs the phone in (one time, 5 minutes). iPhone: the app opens, or the page
|
|
5370
|
+
walks you through installing it; Android: the phone's browser. Enter = a fresh QR.
|
|
5371
|
+
Terminal only; the Decidio lab for now (on your company's Decidio: SSO on the phone)
|
|
5372
|
+
|
|
3930
5373
|
YOUR AGENT AND ITS TOKENS
|
|
3931
5374
|
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
3932
5375
|
(agentId defaults to this folder's name; any name works - it is how the
|
|
@@ -3960,6 +5403,17 @@ APPROVALS AND RECEIPTS
|
|
|
3960
5403
|
(never the receipt's own issuer field; --allow-unpinned: structural check only)
|
|
3961
5404
|
|
|
3962
5405
|
CODING AGENTS (Claude Code)
|
|
5406
|
+
npx @decidio/sdk connect connect this project's coding assistant in one tap: prints a link and a short
|
|
5407
|
+
code (a QR and the browser too, on a real terminal); the workspace owner allows
|
|
5408
|
+
it on the web, then this machine gets an ask-only credential (kept outside the
|
|
5409
|
+
repository) and the hooks below switch on. Your assistant can run it for you.
|
|
5410
|
+
npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
|
|
5411
|
+
(exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
|
|
5412
|
+
--team: the shared .claude/settings.json every clone reads (default: yours
|
|
5413
|
+
alone; a cloud session reads only the shared file, so there it is always that
|
|
5414
|
+
one) · --no-browser · --force: a fresh token for this machine
|
|
5415
|
+
npx @decidio/sdk disconnect delete this machine's connection for this project (destructive commands are then
|
|
5416
|
+
refused until you connect again; the hooks stay on)
|
|
3963
5417
|
npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
|
|
3964
5418
|
a person approves Claude Code's destructive shell commands before they run
|
|
3965
5419
|
(rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
|
|
@@ -4015,7 +5469,7 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
|
|
|
4015
5469
|
in a repository's checkout: writes the workflow that asks for approval
|
|
4016
5470
|
(.github/workflows/decidio-request.yml) and the deploy workflow when there is none
|
|
4017
5471
|
(an existing one is never changed: init prints the steps to add), both using
|
|
4018
|
-
Decidio's GitHub Action pinned to its v1.
|
|
5472
|
+
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
5473
|
never a decision's text
|
|
4020
5474
|
npx @decidio/sdk github request --environment <name> [--justification "<what changed>"]
|
|
4021
5475
|
inside a GitHub Actions job (permissions id-token: write): ask a teammate to approve
|
|
@@ -4024,6 +5478,28 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
|
|
|
4024
5478
|
commit and environment, and writes sha= to $GITHUB_OUTPUT; anything else exits 1 -
|
|
4025
5479
|
do not deploy (DECIDIO_DECISION carries the decidio input)
|
|
4026
5480
|
|
|
5481
|
+
npx @decidio/sdk deploys [D·0042] [--environment <name>] [--repo <owner/name>] [--limit N] [--watch] [--json]
|
|
5482
|
+
what's deployed where: the recent deploy requests you may read, each with the words its
|
|
5483
|
+
commit shows on GitHub; a deployed one names the previous deployed commit and a compare
|
|
5484
|
+
link - what that deploy changed, and the rollback target; one deploy (its D-number) adds
|
|
5485
|
+
the pull requests it shipped and who approved them; --watch follows changes
|
|
5486
|
+
|
|
5487
|
+
EMERGENCY CONTROLS
|
|
5488
|
+
npx @decidio/sdk controls [--json] the halts, freezes and firefighter grants in force or scheduled
|
|
5489
|
+
npx @decidio/sdk halt --scope <systems> --reason <incident|release_freeze|maintenance|security|audit|other> [--until 2h|ISO]
|
|
5490
|
+
stop everything in scope now (owner/repo, owner/repo:environment, agent:<id>, deploys, *)
|
|
5491
|
+
- the workspace owner or the executive role only; until lifted unless --until
|
|
5492
|
+
npx @decidio/sdk freeze --scope <systems> --reason <...> --from now|ISO --until 2d|ISO
|
|
5493
|
+
a change freeze for a window (a halt scheduled ahead)
|
|
5494
|
+
npx @decidio/sdk lift <id> lift a halt or end a freeze now
|
|
5495
|
+
npx @decidio/sdk firefighter --scope <systems> --minutes <15-240> --reason "<what is on fire>"
|
|
5496
|
+
ask for break-glass authority: the executive role approves it (never you); while active,
|
|
5497
|
+
your own requests to those systems go ahead and are reviewed when it ends
|
|
5498
|
+
npx @decidio/sdk firefighter end <id> end your grant early
|
|
5499
|
+
npx @decidio/sdk rollback --repo <owner/name> --environment <name> [--no-push]
|
|
5500
|
+
ask to deploy the environment's previous approved commit (approved like any deploy):
|
|
5501
|
+
run it in a checkout - your git pushes a branch at that commit, the deploy runs there
|
|
5502
|
+
|
|
4027
5503
|
DEVELOPMENT
|
|
4028
5504
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
4029
5505
|
npx @decidio/sdk --version print the CLI version
|
|
@@ -4042,7 +5518,8 @@ NOTES
|
|
|
4042
5518
|
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
|
|
4043
5519
|
3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
|
|
4044
5520
|
receipt written — pass --auto-approve-demo for a run that completes unattended), or
|
|
4045
|
-
hook wait: the approver said no
|
|
5521
|
+
hook wait: the approver said no, or connect --wait: the connection was refused or expired (1 there: still
|
|
5522
|
+
waiting - run it again). hook pretool/posttool always exit 0 (their answer is the JSON).
|
|
4046
5523
|
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
4047
5524
|
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
4048
5525
|
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
@@ -4159,6 +5636,8 @@ async function main(argv) {
|
|
|
4159
5636
|
return pick.code;
|
|
4160
5637
|
if (pick.kind === "logout")
|
|
4161
5638
|
return cmdLogout();
|
|
5639
|
+
if (pick.kind === "phone")
|
|
5640
|
+
return cmdPhone();
|
|
4162
5641
|
if (pick.kind === "init")
|
|
4163
5642
|
return cmdInit(pick.agentId, {});
|
|
4164
5643
|
return cmdQuickstart({ demo: pick.kind === "demo" });
|
|
@@ -4180,7 +5659,10 @@ async function main(argv) {
|
|
|
4180
5659
|
quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent", "--demo"], valued: ["--phone"] },
|
|
4181
5660
|
login: { flags: [] },
|
|
4182
5661
|
logout: { flags: [] },
|
|
5662
|
+
phone: { flags: [] },
|
|
4183
5663
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
5664
|
+
connect: { flags: ["--wait", "--no-browser", "--team", "--force"], valued: ["--host"] },
|
|
5665
|
+
disconnect: { flags: [] },
|
|
4184
5666
|
doctor: { flags: [] },
|
|
4185
5667
|
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
4186
5668
|
receipt: { flags: ["--json"] },
|
|
@@ -4189,6 +5671,13 @@ async function main(argv) {
|
|
|
4189
5671
|
dev: { flags: [], valued: ["--target", "--port"] },
|
|
4190
5672
|
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
|
|
4191
5673
|
github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
|
|
5674
|
+
deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
|
|
5675
|
+
controls: { flags: ["--json"] },
|
|
5676
|
+
halt: { flags: [], valued: ["--scope", "--reason", "--until"] },
|
|
5677
|
+
freeze: { flags: [], valued: ["--scope", "--reason", "--from", "--until"] },
|
|
5678
|
+
lift: { flags: [] },
|
|
5679
|
+
firefighter: { flags: [], valued: ["--scope", "--minutes", "--reason"] },
|
|
5680
|
+
rollback: { flags: ["--no-push"], valued: ["--repo", "--environment"] },
|
|
4192
5681
|
decisions: { flags: ["--dry-run"], valued: ["--adr"] },
|
|
4193
5682
|
};
|
|
4194
5683
|
const spec = KNOWN[cmd];
|
|
@@ -4223,7 +5712,7 @@ async function main(argv) {
|
|
|
4223
5712
|
return 2;
|
|
4224
5713
|
}
|
|
4225
5714
|
}
|
|
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 };
|
|
5715
|
+
const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, connect: 0, disconnect: 0, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
|
|
4227
5716
|
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
|
|
4228
5717
|
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
4229
5718
|
err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
|
|
@@ -4248,6 +5737,7 @@ async function main(argv) {
|
|
|
4248
5737
|
switch (cmd) {
|
|
4249
5738
|
case "login": return cmdLogin(argsIn.find((x) => !x.startsWith("-")));
|
|
4250
5739
|
case "logout": return cmdLogout();
|
|
5740
|
+
case "phone": return cmdPhone();
|
|
4251
5741
|
case "quickstart": {
|
|
4252
5742
|
if (argsIn.includes("cleanup")) {
|
|
4253
5743
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
@@ -4380,6 +5870,8 @@ async function main(argv) {
|
|
|
4380
5870
|
}
|
|
4381
5871
|
return cmdContext(sub, pos.slice(1), { title: valuedOpt(argsIn, "--title"), trigger: valuedOpt(argsIn, "--trigger"), team: valuedOpt(argsIn, "--team"), unit: valuedOpt(argsIn, "--unit"), org: argsIn.includes("--org"), here: argsIn.includes("--here"), pack: valuedOpt(argsIn, "--pack"), chatgpt: argsIn.includes("--chatgpt"), json: argsIn.includes("--json") });
|
|
4382
5872
|
}
|
|
5873
|
+
case "connect": return cmdConnect({ wait: argsIn.includes("--wait"), host: valuedOpt(argsIn, "--host"), noBrowser: argsIn.includes("--no-browser"), team: argsIn.includes("--team"), force: argsIn.includes("--force") });
|
|
5874
|
+
case "disconnect": return cmdDisconnect();
|
|
4383
5875
|
case "hook": {
|
|
4384
5876
|
const VALUED = ["--timeout", "--approver", "--cli"];
|
|
4385
5877
|
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
@@ -4412,6 +5904,31 @@ async function main(argv) {
|
|
|
4412
5904
|
return 2;
|
|
4413
5905
|
}
|
|
4414
5906
|
}
|
|
5907
|
+
case "deploys": {
|
|
5908
|
+
const VALUED = ["--environment", "--repo", "--limit"];
|
|
5909
|
+
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
5910
|
+
if (pos.length > 1) {
|
|
5911
|
+
err(`✘ deploys takes at most one deploy id.\n\n${USAGE}`);
|
|
5912
|
+
return 2;
|
|
5913
|
+
}
|
|
5914
|
+
return cmdDeploys(pos[0], { environment: valuedOpt(argsIn, "--environment"), repo: valuedOpt(argsIn, "--repo"), limit: valuedOpt(argsIn, "--limit"), watch: argsIn.includes("--watch"), json: argsIn.includes("--json") });
|
|
5915
|
+
}
|
|
5916
|
+
case "controls": return cmdControls(argsIn.includes("--json"));
|
|
5917
|
+
case "halt":
|
|
5918
|
+
case "freeze": {
|
|
5919
|
+
if (argsIn.some((x, i) => !x.startsWith("-") && !["--scope", "--reason", "--until", "--from"].includes(argsIn[i - 1] ?? ""))) {
|
|
5920
|
+
err(`✘ ${cmd} takes only flags.\n\n${USAGE}`);
|
|
5921
|
+
return 2;
|
|
5922
|
+
}
|
|
5923
|
+
return cmdHalt(cmd, { scope: valuedOpt(argsIn, "--scope"), reason: valuedOpt(argsIn, "--reason"), from: valuedOpt(argsIn, "--from"), until: valuedOpt(argsIn, "--until") });
|
|
5924
|
+
}
|
|
5925
|
+
case "lift": return cmdLift(argsIn.find((x) => !x.startsWith("-")));
|
|
5926
|
+
case "firefighter": {
|
|
5927
|
+
const VALUED = ["--scope", "--minutes", "--reason"];
|
|
5928
|
+
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
5929
|
+
return cmdFirefighter(pos[0], pos[1], { scope: valuedOpt(argsIn, "--scope"), minutes: valuedOpt(argsIn, "--minutes"), reason: valuedOpt(argsIn, "--reason") });
|
|
5930
|
+
}
|
|
5931
|
+
case "rollback": return cmdRollback({ repo: valuedOpt(argsIn, "--repo"), environment: valuedOpt(argsIn, "--environment"), noPush: argsIn.includes("--no-push") });
|
|
4415
5932
|
case "decisions": {
|
|
4416
5933
|
const sub = argsIn.find((x, i) => !x.startsWith("-") && argsIn[i - 1] !== "--adr");
|
|
4417
5934
|
if (sub !== "import") {
|