@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/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 = loadConnection(projectRoot(), API);
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
- async function cmdHookPretool() {
3402
- const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); return 0; };
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
- g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs,
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
- const cfg = hookConfig();
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} awaits approval${where ? ` - ${where}` : ""}`);
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
- real = "$CLAUDE_PROJECT_DIR/node_modules/" + real.slice(localRoot.length).split(pathSep).join("/");
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 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
- }
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
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
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
- async function startConnect(root, host) {
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 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
- }
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 = loadConnection(root, API);
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(root);
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(root, host);
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 waitConnect(root, opts, interactive) {
3989
- const p = readPendingConnect(root);
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 = loadConnection(root, API);
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 = { 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).`);
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(root));
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
- dropFile(pendingConnectFile(root));
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
- 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`);
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 ? " · 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))
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 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).`);
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
- 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.`));
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 c = loadConnection(root, API);
4110
- dropFile(pendingConnectFile(root));
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("• this project isn't connected on this machine - nothing to do.");
4619
+ log(`• ${where} isn't connected - nothing to do.`);
4113
4620
  return 0;
4114
4621
  }
4115
4622
  try {
4116
- writeSecretFile(connectionFile(root), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4623
+ writeSecretFile(connectionFile(scope), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4117
4624
  }
4118
4625
  catch (e) {
4119
- err(`✘ couldn't update ${connectionFile(root)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
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 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).`);
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 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.
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 (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)
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": return cmdHookPretool();
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 posttool are run by Claude Code).\n\n${USAGE}`);
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
  }