@decidio/sdk 0.10.0 → 0.12.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 +113 -3
- package/README.md +18 -9
- package/dist/adapters/_gate.d.ts +18 -1
- package/dist/adapters/_gate.js +34 -27
- package/dist/adapters/langgraph.js +1 -1
- package/dist/adapters/openai.d.ts +11 -4
- package/dist/adapters/openai.js +12 -4
- package/dist/adapters/temporal.d.ts +29 -2
- package/dist/adapters/temporal.js +42 -6
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +623 -96
- package/dist/demo-gate.js +14 -0
- package/dist/errors.js +3 -1
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +3 -2
- package/dist/index.d.ts +39 -7
- package/dist/index.js +103 -20
- package/dist/inngest.d.ts +5 -0
- package/dist/inngest.js +2 -2
- package/dist/resume.d.ts +52 -2
- package/dist/resume.js +74 -5
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +6 -0
- package/openapi.yaml +158 -2
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -76,9 +76,18 @@ 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
|
+
const COMPUTER = "*";
|
|
80
|
+
const EVERY_PROJECT = "every project";
|
|
81
|
+
const GUARD_POLICY_RE = /^[A-Za-z0-9_-]{20,64}$/;
|
|
82
|
+
let CONNECTED_APPROVER = null;
|
|
83
|
+
let CONNECTED_UNANSWERED = false;
|
|
84
|
+
let CONNECTED_APP_URL = null;
|
|
85
|
+
let CONNECTED_WORKSPACE = null;
|
|
86
|
+
let GUARD_PAUSABLE = true;
|
|
87
|
+
let connectNotice = null;
|
|
79
88
|
let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
|
|
80
89
|
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 =
|
|
90
|
+
const c = activeConnection(projectRoot(), API);
|
|
82
91
|
if (c) {
|
|
83
92
|
promoteConnection(c);
|
|
84
93
|
CREDS_FROM = "connection";
|
|
@@ -2764,6 +2773,14 @@ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], de
|
|
|
2764
2773
|
}
|
|
2765
2774
|
return null;
|
|
2766
2775
|
}
|
|
2776
|
+
function hookEditsGuard(command, at) {
|
|
2777
|
+
try {
|
|
2778
|
+
return !!hookMatch(command, at, HOOK_RULES.filter((r) => r.category === "decidio-guard"));
|
|
2779
|
+
}
|
|
2780
|
+
catch {
|
|
2781
|
+
return true;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2767
2784
|
function hookSelfDecides(command, depth = 0) {
|
|
2768
2785
|
const hit = hookSegments(command).some((s) => {
|
|
2769
2786
|
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
@@ -3038,7 +3055,7 @@ function hookSubject(category, segs, cwdAbs, projectDir, at) {
|
|
|
3038
3055
|
}
|
|
3039
3056
|
return textOnly("text binds this category");
|
|
3040
3057
|
}
|
|
3041
|
-
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3058
|
+
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool|session|prompt)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3042
3059
|
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
3060
|
function hookOurHandlers(settings) {
|
|
3044
3061
|
const out = [];
|
|
@@ -3398,8 +3415,104 @@ async function hookHold(cfg, decisionId, holdMs) {
|
|
|
3398
3415
|
}
|
|
3399
3416
|
return { status, lastError };
|
|
3400
3417
|
}
|
|
3401
|
-
|
|
3402
|
-
|
|
3418
|
+
const ORGANISATION_KEEPS_ON = "Your organisation keeps Decidio on for every session.";
|
|
3419
|
+
const GUARD_PAUSE_HOURS = 12;
|
|
3420
|
+
export function guardWords(prompt) {
|
|
3421
|
+
const m = typeof prompt === "string" ? /^\s*decidio\s+(pause|start)\s*[.!]?\s*$/i.exec(prompt) : null;
|
|
3422
|
+
return m ? m[1].toLowerCase() : null;
|
|
3423
|
+
}
|
|
3424
|
+
const guardSessionKey = (material, session) => idempotencyKey(material, { purpose: "guard-pause", host: "claude-code", session });
|
|
3425
|
+
async function cmdHookPrompt() {
|
|
3426
|
+
const block = (reason) => { process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n"); return 0; };
|
|
3427
|
+
let action = null;
|
|
3428
|
+
try {
|
|
3429
|
+
const read = await readHookStdin();
|
|
3430
|
+
if (!read.ok)
|
|
3431
|
+
return 0;
|
|
3432
|
+
const input = read.input;
|
|
3433
|
+
action = guardWords(input.prompt);
|
|
3434
|
+
if (!action)
|
|
3435
|
+
return 0;
|
|
3436
|
+
const not = action === "pause" ? "the guard stays on" : "nothing changed";
|
|
3437
|
+
if (input.source !== "user")
|
|
3438
|
+
return block(`Decidio: "decidio ${action}" works only on a prompt you type yourself - ${not}.`);
|
|
3439
|
+
const session = typeof input.session_id === "string" ? input.session_id : "";
|
|
3440
|
+
if (!session)
|
|
3441
|
+
return block(`Decidio: this session has no id to pause the guard for - ${not}.`);
|
|
3442
|
+
const cfg = hookConfig();
|
|
3443
|
+
if ("missing" in cfg)
|
|
3444
|
+
return block(`Decidio isn't connected on this computer, so there is no guard to ${action === "pause" ? "pause" : "resume"} here.`);
|
|
3445
|
+
const material = bindingMaterial(cfg);
|
|
3446
|
+
if (!material)
|
|
3447
|
+
return block(`Decidio: this computer's credential can't key the pause - ${not}. Ask the person to run npx @decidio/sdk doctor.`);
|
|
3448
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
3449
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
|
|
3450
|
+
body: JSON.stringify({ session: guardSessionKey(material, session), action }), signal: AbortSignal.timeout(10_000) });
|
|
3451
|
+
const body = await r.json().catch(() => null);
|
|
3452
|
+
if (r.status === 403 && body?.code === "ORGANISATION_KEEPS_ON") {
|
|
3453
|
+
notePause(session, false);
|
|
3454
|
+
rememberPausable(false);
|
|
3455
|
+
return block(ORGANISATION_KEEPS_ON);
|
|
3456
|
+
}
|
|
3457
|
+
if (r.ok && body?.state === "paused") {
|
|
3458
|
+
notePause(session, true);
|
|
3459
|
+
rememberPausable(true);
|
|
3460
|
+
return block(`Decidio guard paused for this session: destructive commands run without a person's approval - each one is recorded in your workspace's audit log - until you type "decidio start" (or for ${GUARD_PAUSE_HOURS} hours). Decidio still asks before a command edits Claude Code's settings to switch its check off.`);
|
|
3461
|
+
}
|
|
3462
|
+
if (r.ok && body?.state === "on") {
|
|
3463
|
+
notePause(session, false);
|
|
3464
|
+
return block(body.resumed ? "Decidio guard is back on for this session." : "Decidio guard is on for this session (it wasn't paused).");
|
|
3465
|
+
}
|
|
3466
|
+
return block(`Decidio couldn't ${action} the guard just now (${r.status}) - ${action === "pause" ? "it stays on" : "if it was paused, type \"decidio start\" again"}.`);
|
|
3467
|
+
}
|
|
3468
|
+
catch (e) {
|
|
3469
|
+
if (!action)
|
|
3470
|
+
return 0;
|
|
3471
|
+
return block(`Decidio couldn't ${action} the guard just now (${String(e?.message ?? e).replace(/^\[decidio\] /, "").slice(0, 160)}) - ${action === "pause" ? "it stays on" : "if it was paused, type \"decidio start\" again"}.`);
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
function rememberPausable(pausable) {
|
|
3475
|
+
if (CREDS_FROM !== "connection")
|
|
3476
|
+
return;
|
|
3477
|
+
const c = activeConnection(projectRoot(), API);
|
|
3478
|
+
if (!c || (c.guardPausable !== false) === pausable)
|
|
3479
|
+
return;
|
|
3480
|
+
try {
|
|
3481
|
+
writeSecretFile(connectionFile(c.root), JSON.stringify({ ...c, guardPausable: pausable }) + "\n");
|
|
3482
|
+
}
|
|
3483
|
+
catch { }
|
|
3484
|
+
}
|
|
3485
|
+
function hookForgesPrompt(command, depth = 0) {
|
|
3486
|
+
const hit = hookSegments(command).some((s) => {
|
|
3487
|
+
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
3488
|
+
const cli = (x) => /^@decidio\/sdk(@\S+)?$/i.test(x) || /^decidio(@\S+)?$/i.test(x) || /(^|[\\/])(decidio-guard|@decidio[\\/]sdk)[\\/](dist[\\/])?cli\.(js|ts)$/i.test(x);
|
|
3489
|
+
const runsCli = s.program === "decidio" || s.program === "decidio.js"
|
|
3490
|
+
|| ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
|
|
3491
|
+
const i = a.indexOf("hook");
|
|
3492
|
+
return runsCli && i >= 0 && a.slice(i + 1).some((x) => x.toLowerCase() === "prompt");
|
|
3493
|
+
});
|
|
3494
|
+
return hit || (depth < 2 && hookShellInner(command).some((c) => hookForgesPrompt(c, depth + 1)));
|
|
3495
|
+
}
|
|
3496
|
+
function cmdHookSession() {
|
|
3497
|
+
try {
|
|
3498
|
+
const cfg = hookConfig();
|
|
3499
|
+
const msg = "missing" in cfg
|
|
3500
|
+
? "Decidio approvals are on here, but this computer isn't connected yet - the first destructive command shows a link to connect it (once, for every project on this computer)."
|
|
3501
|
+
: `Decidio guard is on for this session${CONNECTED_WORKSPACE ? ` (workspace ${CONNECTED_WORKSPACE})` : ""}: deleting files, force-pushing, dropping tables, infrastructure changes and publishing wait for ${CONNECTED_APPROVER ?? "a person"}'s OK; everything else runs as usual. `
|
|
3502
|
+
+ (GUARD_PAUSABLE ? `Type "decidio pause" to pause it for this session, "decidio start" to resume.` : ORGANISATION_KEEPS_ON);
|
|
3503
|
+
process.stdout.write(JSON.stringify({ systemMessage: msg, hookSpecificOutput: { hookEventName: "SessionStart",
|
|
3504
|
+
additionalContext: "Decidio's hook asks a person before destructive shell commands in this project. When it refuses one, tell the user what its message says (who must approve, or the link that connects this computer) and follow it; never work around it." } }) + "\n");
|
|
3505
|
+
}
|
|
3506
|
+
catch { }
|
|
3507
|
+
return 0;
|
|
3508
|
+
}
|
|
3509
|
+
async function cmdHookPretool(opts = { unconnected: "hold" }) {
|
|
3510
|
+
const out = (o) => {
|
|
3511
|
+
if (connectNotice)
|
|
3512
|
+
o = { ...o, systemMessage: o?.systemMessage ? `${connectNotice} ${o.systemMessage}` : connectNotice };
|
|
3513
|
+
process.stdout.write(JSON.stringify(o) + "\n");
|
|
3514
|
+
return 0;
|
|
3515
|
+
};
|
|
3403
3516
|
const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
|
|
3404
3517
|
const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
|
|
3405
3518
|
try {
|
|
@@ -3416,6 +3529,8 @@ async function cmdHookPretool() {
|
|
|
3416
3529
|
const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
|
|
3417
3530
|
if (!command.trim())
|
|
3418
3531
|
return 0;
|
|
3532
|
+
if (hookForgesPrompt(command))
|
|
3533
|
+
return deny("Decidio: an agent can't pause or resume Decidio's guard - only the person does, by typing \"decidio pause\" or \"decidio start\" themselves. Nothing ran. Tell the user if they want it paused.");
|
|
3419
3534
|
if (hookSelfDecides(command))
|
|
3420
3535
|
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
3536
|
const segs = hookSegments(command);
|
|
@@ -3435,7 +3550,8 @@ async function cmdHookPretool() {
|
|
|
3435
3550
|
const canonical = command.replace(/\s+/g, " ").trim();
|
|
3436
3551
|
const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
|
|
3437
3552
|
const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
|
|
3438
|
-
|
|
3553
|
+
const offSwitch = rule.category === "decidio-guard" || hookEditsGuard(command, at);
|
|
3554
|
+
g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs, offSwitch,
|
|
3439
3555
|
...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
|
|
3440
3556
|
}
|
|
3441
3557
|
else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
|
|
@@ -3443,7 +3559,7 @@ async function cmdHookPretool() {
|
|
|
3443
3559
|
if (!fc)
|
|
3444
3560
|
return 0;
|
|
3445
3561
|
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 };
|
|
3562
|
+
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, offSwitch: true };
|
|
3447
3563
|
}
|
|
3448
3564
|
else
|
|
3449
3565
|
return 0;
|
|
@@ -3451,7 +3567,24 @@ async function cmdHookPretool() {
|
|
|
3451
3567
|
? { 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
3568
|
: { 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
3569
|
const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
|
|
3454
|
-
|
|
3570
|
+
let cfg = hookConfig();
|
|
3571
|
+
if ("missing" in cfg) {
|
|
3572
|
+
const got = await hookProvision(opts.policy);
|
|
3573
|
+
if (!got.ok) {
|
|
3574
|
+
const held = got.hold === true || (opts.policy !== undefined && got.hold !== false);
|
|
3575
|
+
if (opts.unconnected === "tell" && held && !g.offSwitch)
|
|
3576
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran} ${got.holdWhy ?? (got.hold === true ? "This team's Decidio holds a computer that isn't connected until it connects." : "This repository's Decidio line holds a computer that isn't connected unless Decidio confirms this team lets it through, and it couldn't confirm that here.")}`, got.user);
|
|
3577
|
+
if (opts.unconnected === "tell" && !g.offSwitch) {
|
|
3578
|
+
const told = toldOnce(typeof input.session_id === "string" ? input.session_id : "", g.binding);
|
|
3579
|
+
if (told === "passed")
|
|
3580
|
+
return out({ systemMessage: `Decidio: "${short}" ran without Decidio's approval - this computer isn't connected, and this repository lets a computer that isn't connected through after one notice.` });
|
|
3581
|
+
if (told === "noted")
|
|
3582
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran} This repository lets a computer that isn't connected run it after this one notice: if you can't connect, tell the user it would run WITHOUT Decidio's approval, and retry the same ${w.noun} only if they want that.`, got.user);
|
|
3583
|
+
}
|
|
3584
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran}`, got.user);
|
|
3585
|
+
}
|
|
3586
|
+
cfg = hookConfig();
|
|
3587
|
+
}
|
|
3455
3588
|
if ("missing" in cfg)
|
|
3456
3589
|
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`);
|
|
3457
3590
|
const rel = relative(projectDir, cwdAbs);
|
|
@@ -3464,6 +3597,13 @@ async function cmdHookPretool() {
|
|
|
3464
3597
|
catch (e) {
|
|
3465
3598
|
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\`.`);
|
|
3466
3599
|
}
|
|
3600
|
+
if (material && session && !g.offSwitch) {
|
|
3601
|
+
const paused = existsSync(pauseNote(session)) ? await guardPausedNow(cfg, guardSessionKey(material, session), g.category) : "on";
|
|
3602
|
+
if (paused === "on")
|
|
3603
|
+
notePause(session, false);
|
|
3604
|
+
if (paused === "paused")
|
|
3605
|
+
return out({ systemMessage: `Decidio guard is paused for this session: "${short}" ran without a person's approval (recorded). Type "decidio start" to resume.` });
|
|
3606
|
+
}
|
|
3467
3607
|
if (!material)
|
|
3468
3608
|
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
3609
|
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 } : {}) };
|
|
@@ -3508,7 +3648,7 @@ async function cmdHookPretool() {
|
|
|
3508
3648
|
if (auth.decision === "proceed") {
|
|
3509
3649
|
writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
|
|
3510
3650
|
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;
|
|
3651
|
+
return connectNotice ? out({}) : 0;
|
|
3512
3652
|
}
|
|
3513
3653
|
const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
|
|
3514
3654
|
const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
|
|
@@ -3516,7 +3656,7 @@ async function cmdHookPretool() {
|
|
|
3516
3656
|
if (!st)
|
|
3517
3657
|
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}.`);
|
|
3518
3658
|
if (st.state === "pending")
|
|
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}
|
|
3659
|
+
return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in ${CONNECTED_APPROVER ? `${CONNECTED_APPROVER}'s` : "their"} queue${where ? ` (${where})` : ""}; they see it on their phone, Slack, Teams or the web. ${w.ran} Tell the user it is waiting for ${CONNECTED_APPROVER ?? "the approver"}.${CONNECTED_UNANSWERED ? ` If nobody has said yet whether another person approves this computer's requests, tell the user: the workspace owner answers "Should another person approve this?" once on the Decidio Agents page${CONNECTED_APP_URL ? ` (${CONNECTED_APP_URL}/connect/agents#coding-assistants)` : ""}. A request made before the answer keeps it: its owner can't approve that one - another approver can, or ask again in a new session.` : ""} 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: waiting for ${CONNECTED_APPROVER ?? "a person"}'s approval (${auth.decisionId})${where ? ` - ${where}` : ""}. ${w.ran} Claude continues once it is approved.`);
|
|
3520
3660
|
if (st.state === "rejected" || st.state === "blocked")
|
|
3521
3661
|
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}`);
|
|
3522
3662
|
const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
|
|
@@ -3675,10 +3815,10 @@ function cmdHookRules() {
|
|
|
3675
3815
|
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".`);
|
|
3676
3816
|
return 0;
|
|
3677
3817
|
}
|
|
3678
|
-
function hookRunner() {
|
|
3818
|
+
function hookRunner(portable = false) {
|
|
3679
3819
|
const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3680
3820
|
const entry = process.argv[1] ?? "";
|
|
3681
|
-
if (!entry || /[\\/]_npx[\\/]/.test(entry))
|
|
3821
|
+
if (portable || !entry || /[\\/]_npx[\\/]/.test(entry))
|
|
3682
3822
|
return `npx -y @decidio/sdk@${VERSION}`;
|
|
3683
3823
|
let real = entry;
|
|
3684
3824
|
try {
|
|
@@ -3687,14 +3827,62 @@ function hookRunner() {
|
|
|
3687
3827
|
catch { }
|
|
3688
3828
|
const localRoot = resolvePath("node_modules") + pathSep;
|
|
3689
3829
|
if (real.startsWith(localRoot))
|
|
3690
|
-
|
|
3830
|
+
return `node "$CLAUDE_PROJECT_DIR/node_modules/${real.slice(localRoot.length).split(pathSep).join("/").replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3691
3831
|
real = real.split(pathSep).join("/");
|
|
3692
3832
|
if (/\.ts$/.test(real))
|
|
3693
3833
|
return `npx tsx ${q(real)}`;
|
|
3694
3834
|
return `node ${q(real)}`;
|
|
3695
3835
|
}
|
|
3836
|
+
const hookPromptLine = (runner) => `cd "$CLAUDE_PROJECT_DIR" && IN=$(cat) && case "$IN" in *[Dd][Ee][Cc][Ii][Dd][Ii][Oo]*) printf '%s' "$IN" | ${runner} hook prompt;; esac`;
|
|
3696
3837
|
const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
|
|
3697
3838
|
const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
|
|
3839
|
+
function decidioHookSet(runner, preLine, postLine) {
|
|
3840
|
+
const set = [
|
|
3841
|
+
{ event: "PreToolUse", matcher: "Bash|PowerShell", command: preLine, extra: { timeout: 150, onFailure: "block" } },
|
|
3842
|
+
{ event: "PostToolUse", matcher: "Bash|PowerShell", command: postLine, extra: { timeout: 30 } },
|
|
3843
|
+
{ event: "SessionStart", matcher: "startup|resume", command: `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook session`, extra: { timeout: 15 } },
|
|
3844
|
+
{ event: "UserPromptSubmit", matcher: "", command: hookPromptLine(runner), extra: { timeout: 15 } },
|
|
3845
|
+
];
|
|
3846
|
+
for (const t of HOOK_FILE_TOOLS)
|
|
3847
|
+
for (const p of HOOK_GUARD_PATHS) {
|
|
3848
|
+
set.push({ event: "PreToolUse", matcher: t, iff: `${t}(${p})`, command: preLine, extra: { timeout: 150, onFailure: "block" } });
|
|
3849
|
+
set.push({ event: "PostToolUse", matcher: t, iff: `${t}(${p})`, command: postLine, extra: { timeout: 30 } });
|
|
3850
|
+
}
|
|
3851
|
+
return set;
|
|
3852
|
+
}
|
|
3853
|
+
function decidioHooksJson(runner) {
|
|
3854
|
+
const hooks = {};
|
|
3855
|
+
for (const h of decidioHookSet(runner, `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`)) {
|
|
3856
|
+
const handler = { type: "command", ...(h.iff ? { if: h.iff } : {}), command: h.command, ...h.extra };
|
|
3857
|
+
(hooks[h.event] ??= []).push(h.matcher ? { matcher: h.matcher, hooks: [handler] } : { hooks: [handler] });
|
|
3858
|
+
}
|
|
3859
|
+
return hooks;
|
|
3860
|
+
}
|
|
3861
|
+
function cmdHookManagedSettings() {
|
|
3862
|
+
process.stdout.write(JSON.stringify({ hooks: decidioHooksJson(`npx -y @decidio/sdk@${VERSION}`) }, null, 2) + "\n");
|
|
3863
|
+
for (const line of [
|
|
3864
|
+
"Decidio hooks for Claude Code's managed settings (stdout above). Merge the \"hooks\" block into your organisation's managed settings:",
|
|
3865
|
+
" macOS /Library/Application Support/ClaudeCode/managed-settings.json (or the MDM profile domain com.anthropic.claudecode)",
|
|
3866
|
+
" Linux /etc/claude-code/managed-settings.json (WSL too)",
|
|
3867
|
+
" Windows C:\\Program Files\\ClaudeCode\\managed-settings.json (or HKLM\\SOFTWARE\\Policies\\ClaudeCode, value \"Settings\")",
|
|
3868
|
+
" or a drop-in file in managed-settings.d/ beside it.",
|
|
3869
|
+
"What it does: managed hooks run beside people's own hooks and can't be removed from their settings; a user's or project's",
|
|
3870
|
+
" disableAllHooks doesn't reach them. Never set disableAllHooks in managed settings - it would turn these off too.",
|
|
3871
|
+
"Watch out:",
|
|
3872
|
+
" - Server-managed settings (the Claude admin console) and MDM/registry policies outrank this file - by default the first source",
|
|
3873
|
+
" found wins and the rest are skipped. Put these hooks in the highest-ranked source you deploy (server-managed hooks show each",
|
|
3874
|
+
" person an approval dialog first), or set \"managedSourcesBehavior\": \"merge\" there (Claude Code 2.1.242+).",
|
|
3875
|
+
" - Claude Code cloud sessions (claude.ai/code) read neither this file nor an MDM profile - only server-managed settings reach them.",
|
|
3876
|
+
" - Every computer needs Node 18+ and the npm registry: the pretool hook blocks the call if it can't run (fail closed).",
|
|
3877
|
+
" - Each computer still connects once - the first destructive command shows a link the workspace owner allows; until then it waits.",
|
|
3878
|
+
" - A repository whose committed settings already carry Decidio's hook runs both (one request; test it once).",
|
|
3879
|
+
" - A local administrator can edit this file.",
|
|
3880
|
+
" - allowManagedHooksOnly: true would also stop people's own hooks - your choice; Decidio doesn't need it.",
|
|
3881
|
+
` - To update, redeploy with the new version (pinned here: @decidio/sdk@${VERSION}).`,
|
|
3882
|
+
])
|
|
3883
|
+
err(line);
|
|
3884
|
+
return 0;
|
|
3885
|
+
}
|
|
3698
3886
|
async function cmdHookInstall(opts) {
|
|
3699
3887
|
const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
|
|
3700
3888
|
let settings = {};
|
|
@@ -3716,14 +3904,14 @@ async function cmdHookInstall(opts) {
|
|
|
3716
3904
|
return 2;
|
|
3717
3905
|
}
|
|
3718
3906
|
const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
|
|
3719
|
-
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
|
|
3907
|
+
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner(opts.portable === true);
|
|
3720
3908
|
const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
|
|
3721
3909
|
const place = (event, matcher, iff, command, extra) => {
|
|
3722
3910
|
const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
|
|
3723
3911
|
const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
|
|
3724
3912
|
let state = "added";
|
|
3725
3913
|
for (const g of list) {
|
|
3726
|
-
if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
|
|
3914
|
+
if (!g || typeof g !== "object" || (g.matcher ?? "") !== matcher || !Array.isArray(g.hooks))
|
|
3727
3915
|
continue;
|
|
3728
3916
|
g.hooks = g.hooks.flatMap((h) => {
|
|
3729
3917
|
if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
|
|
@@ -3738,20 +3926,23 @@ async function cmdHookInstall(opts) {
|
|
|
3738
3926
|
}
|
|
3739
3927
|
hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
3740
3928
|
if (state === "added")
|
|
3741
|
-
hooks[event].push({ matcher, hooks: [want] });
|
|
3929
|
+
hooks[event].push(matcher ? { matcher, hooks: [want] } : { hooks: [want] });
|
|
3742
3930
|
return state;
|
|
3743
3931
|
};
|
|
3744
|
-
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3932
|
+
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool${opts.unconnected === "tell" ? ` --unconnected=tell${opts.policy && GUARD_POLICY_RE.test(opts.policy) ? ` --policy=${opts.policy}` : ""}` : ""}`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3933
|
+
const set = decidioHookSet(runner, preLine, postLine);
|
|
3745
3934
|
const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
|
|
3746
3935
|
const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
|
|
3747
3936
|
const guard = { added: 0, updated: 0, kept: 0 };
|
|
3748
|
-
for (const
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
guard[
|
|
3752
|
-
|
|
3937
|
+
for (const h of set.slice(2)) {
|
|
3938
|
+
const st = place(h.event, h.matcher, h.iff, h.command, h.extra);
|
|
3939
|
+
if (h.iff)
|
|
3940
|
+
guard[st]++;
|
|
3941
|
+
}
|
|
3753
3942
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
3754
|
-
|
|
3943
|
+
const target = realOrSame(settingsPath), tmpSettings = `${target}.decidio-${process.pid}.tmp`;
|
|
3944
|
+
writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + "\n", { mode: existsSync(target) ? statSync(target).mode & 0o777 : 0o644 });
|
|
3945
|
+
renameSync(tmpSettings, target);
|
|
3755
3946
|
lastHookInstall = { settingsPath, pre, post };
|
|
3756
3947
|
if (opts.quiet)
|
|
3757
3948
|
return 0;
|
|
@@ -3795,7 +3986,7 @@ function projectRoot() {
|
|
|
3795
3986
|
catch { }
|
|
3796
3987
|
return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
|
|
3797
3988
|
}
|
|
3798
|
-
function connectionKey(root) { return createHash("sha256").update(`${API}\n${process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
|
|
3989
|
+
function connectionKey(root) { return createHash("sha256").update(`${API}\n${root === COMPUTER ? "computer" : process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
|
|
3799
3990
|
function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
|
|
3800
3991
|
function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
|
|
3801
3992
|
function readJsonQuiet(f) {
|
|
@@ -3806,6 +3997,8 @@ function readJsonQuiet(f) {
|
|
|
3806
3997
|
return null;
|
|
3807
3998
|
}
|
|
3808
3999
|
}
|
|
4000
|
+
function activeConnection(root, api) { return loadConnection(root, api) ?? loadConnection(COMPUTER, api); }
|
|
4001
|
+
const connectScope = (root) => (connectionRecord(root, API) ? root : COMPUTER);
|
|
3809
4002
|
function loadConnection(root, api) {
|
|
3810
4003
|
const c = connectionRecord(root, api);
|
|
3811
4004
|
return c && !c.disconnected && c.token ? c : null;
|
|
@@ -3814,7 +4007,15 @@ function connectionRecord(root, api) {
|
|
|
3814
4007
|
const c = readJsonQuiet(connectionFile(root));
|
|
3815
4008
|
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
4009
|
}
|
|
4010
|
+
function printableName(v) {
|
|
4011
|
+
return typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, "").trim().slice(0, 60) || null : null;
|
|
4012
|
+
}
|
|
3817
4013
|
function promoteConnection(c) {
|
|
4014
|
+
CONNECTED_APPROVER = printableName(c.approver);
|
|
4015
|
+
CONNECTED_UNANSWERED = c.answered === false;
|
|
4016
|
+
CONNECTED_APP_URL = typeof c.appUrl === "string" && /^https?:\/\/[^\s"<>]+$/.test(c.appUrl) ? c.appUrl.replace(/\/+$/, "") : null;
|
|
4017
|
+
CONNECTED_WORKSPACE = printableName(c.workspaceName);
|
|
4018
|
+
GUARD_PAUSABLE = c.guardPausable !== false;
|
|
3818
4019
|
process.env.DECIDIO_API_TOKEN = c.token;
|
|
3819
4020
|
process.env.DECIDIO_AGENT_ID = c.agentId;
|
|
3820
4021
|
process.env.DECIDIO_AGENT_DID = c.did;
|
|
@@ -3889,31 +4090,46 @@ function openInBrowser(url) {
|
|
|
3889
4090
|
}
|
|
3890
4091
|
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
4092
|
let lastHookInstall = null;
|
|
3892
|
-
|
|
4093
|
+
let connectError = "";
|
|
4094
|
+
function hostOf(url) {
|
|
4095
|
+
try {
|
|
4096
|
+
return new URL(url).host || "Decidio's API host";
|
|
4097
|
+
}
|
|
4098
|
+
catch {
|
|
4099
|
+
return "Decidio's API host";
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
function cloudNetworkWords() {
|
|
4103
|
+
const host = hostOf(API);
|
|
4104
|
+
return `this is a Claude Code cloud session: its environment's network access must allow ${host}. Edit the environment and add ${host} under Network access → Allowed domains, keeping the package-manager defaults (on Team and Enterprise an Owner can set it once on a shared environment): https://code.claude.com/docs/en/cloud-environments#network-access`;
|
|
4105
|
+
}
|
|
4106
|
+
let connectFailure = "other";
|
|
4107
|
+
async function startConnect(root, host, quiet = false, policy) {
|
|
4108
|
+
const fail = (m, kind = "other") => { connectError = m; connectFailure = kind; if (!quiet)
|
|
4109
|
+
err(`✘ ${m}`); return null; };
|
|
3893
4110
|
const prior = connectionRecord(root, API);
|
|
3894
4111
|
const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
|
|
3895
4112
|
const cloud = inCloudSession();
|
|
3896
|
-
const project = projectLabel(root).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
|
|
4113
|
+
const project = (root === COMPUTER ? EVERY_PROJECT : projectLabel(root)).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
|
|
3897
4114
|
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
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
if (r.status ===
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string")
|
|
3912
|
-
|
|
3913
|
-
return null;
|
|
3914
|
-
}
|
|
4115
|
+
const startBody = { host, project, machine, did: kp.did, cloud, proof };
|
|
4116
|
+
let r = await api("/connect/start", { method: "POST", body: JSON.stringify(policy ? { ...startBody, policy } : startBody) }, "", true);
|
|
4117
|
+
let asked = !!policy;
|
|
4118
|
+
if (policy && r.status === 400 && r.body?.code === "VALIDATION") {
|
|
4119
|
+
asked = false;
|
|
4120
|
+
r = await api("/connect/start", { method: "POST", body: JSON.stringify(startBody) }, "", true);
|
|
4121
|
+
}
|
|
4122
|
+
if (r.status === 0)
|
|
4123
|
+
return fail(`couldn't reach Decidio at ${API} - ${inCloudSession() ? cloudNetworkWords() : "check your connection and run npx @decidio/sdk connect again."}`, "unreachable");
|
|
4124
|
+
if (r.status === 404 || r.status === 401)
|
|
4125
|
+
return fail(`${r.body?.message ?? "Connecting a coding assistant isn't switched on for this Decidio yet - it is on in the Decidio lab."} (${API})`, "off");
|
|
4126
|
+
if (r.status === 429)
|
|
4127
|
+
return fail(`${r.body?.message ?? "Too many tries - wait a minute and try again."}`, "unreachable");
|
|
4128
|
+
if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string")
|
|
4129
|
+
return fail(`couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`, r.status >= 500 ? "unreachable" : "other");
|
|
3915
4130
|
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()
|
|
4131
|
+
pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString(),
|
|
4132
|
+
...(asked && policy && typeof r.body.hold === "boolean" ? { policy, hold: r.body.hold } : {}) };
|
|
3917
4133
|
mkdirSync(connectDir(), { recursive: true });
|
|
3918
4134
|
writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
|
|
3919
4135
|
if (typeof r.body.qr === "string")
|
|
@@ -3925,6 +4141,172 @@ function readPendingConnect(root) {
|
|
|
3925
4141
|
const p = readJsonQuiet(pendingConnectFile(root));
|
|
3926
4142
|
return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
|
|
3927
4143
|
}
|
|
4144
|
+
async function hookProvision(policy) {
|
|
4145
|
+
const scope = COMPUTER;
|
|
4146
|
+
const UNCONFIRMED = "Decidio couldn't confirm whether this team lets a computer that isn't connected through, so it holds it until it connects.";
|
|
4147
|
+
const holdOf = (p) => !policy ? {} : p && p.policy === policy && typeof p.hold === "boolean" ? { hold: p.hold } : { hold: true, holdWhy: UNCONFIRMED };
|
|
4148
|
+
if (process.env.DECIDIO_AGENT_ID) {
|
|
4149
|
+
const named = printableName(process.env.DECIDIO_AGENT_ID) ?? "(unprintable)";
|
|
4150
|
+
return { ok: false, reason: `this project names the Decidio agent "${named}" (in its .env or the shell) but has no token for it. Ask the user to check it with npx @decidio/sdk doctor, or to remove DECIDIO_AGENT_ID to use this computer's connection.`, user: `Decidio: "${named}" has no token here` };
|
|
4151
|
+
}
|
|
4152
|
+
const own = (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
4153
|
+
if (API !== own) {
|
|
4154
|
+
const host = printableName(redactUrl(API)) ?? "(unprintable)";
|
|
4155
|
+
return { ok: false, reason: `this project's .env points Decidio at ${host}; the hook won't connect this computer to a server a repository names. If the user trusts it, they run npx @decidio/sdk connect themselves.`, user: `Decidio: not connecting to ${host} (named by this project)` };
|
|
4156
|
+
}
|
|
4157
|
+
try {
|
|
4158
|
+
assertSafeTransport(API, true);
|
|
4159
|
+
}
|
|
4160
|
+
catch (e) {
|
|
4161
|
+
return { ok: false, reason: `${String(e?.message ?? e).replace(/^\[decidio\] /, "")} - the connection's secret would cross in clear.`, user: "Decidio: refusing a plaintext connection" };
|
|
4162
|
+
}
|
|
4163
|
+
const linkWords = (p, fresh) => {
|
|
4164
|
+
const mins = minutesLeft(p.expiresAt) || 1;
|
|
4165
|
+
return { ok: false, ...holdOf(p),
|
|
4166
|
+
reason: `this computer isn't connected to Decidio yet${fresh ? "" : " (its link is still waiting to be allowed)"}. If Decidio's connect_device tool is available to you (the Decidio connector), tell the user you are connecting this computer to their Decidio workspace, call connect_device with the code ${p.code}, run the command it returns, then retry. Otherwise show the user this link and code: ${p.link} (code ${p.code}, valid ${mins} more minute${mins === 1 ? "" : "s"}) - the workspace owner allows it once, signed in, and it covers every project on this computer. Then retry the same command: it connects and asks for approval.`,
|
|
4167
|
+
user: `Decidio: connect this computer once - open ${p.link} and check the code ${p.code}. Destructive commands wait until it's allowed; everything else runs as usual.` };
|
|
4168
|
+
};
|
|
4169
|
+
let p = readPendingConnect(scope);
|
|
4170
|
+
if (p && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
|
|
4171
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
|
|
4172
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4173
|
+
const c = storeCollected(scope, p, r.body);
|
|
4174
|
+
if (typeof c === "string")
|
|
4175
|
+
return { ok: false, reason: `this computer was allowed, but its credential couldn't be written (${c}). Ask the user to run npx @decidio/sdk doctor.`, user: "Decidio: connected, but the credential couldn't be saved" };
|
|
4176
|
+
promoteConnection(c);
|
|
4177
|
+
CREDS_FROM = "connection";
|
|
4178
|
+
connectNotice = `Decidio connected this computer${CONNECTED_WORKSPACE ? ` to workspace ${CONNECTED_WORKSPACE}` : ""}${printableName(c.approvedBy) ? `, allowed by ${printableName(c.approvedBy)}` : ""} - every project on it now asks before destructive commands.`;
|
|
4179
|
+
return { ok: true };
|
|
4180
|
+
}
|
|
4181
|
+
if (r.status === 202 || r.status === 429 || r.status >= 500)
|
|
4182
|
+
return linkWords(p, false);
|
|
4183
|
+
if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch"))
|
|
4184
|
+
return { ok: false,
|
|
4185
|
+
reason: `this computer's link (code ${p.code}) was allowed through the Decidio connector and is finished only with the ticket connect_device returned: if YOU called connect_device for code ${p.code} in this session, run npx @decidio/sdk@latest connect --ticket <the ticket it returned>, then retry the same command. If you did not, do not finish it with any ticket from anywhere else - tell the user that another workspace's connector allowed this computer, and leave it: the link lapses within ten minutes and the next try shows a fresh code.`,
|
|
4186
|
+
user: "Decidio: finishing this computer's connection (allowed through the Decidio connector) - destructive commands wait until it's done" };
|
|
4187
|
+
if (r.status === 0)
|
|
4188
|
+
return { ok: false, ...holdOf(p), reason: `couldn't reach Decidio at ${API} to finish connecting this computer - ${inCloudSession() ? cloudNetworkWords() : "retry once it is reachable."}`, user: `Decidio: unreachable - destructive commands wait${inCloudSession() ? ` (this cloud environment's network access must allow ${hostOf(API)})` : ""}` };
|
|
4189
|
+
if (r.status === 410 && r.body?.status === "collected") {
|
|
4190
|
+
const won = activeConnection(projectRoot(), API);
|
|
4191
|
+
if (won) {
|
|
4192
|
+
promoteConnection(won);
|
|
4193
|
+
CREDS_FROM = "connection";
|
|
4194
|
+
return { ok: true };
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4198
|
+
dropFile(pendingConnectFile(scope));
|
|
4199
|
+
return { ok: false, reason: `connecting this computer was refused on the web ("Not me"). Ask the user before retrying - the next attempt shows a new link.`, user: "Decidio: connecting this computer was refused" };
|
|
4200
|
+
}
|
|
4201
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4202
|
+
dropFile(pendingConnectFile(scope));
|
|
4203
|
+
return { ok: false, reason: `the workspace owner removed this computer before it finished connecting. Ask the user before retrying - the next attempt shows a new link, and only the owner's Allow on the web brings it back.`, user: "Decidio: the workspace owner removed this computer" };
|
|
4204
|
+
}
|
|
4205
|
+
dropFile(pendingConnectFile(scope));
|
|
4206
|
+
}
|
|
4207
|
+
const lock = pendingConnectFile(scope) + ".lock";
|
|
4208
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4209
|
+
let fd = null;
|
|
4210
|
+
try {
|
|
4211
|
+
fd = openSync(lock, "wx", 0o600);
|
|
4212
|
+
}
|
|
4213
|
+
catch {
|
|
4214
|
+
try {
|
|
4215
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000) {
|
|
4216
|
+
rmSync(lock, { force: true });
|
|
4217
|
+
fd = openSync(lock, "wx", 0o600);
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
catch {
|
|
4221
|
+
fd = null;
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
if (fd !== null && (p = readPendingConnect(scope)) && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
|
|
4225
|
+
try {
|
|
4226
|
+
closeSync(fd);
|
|
4227
|
+
}
|
|
4228
|
+
catch { }
|
|
4229
|
+
dropFile(lock);
|
|
4230
|
+
return linkWords(p, false);
|
|
4231
|
+
}
|
|
4232
|
+
if (fd === null) {
|
|
4233
|
+
for (let i = 0; i < 20 && !(p = readPendingConnect(scope)); i++)
|
|
4234
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
4235
|
+
if (p)
|
|
4236
|
+
return linkWords(p, false);
|
|
4237
|
+
return { ok: false, reason: "another Decidio check on this computer is connecting it right now - retry the same command in a few seconds.", user: "Decidio: connecting this computer" };
|
|
4238
|
+
}
|
|
4239
|
+
try {
|
|
4240
|
+
const started = await startConnect(scope, "claude-code", true, policy);
|
|
4241
|
+
if (!started)
|
|
4242
|
+
return { ok: false, ...(!policy ? {} : connectFailure === "off" ? { hold: false } : { hold: true, holdWhy: UNCONFIRMED }), reason: `this computer isn't connected to Decidio, and a connection couldn't be started: ${connectError}`, user: `Decidio: this computer isn't connected${inCloudSession() && connectError.startsWith("couldn't reach Decidio") ? ` - this cloud environment's network access must allow ${hostOf(API)}` : ""}` };
|
|
4243
|
+
return linkWords(started, true);
|
|
4244
|
+
}
|
|
4245
|
+
finally {
|
|
4246
|
+
try {
|
|
4247
|
+
closeSync(fd);
|
|
4248
|
+
}
|
|
4249
|
+
catch { }
|
|
4250
|
+
dropFile(lock);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
function toldOnce(session, binding) {
|
|
4254
|
+
if (!session)
|
|
4255
|
+
return "hold";
|
|
4256
|
+
const dir = joinPath(connectDir(), "told");
|
|
4257
|
+
const f = joinPath(dir, createHash("sha256").update(`${API}\n${session}\n${binding}`).digest("hex").slice(0, 40));
|
|
4258
|
+
try {
|
|
4259
|
+
if (existsSync(f))
|
|
4260
|
+
return "passed";
|
|
4261
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4262
|
+
const week = Date.now() - 7 * 86_400_000;
|
|
4263
|
+
for (const old of readdirSync(dir)) {
|
|
4264
|
+
try {
|
|
4265
|
+
if (statSync(joinPath(dir, old)).mtimeMs < week)
|
|
4266
|
+
rmSync(joinPath(dir, old), { force: true });
|
|
4267
|
+
}
|
|
4268
|
+
catch { }
|
|
4269
|
+
}
|
|
4270
|
+
writeFileSync(f, "", { mode: 0o600, flag: "wx" });
|
|
4271
|
+
return "noted";
|
|
4272
|
+
}
|
|
4273
|
+
catch {
|
|
4274
|
+
return "hold";
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
async function guardPausedNow(cfg, sessionKey, category) {
|
|
4278
|
+
try {
|
|
4279
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
4280
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard/check", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
|
|
4281
|
+
body: JSON.stringify({ session: sessionKey, category }), signal: AbortSignal.timeout(10_000) });
|
|
4282
|
+
const body = await r.json().catch(() => null);
|
|
4283
|
+
return r.ok && body?.paused === true ? "paused" : r.ok && body?.paused === false ? "on" : "unknown";
|
|
4284
|
+
}
|
|
4285
|
+
catch {
|
|
4286
|
+
return "unknown";
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
function pauseNote(session) {
|
|
4290
|
+
return joinPath(connectDir(), "paused", createHash("sha256").update(`${API}\n${session}`).digest("hex").slice(0, 40));
|
|
4291
|
+
}
|
|
4292
|
+
function notePause(session, paused) {
|
|
4293
|
+
const f = pauseNote(session);
|
|
4294
|
+
try {
|
|
4295
|
+
if (!paused)
|
|
4296
|
+
return dropFile(f);
|
|
4297
|
+
const dir = dirname(f);
|
|
4298
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4299
|
+
for (const old of readdirSync(dir)) {
|
|
4300
|
+
try {
|
|
4301
|
+
if (statSync(joinPath(dir, old)).mtimeMs < Date.now() - GUARD_PAUSE_HOURS * 3_600_000)
|
|
4302
|
+
rmSync(joinPath(dir, old), { force: true });
|
|
4303
|
+
}
|
|
4304
|
+
catch { }
|
|
4305
|
+
}
|
|
4306
|
+
writeFileSync(f, "", { mode: 0o600 });
|
|
4307
|
+
}
|
|
4308
|
+
catch { }
|
|
4309
|
+
}
|
|
3928
4310
|
function dropFile(f) { try {
|
|
3929
4311
|
rmSync(f, { force: true });
|
|
3930
4312
|
}
|
|
@@ -3950,6 +4332,14 @@ async function cmdConnect(opts) {
|
|
|
3950
4332
|
err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
|
|
3951
4333
|
return 2;
|
|
3952
4334
|
}
|
|
4335
|
+
if (opts.ticket !== undefined && (opts.wait || opts.team || opts.force || opts.holdEveryone)) {
|
|
4336
|
+
err("✘ --ticket finishes a link the Decidio connector allowed and takes no other option.");
|
|
4337
|
+
return 2;
|
|
4338
|
+
}
|
|
4339
|
+
if (opts.holdEveryone && !opts.team && !inCloudSession()) {
|
|
4340
|
+
err("✘ --hold-everyone is for the shared file (--team, or a cloud session): it keeps refusing a computer that isn't connected.");
|
|
4341
|
+
return 2;
|
|
4342
|
+
}
|
|
3953
4343
|
try {
|
|
3954
4344
|
assertSafeTransport(API, true);
|
|
3955
4345
|
}
|
|
@@ -3958,37 +4348,123 @@ async function cmdConnect(opts) {
|
|
|
3958
4348
|
return 1;
|
|
3959
4349
|
}
|
|
3960
4350
|
const root = projectRoot();
|
|
4351
|
+
if (opts.ticket !== undefined)
|
|
4352
|
+
return connectWithTicket(root, opts.ticket);
|
|
3961
4353
|
const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
3962
4354
|
if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
|
|
3963
4355
|
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
4356
|
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
4357
|
return finishConnect(root, null, opts, false);
|
|
3966
4358
|
}
|
|
3967
|
-
const existing =
|
|
4359
|
+
const existing = activeConnection(root, API);
|
|
3968
4360
|
if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
|
|
3969
4361
|
return finishConnect(root, existing, opts, false);
|
|
4362
|
+
const scope = connectScope(root);
|
|
3970
4363
|
if (opts.wait)
|
|
3971
|
-
return waitConnect(root, opts, false);
|
|
3972
|
-
let p = readPendingConnect(
|
|
4364
|
+
return waitConnect(root, scope, opts, false);
|
|
4365
|
+
let p = readPendingConnect(scope);
|
|
3973
4366
|
if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
|
|
3974
|
-
p = await startConnect(
|
|
4367
|
+
p = await startConnect(scope, host);
|
|
3975
4368
|
if (!p)
|
|
3976
4369
|
return 1;
|
|
3977
4370
|
const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
|
|
3978
4371
|
showConnectLink(p, opened, tty);
|
|
3979
4372
|
if (tty) {
|
|
3980
4373
|
log("");
|
|
3981
|
-
return waitConnect(root, opts, true);
|
|
4374
|
+
return waitConnect(root, scope, opts, true);
|
|
3982
4375
|
}
|
|
3983
4376
|
log("");
|
|
3984
4377
|
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
4378
|
log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
|
|
3986
4379
|
return 0;
|
|
3987
4380
|
}
|
|
3988
|
-
async function
|
|
3989
|
-
|
|
4381
|
+
async function connectWithTicket(root, ticket) {
|
|
4382
|
+
if (!/^[A-Za-z0-9_-]{32,128}$/.test(ticket)) {
|
|
4383
|
+
err("✘ --ticket needs the ticket connect_device returned (letters, digits, - and _).");
|
|
4384
|
+
return 2;
|
|
4385
|
+
}
|
|
4386
|
+
const scope = connectScope(root);
|
|
4387
|
+
const p = readPendingConnect(scope);
|
|
4388
|
+
if (!p || new Date(p.expiresAt).getTime() <= Date.now()) {
|
|
4389
|
+
const c = activeConnection(root, API);
|
|
4390
|
+
if (c) {
|
|
4391
|
+
log(`✔ This computer is already connected to Decidio${printableName(c.workspaceName) ? ` - workspace ${printableName(c.workspaceName)}` : ""}. Retry the command.`);
|
|
4392
|
+
return 0;
|
|
4393
|
+
}
|
|
4394
|
+
err("✘ this computer has no link waiting to be finished - retry the command Decidio refused (it shows a fresh code), then call connect_device with that code.");
|
|
4395
|
+
return 1;
|
|
4396
|
+
}
|
|
4397
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret, "x-decidio-connect-ticket": ticket }, body: "{}" }, "", true);
|
|
4398
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4399
|
+
const c = storeCollected(scope, p, r.body);
|
|
4400
|
+
if (typeof c === "string") {
|
|
4401
|
+
err(`✘ connected, but this computer's credential couldn't be written (${c}) - run npx @decidio/sdk doctor.`);
|
|
4402
|
+
return 1;
|
|
4403
|
+
}
|
|
4404
|
+
const ws = printableName(c.workspaceName), approver = printableName(c.approver);
|
|
4405
|
+
log(`✔ Connected this computer to Decidio${ws ? ` - workspace ${ws}` : ""}, through the Decidio connector.`);
|
|
4406
|
+
log(`✔ Retry the command Decidio refused: it now asks ${approver ?? "the approver"} before it runs.`);
|
|
4407
|
+
if (r.body.trust === "unconfirmed")
|
|
4408
|
+
log(dim(" Until the workspace owner confirms this computer is theirs (Decidio sent them a notification), every destructive command it asks about goes to a person."));
|
|
4409
|
+
else if (r.body.trust === "cloud")
|
|
4410
|
+
log(dim(" This cloud machine always asks a person for its destructive commands."));
|
|
4411
|
+
return 0;
|
|
4412
|
+
}
|
|
4413
|
+
if (r.status === 202) {
|
|
4414
|
+
err(`✘ this computer's link (code ${p.code}) hasn't been allowed yet - call connect_device with that code first, then run this again with the ticket it returns.`);
|
|
4415
|
+
return 3;
|
|
4416
|
+
}
|
|
4417
|
+
if (r.status === 409 && r.body?.status === "ticket_mismatch") {
|
|
4418
|
+
err(`✘ that ticket is for another link - call connect_device with this computer's code (${p.code}) and use the ticket it returns.`);
|
|
4419
|
+
return 3;
|
|
4420
|
+
}
|
|
4421
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4422
|
+
dropFile(pendingConnectFile(scope));
|
|
4423
|
+
err("✘ this computer's link was refused on the web (Not me). Nothing was saved.");
|
|
4424
|
+
return 3;
|
|
4425
|
+
}
|
|
4426
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4427
|
+
dropFile(pendingConnectFile(scope));
|
|
4428
|
+
err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved - ask them before trying again.");
|
|
4429
|
+
return 3;
|
|
4430
|
+
}
|
|
4431
|
+
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4432
|
+
const won = r.body.status === "collected" ? activeConnection(root, API) : null;
|
|
4433
|
+
if (won) {
|
|
4434
|
+
log("✔ This computer is connected (another run finished it first). Retry the command.");
|
|
4435
|
+
return 0;
|
|
4436
|
+
}
|
|
4437
|
+
dropFile(pendingConnectFile(scope));
|
|
4438
|
+
err("✘ this computer's link expired before it was finished - retry the command Decidio refused for a fresh code, then call connect_device again.");
|
|
4439
|
+
return 3;
|
|
4440
|
+
}
|
|
4441
|
+
if (r.status === 0) {
|
|
4442
|
+
err(`✘ couldn't reach Decidio at ${API} - check the connection and run this again (the ticket stays good for ten minutes).`);
|
|
4443
|
+
return 1;
|
|
4444
|
+
}
|
|
4445
|
+
err(`✘ Decidio answered ${r.status}${r.body?.message ? `: ${String(r.body.message).slice(0, 200)}` : ""}`);
|
|
4446
|
+
return 1;
|
|
4447
|
+
}
|
|
4448
|
+
function storeCollected(scope, p, body) {
|
|
4449
|
+
const c = { v: 1, api: API, root: scope, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(body.agentId), did: p.did, key: p.key,
|
|
4450
|
+
token: body.token, jti: String(body.jti), expiresAt: String(body.expiresAt), workspaceId: String(body.workspaceId), workspaceName: body.workspace?.name ?? null,
|
|
4451
|
+
approver: body.approver?.name ?? null, approvedBy: body.approvedBy?.name ?? null, appUrl: typeof body.appUrl === "string" ? body.appUrl : null, connectedAt: new Date().toISOString(),
|
|
4452
|
+
guardPausable: body.guardPausable !== false, ...(body.answered === false ? { answered: false } : {}),
|
|
4453
|
+
...(typeof body.guardPolicyKey === "string" && GUARD_POLICY_RE.test(body.guardPolicyKey) ? { guardPolicyKey: body.guardPolicyKey } : {}) };
|
|
4454
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4455
|
+
try {
|
|
4456
|
+
writeSecretFile(connectionFile(scope), JSON.stringify(c) + "\n");
|
|
4457
|
+
}
|
|
4458
|
+
catch (e) {
|
|
4459
|
+
return String(e?.message ?? e);
|
|
4460
|
+
}
|
|
4461
|
+
dropFile(pendingConnectFile(scope));
|
|
4462
|
+
return c;
|
|
4463
|
+
}
|
|
4464
|
+
async function waitConnect(root, scope, opts, interactive) {
|
|
4465
|
+
const p = readPendingConnect(scope);
|
|
3990
4466
|
if (!p) {
|
|
3991
|
-
const c =
|
|
4467
|
+
const c = activeConnection(root, API);
|
|
3992
4468
|
if (c)
|
|
3993
4469
|
return finishConnect(root, c, opts, false);
|
|
3994
4470
|
err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
|
|
@@ -4004,29 +4480,41 @@ async function waitConnect(root, opts, interactive) {
|
|
|
4004
4480
|
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4005
4481
|
if (interactive && TTY_OUT)
|
|
4006
4482
|
process.stderr.write("\r\x1b[K");
|
|
4007
|
-
const c =
|
|
4008
|
-
|
|
4009
|
-
|
|
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).`);
|
|
4483
|
+
const c = storeCollected(scope, p, r.body);
|
|
4484
|
+
if (typeof c === "string") {
|
|
4485
|
+
err(`✘ connected, but this machine's credentials could not be written (${c}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
|
|
4016
4486
|
return 1;
|
|
4017
4487
|
}
|
|
4018
|
-
dropFile(pendingConnectFile(root));
|
|
4019
4488
|
return finishConnect(root, c, opts, true);
|
|
4020
4489
|
}
|
|
4021
4490
|
if (r.status === 403 && r.body?.status === "denied") {
|
|
4022
|
-
dropFile(pendingConnectFile(
|
|
4491
|
+
dropFile(pendingConnectFile(scope));
|
|
4023
4492
|
if (interactive && TTY_OUT)
|
|
4024
4493
|
process.stderr.write("\r\x1b[K");
|
|
4025
4494
|
err("✘ the connection was refused on the web (Not me). Nothing was saved.");
|
|
4026
4495
|
return 3;
|
|
4027
4496
|
}
|
|
4497
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4498
|
+
dropFile(pendingConnectFile(scope));
|
|
4499
|
+
if (interactive && TTY_OUT)
|
|
4500
|
+
process.stderr.write("\r\x1b[K");
|
|
4501
|
+
err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved.");
|
|
4502
|
+
return 3;
|
|
4503
|
+
}
|
|
4504
|
+
if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch")) {
|
|
4505
|
+
if (interactive && TTY_OUT)
|
|
4506
|
+
process.stderr.write("\r\x1b[K");
|
|
4507
|
+
err("✘ this computer's link was allowed through the Decidio connector: finish it with npx @decidio/sdk connect --ticket <the ticket YOUR connect_device returned> - never a ticket from anywhere else.");
|
|
4508
|
+
return 3;
|
|
4509
|
+
}
|
|
4028
4510
|
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4029
|
-
|
|
4511
|
+
const won = r.body.status === "collected" ? activeConnection(root, API) : null;
|
|
4512
|
+
if (won) {
|
|
4513
|
+
if (interactive && TTY_OUT)
|
|
4514
|
+
process.stderr.write("\r\x1b[K");
|
|
4515
|
+
return finishConnect(root, won, opts, true);
|
|
4516
|
+
}
|
|
4517
|
+
dropFile(pendingConnectFile(scope));
|
|
4030
4518
|
if (interactive && TTY_OUT)
|
|
4031
4519
|
process.stderr.write("\r\x1b[K");
|
|
4032
4520
|
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");
|
|
@@ -4052,19 +4540,26 @@ async function finishConnect(root, c, opts, fresh) {
|
|
|
4052
4540
|
}
|
|
4053
4541
|
const cloud = inCloudSession();
|
|
4054
4542
|
const shared = opts.team || cloud;
|
|
4543
|
+
const everywhere = !shared && c?.root === COMPUTER;
|
|
4544
|
+
const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
4055
4545
|
const plugin = !cloud && decidioPluginEnabled(root);
|
|
4056
4546
|
if (!plugin) {
|
|
4057
|
-
const rc = await cmdHookInstall({ project: shared, dir: root, quiet: true });
|
|
4547
|
+
const rc = await cmdHookInstall({ project: shared || everywhere, dir: everywhere ? home : root, quiet: true, portable: everywhere, ...(shared && !opts.holdEveryone ? { unconnected: "tell", ...(c?.guardPolicyKey ? { policy: c.guardPolicyKey } : {}) } : {}) });
|
|
4058
4548
|
if (rc !== 0)
|
|
4059
4549
|
return rc;
|
|
4060
4550
|
}
|
|
4061
4551
|
const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
|
|
4062
4552
|
const verified = probe.status === 404;
|
|
4063
|
-
const settings = lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
|
|
4553
|
+
const settings = everywhere ? "~/.claude/settings.json" : lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
|
|
4064
4554
|
const hostName = connectHostLabel(c?.host ?? "claude-code");
|
|
4555
|
+
const approverName = printableName(c?.approver);
|
|
4065
4556
|
if (c) {
|
|
4066
|
-
|
|
4067
|
-
log(`✔
|
|
4557
|
+
const wsName = printableName(c.workspaceName), allowedBy = printableName(c.approvedBy);
|
|
4558
|
+
log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${wsName ? ` - workspace ${wsName}` : ""}${fresh && allowedBy ? `, allowed by ${allowedBy}` : ""}`);
|
|
4559
|
+
if (c.answered === false)
|
|
4560
|
+
log(`• One question is still open for this computer: "Should another person approve this?" - the workspace owner answers it once on the Decidio Agents page${c.appUrl ? ` (${c.appUrl.replace(/\/+$/, "")}/connect/agents#coding-assistants)` : ""}. Until then its owner can't approve its destructive commands - another approver can, or they wait.`);
|
|
4561
|
+
else
|
|
4562
|
+
log(`✔ Approvals go to ${approverName ?? "its approver"} - on the phone app, Slack, Teams or the web`);
|
|
4068
4563
|
}
|
|
4069
4564
|
if (!verified) {
|
|
4070
4565
|
err(probe.status === 401 || probe.status === 403
|
|
@@ -4076,19 +4571,29 @@ async function finishConnect(root, c, opts, fresh) {
|
|
|
4076
4571
|
const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
|
|
4077
4572
|
log(plugin
|
|
4078
4573
|
? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
|
|
4079
|
-
: `✔ ${hostName} asks before destructive commands in this project (${settings})`);
|
|
4574
|
+
: `✔ ${hostName} asks before destructive commands in ${everywhere ? "every project on this computer" : "this project"} (${settings})`);
|
|
4575
|
+
log(dim(` What waits for a person's approval: deleting files (rm -rf), force-pushing, dropping tables, terraform and kubectl changes, publishing a package, curl | sh - and switching Decidio's own check off. Everything else runs as before.`));
|
|
4080
4576
|
if (changed)
|
|
4081
4577
|
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
4578
|
if (c)
|
|
4083
|
-
log(dim(` Credentials: ${connectionFile(root)} (outside the repository, owner-only) · until ${c.expiresAt.slice(0, 10)}${cloud ?
|
|
4084
|
-
if (!plugin && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
|
|
4579
|
+
log(dim(` Credentials: ${connectionFile(c.root)} (outside the repository, owner-only) · ${c.root === COMPUTER ? "every project on this computer" : "this project"} · until ${c.expiresAt.slice(0, 10)}${cloud ? ` · this cloud machine only - a fresh one connects again (the Decidio connector does it with no tap)${opts.holdEveryone ? ", and is refused destructive commands until it does" : ""}` : ""}`));
|
|
4580
|
+
if (!plugin && !everywhere && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
|
|
4085
4581
|
log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
|
|
4086
4582
|
if (cloud && !opts.team)
|
|
4087
|
-
log(`• A cloud session reads
|
|
4583
|
+
log(`• A cloud session reads the repository's settings, not a computer's, so the hooks are in ${settings} - 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
4584
|
else if (shared)
|
|
4089
4585
|
log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
|
|
4090
|
-
|
|
4091
|
-
|
|
4586
|
+
const keyed = !!c?.guardPolicyKey && GUARD_POLICY_RE.test(c.guardPolicyKey);
|
|
4587
|
+
if (shared && !plugin)
|
|
4588
|
+
log(dim(opts.holdEveryone
|
|
4589
|
+
? " A computer that isn't connected is refused every destructive command until it connects (--hold-everyone)."
|
|
4590
|
+
: ` A computer that isn't connected is told once (with how to connect), then the same command runs without Decidio's approval${keyed ? " - unless the workspace owner holds computers that aren't connected (the Agents page)" : ""}. To keep refusing it until it connects: npx @decidio/sdk connect --team --hold-everyone`));
|
|
4591
|
+
if (shared && !plugin && !opts.holdEveryone && !keyed)
|
|
4592
|
+
log(`⚠ This file's Decidio line carries no team key, so the workspace owner's "Hold computers that aren't connected" won't reach this repository. ${c
|
|
4593
|
+
? "This computer's connection was made before that switch existed: run npx @decidio/sdk connect --team --force (the workspace owner allows it once more), then commit the file."
|
|
4594
|
+
: "These credentials come from the shell or .env: run npx @decidio/sdk connect --team from a computer connected with npx @decidio/sdk connect, then commit the file."}`);
|
|
4595
|
+
if (fresh && c?.answered !== false)
|
|
4596
|
+
log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${approverName ?? "the approver"} on their phone.`));
|
|
4092
4597
|
return 0;
|
|
4093
4598
|
}
|
|
4094
4599
|
const DECIDIO_PLUGIN = "decidio-approvals";
|
|
@@ -4106,21 +4611,23 @@ function decidioPluginEnabled(root) {
|
|
|
4106
4611
|
}
|
|
4107
4612
|
async function cmdDisconnect() {
|
|
4108
4613
|
const root = projectRoot();
|
|
4109
|
-
const
|
|
4110
|
-
|
|
4614
|
+
const scope = connectScope(root);
|
|
4615
|
+
const c = loadConnection(scope, API);
|
|
4616
|
+
dropFile(pendingConnectFile(scope));
|
|
4617
|
+
const where = scope === COMPUTER ? "this computer" : "this project on this machine";
|
|
4111
4618
|
if (!c) {
|
|
4112
|
-
log(
|
|
4619
|
+
log(`• ${where} isn't connected - nothing to do.`);
|
|
4113
4620
|
return 0;
|
|
4114
4621
|
}
|
|
4115
4622
|
try {
|
|
4116
|
-
writeSecretFile(connectionFile(
|
|
4623
|
+
writeSecretFile(connectionFile(scope), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
|
|
4117
4624
|
}
|
|
4118
4625
|
catch (e) {
|
|
4119
|
-
err(`✘ couldn't update ${connectionFile(
|
|
4626
|
+
err(`✘ couldn't update ${connectionFile(scope)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
|
|
4120
4627
|
return 1;
|
|
4121
4628
|
}
|
|
4122
|
-
log(`✔ disconnected
|
|
4123
|
-
log(` Destructive commands are now refused until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
|
|
4629
|
+
log(`✔ disconnected ${where} - its token is deleted.`);
|
|
4630
|
+
log(` Destructive commands are now refused${scope === COMPUTER ? " in every project here" : ""} until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
|
|
4124
4631
|
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
4632
|
return 0;
|
|
4126
4633
|
}
|
|
@@ -5403,17 +5910,21 @@ APPROVALS AND RECEIPTS
|
|
|
5403
5910
|
(never the receipt's own issuer field; --allow-unpinned: structural check only)
|
|
5404
5911
|
|
|
5405
5912
|
CODING AGENTS (Claude Code)
|
|
5406
|
-
npx @decidio/sdk connect connect this
|
|
5407
|
-
code (a QR and the browser too, on a real terminal); the workspace
|
|
5408
|
-
it on the web, then this
|
|
5409
|
-
repository) and the hooks below switch on
|
|
5913
|
+
npx @decidio/sdk connect connect this computer's coding assistant once, for every project on it: prints a
|
|
5914
|
+
link and a short code (a QR and the browser too, on a real terminal); the workspace
|
|
5915
|
+
owner allows it on the web, then this computer gets an ask-only credential (kept
|
|
5916
|
+
outside every repository) and the hooks below switch on in ~/.claude/settings.json.
|
|
5410
5917
|
npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
|
|
5411
5918
|
(exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
|
|
5412
|
-
--team: the shared .claude/settings.json every clone reads (
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5919
|
+
--team: the shared .claude/settings.json every clone reads (a cloud session reads
|
|
5920
|
+
the repository's files, not a computer's, so there it is always the shared one); a
|
|
5921
|
+
computer that isn't connected is told once, then let through - --hold-everyone keeps
|
|
5922
|
+
refusing it · --no-browser · --force: a fresh token for this computer
|
|
5923
|
+
npx @decidio/sdk connect --ticket <t>
|
|
5924
|
+
finish the link the Decidio connector allowed (its connect_device tool returns the
|
|
5925
|
+
ticket): writes this computer's credential, nothing else
|
|
5926
|
+
npx @decidio/sdk disconnect delete this computer's connection (destructive commands are then refused until
|
|
5927
|
+
you connect again; the hooks stay on)
|
|
5417
5928
|
npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
|
|
5418
5929
|
a person approves Claude Code's destructive shell commands before they run
|
|
5419
5930
|
(rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
|
|
@@ -5429,11 +5940,20 @@ CODING AGENTS (Claude Code)
|
|
|
5429
5940
|
refused: store the user's one-line reason it is different, then retry the same
|
|
5430
5941
|
command - the request reaches the approver with that reason beside the refusal
|
|
5431
5942
|
npx @decidio/sdk hook rules the command patterns the hook gates (DECIDIO_HOOK_EXTRA_RULES adds yours)
|
|
5943
|
+
npx @decidio/sdk hook managed-settings the Decidio hooks for Claude Code's managed settings (an Enterprise admin deploys
|
|
5944
|
+
them to every developer's computer) - the JSON on stdout, where it goes and what it
|
|
5945
|
+
does and doesn't guarantee on stderr; writes nothing
|
|
5432
5946
|
npx @decidio/sdk hook pretool|posttool the hooks themselves: run by Claude Code with the tool call as JSON on
|
|
5433
5947
|
stdin, never by hand. A gated command is held up to DECIDIO_HOOK_HOLD_MS
|
|
5434
5948
|
(90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
|
|
5435
5949
|
retried after approval runs once - its execution is recorded and that
|
|
5436
5950
|
approval is spent. No credentials, no gate, no answer = refused (fail closed).
|
|
5951
|
+
--unconnected=tell (written into a shared file by connect): a computer with no
|
|
5952
|
+
credential is refused once per session and command, then let through with a line.
|
|
5953
|
+
npx @decidio/sdk hook prompt the UserPromptSubmit hook: the person's own typed "decidio pause" / "decidio start"
|
|
5954
|
+
pauses or resumes the guard for that session (not where the workspace keeps the
|
|
5955
|
+
guard on - Enterprise by default; every pause and every command run under it is
|
|
5956
|
+
recorded); other prompts pass untouched.
|
|
5437
5957
|
|
|
5438
5958
|
CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
|
|
5439
5959
|
npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
|
|
@@ -5661,7 +6181,7 @@ async function main(argv) {
|
|
|
5661
6181
|
logout: { flags: [] },
|
|
5662
6182
|
phone: { flags: [] },
|
|
5663
6183
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
5664
|
-
connect: { flags: ["--wait", "--no-browser", "--team", "--force"], valued: ["--host"] },
|
|
6184
|
+
connect: { flags: ["--wait", "--no-browser", "--team", "--force", "--hold-everyone"], valued: ["--host", "--ticket"] },
|
|
5665
6185
|
disconnect: { flags: [] },
|
|
5666
6186
|
doctor: { flags: [] },
|
|
5667
6187
|
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
@@ -5669,7 +6189,7 @@ async function main(argv) {
|
|
|
5669
6189
|
verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
|
|
5670
6190
|
tokens: { flags: ["--mine"] },
|
|
5671
6191
|
dev: { flags: [], valued: ["--target", "--port"] },
|
|
5672
|
-
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
|
|
6192
|
+
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected", "--policy"] },
|
|
5673
6193
|
github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
|
|
5674
6194
|
deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
|
|
5675
6195
|
controls: { flags: ["--json"] },
|
|
@@ -5870,13 +6390,14 @@ async function main(argv) {
|
|
|
5870
6390
|
}
|
|
5871
6391
|
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") });
|
|
5872
6392
|
}
|
|
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")
|
|
6393
|
+
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"),
|
|
6394
|
+
ticket: valuedOpt(argsIn, "--ticket"), holdEveryone: argsIn.includes("--hold-everyone") });
|
|
5874
6395
|
case "disconnect": return cmdDisconnect();
|
|
5875
6396
|
case "hook": {
|
|
5876
|
-
const VALUED = ["--timeout", "--approver", "--cli"];
|
|
6397
|
+
const VALUED = ["--timeout", "--approver", "--cli", "--unconnected", "--policy"];
|
|
5877
6398
|
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
5878
6399
|
const sub = pos[0];
|
|
5879
|
-
const allowed = { pretool: [], posttool: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
|
|
6400
|
+
const allowed = { pretool: ["--unconnected", "--policy"], posttool: [], session: [], prompt: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [], "managed-settings": [] };
|
|
5880
6401
|
const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
|
|
5881
6402
|
if (sub && allowed[sub]) {
|
|
5882
6403
|
const stray = given.find((f) => !allowed[sub].includes(f));
|
|
@@ -5886,8 +6407,13 @@ async function main(argv) {
|
|
|
5886
6407
|
}
|
|
5887
6408
|
}
|
|
5888
6409
|
switch (sub) {
|
|
5889
|
-
case "pretool":
|
|
6410
|
+
case "pretool": {
|
|
6411
|
+
const pol = valuedOpt(argsIn, "--policy");
|
|
6412
|
+
return cmdHookPretool({ unconnected: valuedOpt(argsIn, "--unconnected") === "tell" ? "tell" : "hold", ...(pol && GUARD_POLICY_RE.test(pol) ? { policy: pol } : {}) });
|
|
6413
|
+
}
|
|
5890
6414
|
case "posttool": return cmdHookPosttool();
|
|
6415
|
+
case "session": return cmdHookSession();
|
|
6416
|
+
case "prompt": return cmdHookPrompt();
|
|
5891
6417
|
case "wait": {
|
|
5892
6418
|
const t = valuedOpt(argsIn, "--timeout") ?? "600";
|
|
5893
6419
|
if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
|
|
@@ -5899,8 +6425,9 @@ async function main(argv) {
|
|
|
5899
6425
|
case "install": return cmdHookInstall({ project: argsIn.includes("--project"), approver: valuedOpt(argsIn, "--approver"), cli: valuedOpt(argsIn, "--cli") });
|
|
5900
6426
|
case "rules": return cmdHookRules();
|
|
5901
6427
|
case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
|
|
6428
|
+
case "managed-settings": return cmdHookManagedSettings();
|
|
5902
6429
|
default:
|
|
5903
|
-
err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool and
|
|
6430
|
+
err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules | managed-settings (pretool, posttool and session are run by Claude Code).\n\n${USAGE}`);
|
|
5904
6431
|
return 2;
|
|
5905
6432
|
}
|
|
5906
6433
|
}
|