@decidio/sdk 0.11.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
@@ -78,6 +78,7 @@ const DEFAULT_API = "https://decidio-api.onrender.com";
78
78
  let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
79
79
  const COMPUTER = "*";
80
80
  const EVERY_PROJECT = "every project";
81
+ const GUARD_POLICY_RE = /^[A-Za-z0-9_-]{20,64}$/;
81
82
  let CONNECTED_APPROVER = null;
82
83
  let CONNECTED_UNANSWERED = false;
83
84
  let CONNECTED_APP_URL = null;
@@ -3450,10 +3451,12 @@ async function cmdHookPrompt() {
3450
3451
  const body = await r.json().catch(() => null);
3451
3452
  if (r.status === 403 && body?.code === "ORGANISATION_KEEPS_ON") {
3452
3453
  notePause(session, false);
3454
+ rememberPausable(false);
3453
3455
  return block(ORGANISATION_KEEPS_ON);
3454
3456
  }
3455
3457
  if (r.ok && body?.state === "paused") {
3456
3458
  notePause(session, true);
3459
+ rememberPausable(true);
3457
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.`);
3458
3461
  }
3459
3462
  if (r.ok && body?.state === "on") {
@@ -3468,6 +3471,17 @@ async function cmdHookPrompt() {
3468
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"}.`);
3469
3472
  }
3470
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
+ }
3471
3485
  function hookForgesPrompt(command, depth = 0) {
3472
3486
  const hit = hookSegments(command).some((s) => {
3473
3487
  const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
@@ -3555,8 +3569,11 @@ async function cmdHookPretool(opts = { unconnected: "hold" }) {
3555
3569
  const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
3556
3570
  let cfg = hookConfig();
3557
3571
  if ("missing" in cfg) {
3558
- const got = await hookProvision();
3572
+ const got = await hookProvision(opts.policy);
3559
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);
3560
3577
  if (opts.unconnected === "tell" && !g.offSwitch) {
3561
3578
  const told = toldOnce(typeof input.session_id === "string" ? input.session_id : "", g.binding);
3562
3579
  if (told === "passed")
@@ -3819,6 +3836,53 @@ function hookRunner(portable = false) {
3819
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`;
3820
3837
  const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
3821
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
+ }
3822
3886
  async function cmdHookInstall(opts) {
3823
3887
  const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
3824
3888
  let settings = {};
@@ -3865,17 +3929,16 @@ async function cmdHookInstall(opts) {
3865
3929
  hooks[event].push(matcher ? { matcher, hooks: [want] } : { hooks: [want] });
3866
3930
  return state;
3867
3931
  };
3868
- const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool${opts.unconnected === "tell" ? " --unconnected=tell" : ""}`, 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);
3869
3934
  const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
3870
3935
  const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
3871
- place("SessionStart", "startup|resume", undefined, `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook session`, { timeout: 15 });
3872
- place("UserPromptSubmit", "", undefined, hookPromptLine(runner), { timeout: 15 });
3873
3936
  const guard = { added: 0, updated: 0, kept: 0 };
3874
- for (const t of HOOK_FILE_TOOLS)
3875
- for (const p of HOOK_GUARD_PATHS) {
3876
- guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
3877
- guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
3878
- }
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
+ }
3879
3942
  mkdirSync(dirname(settingsPath), { recursive: true });
3880
3943
  const target = realOrSame(settingsPath), tmpSettings = `${target}.decidio-${process.pid}.tmp`;
3881
3944
  writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + "\n", { mode: existsSync(target) ? statSync(target).mode & 0o777 : 0o644 });
@@ -4028,25 +4091,45 @@ function openInBrowser(url) {
4028
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; };
4029
4092
  let lastHookInstall = null;
4030
4093
  let connectError = "";
4031
- async function startConnect(root, host, quiet = false) {
4032
- const fail = (m) => { connectError = m; if (!quiet)
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)
4033
4109
  err(`✘ ${m}`); return null; };
4034
4110
  const prior = connectionRecord(root, API);
4035
4111
  const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
4036
4112
  const cloud = inCloudSession();
4037
4113
  const project = (root === COMPUTER ? EVERY_PROJECT : projectLabel(root)).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
4038
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") });
4039
- const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
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
+ }
4040
4122
  if (r.status === 0)
4041
- return fail(`couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
4123
+ return fail(`couldn't reach Decidio at ${API} - ${inCloudSession() ? cloudNetworkWords() : "check your connection and run npx @decidio/sdk connect again."}`, "unreachable");
4042
4124
  if (r.status === 404 || r.status === 401)
4043
- 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})`);
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");
4044
4126
  if (r.status === 429)
4045
- return fail(`${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
4127
+ return fail(`${r.body?.message ?? "Too many tries - wait a minute and try again."}`, "unreachable");
4046
4128
  if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string")
4047
- return fail(`couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
4129
+ return fail(`couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`, r.status >= 500 ? "unreachable" : "other");
4048
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),
4049
- 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 } : {}) };
4050
4133
  mkdirSync(connectDir(), { recursive: true });
4051
4134
  writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
4052
4135
  if (typeof r.body.qr === "string")
@@ -4058,8 +4141,10 @@ function readPendingConnect(root) {
4058
4141
  const p = readJsonQuiet(pendingConnectFile(root));
4059
4142
  return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
4060
4143
  }
4061
- async function hookProvision() {
4144
+ async function hookProvision(policy) {
4062
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 };
4063
4148
  if (process.env.DECIDIO_AGENT_ID) {
4064
4149
  const named = printableName(process.env.DECIDIO_AGENT_ID) ?? "(unprintable)";
4065
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` };
@@ -4077,7 +4162,7 @@ async function hookProvision() {
4077
4162
  }
4078
4163
  const linkWords = (p, fresh) => {
4079
4164
  const mins = minutesLeft(p.expiresAt) || 1;
4080
- return { ok: false,
4165
+ return { ok: false, ...holdOf(p),
4081
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.`,
4082
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.` };
4083
4168
  };
@@ -4100,7 +4185,7 @@ async function hookProvision() {
4100
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.`,
4101
4186
  user: "Decidio: finishing this computer's connection (allowed through the Decidio connector) - destructive commands wait until it's done" };
4102
4187
  if (r.status === 0)
4103
- return { ok: false, reason: `couldn't reach Decidio at ${API} to finish connecting this computer - retry once it is reachable.`, user: "Decidio: unreachable - destructive commands wait" };
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)})` : ""}` };
4104
4189
  if (r.status === 410 && r.body?.status === "collected") {
4105
4190
  const won = activeConnection(projectRoot(), API);
4106
4191
  if (won) {
@@ -4152,9 +4237,9 @@ async function hookProvision() {
4152
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" };
4153
4238
  }
4154
4239
  try {
4155
- const started = await startConnect(scope, "claude-code", true);
4240
+ const started = await startConnect(scope, "claude-code", true, policy);
4156
4241
  if (!started)
4157
- return { ok: false, reason: `this computer isn't connected to Decidio, and a connection couldn't be started: ${connectError}`, user: "Decidio: this computer isn't connected" };
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)}` : ""}` };
4158
4243
  return linkWords(started, true);
4159
4244
  }
4160
4245
  finally {
@@ -4364,7 +4449,8 @@ function storeCollected(scope, p, body) {
4364
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,
4365
4450
  token: body.token, jti: String(body.jti), expiresAt: String(body.expiresAt), workspaceId: String(body.workspaceId), workspaceName: body.workspace?.name ?? null,
4366
4451
  approver: body.approver?.name ?? null, approvedBy: body.approvedBy?.name ?? null, appUrl: typeof body.appUrl === "string" ? body.appUrl : null, connectedAt: new Date().toISOString(),
4367
- guardPausable: body.guardPausable !== false, ...(body.answered === false ? { answered: false } : {}) };
4452
+ guardPausable: body.guardPausable !== false, ...(body.answered === false ? { answered: false } : {}),
4453
+ ...(typeof body.guardPolicyKey === "string" && GUARD_POLICY_RE.test(body.guardPolicyKey) ? { guardPolicyKey: body.guardPolicyKey } : {}) };
4368
4454
  mkdirSync(connectDir(), { recursive: true });
4369
4455
  try {
4370
4456
  writeSecretFile(connectionFile(scope), JSON.stringify(c) + "\n");
@@ -4458,7 +4544,7 @@ async function finishConnect(root, c, opts, fresh) {
4458
4544
  const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
4459
4545
  const plugin = !cloud && decidioPluginEnabled(root);
4460
4546
  if (!plugin) {
4461
- const rc = await cmdHookInstall({ project: shared || everywhere, dir: everywhere ? home : root, quiet: true, portable: everywhere, ...(shared && !opts.holdEveryone ? { unconnected: "tell" } : {}) });
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 } : {}) } : {}) });
4462
4548
  if (rc !== 0)
4463
4549
  return rc;
4464
4550
  }
@@ -4497,10 +4583,15 @@ async function finishConnect(root, c, opts, fresh) {
4497
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).`);
4498
4584
  else if (shared)
4499
4585
  log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
4586
+ const keyed = !!c?.guardPolicyKey && GUARD_POLICY_RE.test(c.guardPolicyKey);
4500
4587
  if (shared && !plugin)
4501
4588
  log(dim(opts.holdEveryone
4502
4589
  ? " A computer that isn't connected is refused every destructive command until it connects (--hold-everyone)."
4503
- : " A computer that isn't connected is told once (with how to connect), then the same command runs without Decidio's approval. To keep refusing it until it connects: npx @decidio/sdk connect --team --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."}`);
4504
4595
  if (fresh && c?.answered !== false)
4505
4596
  log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${approverName ?? "the approver"} on their phone.`));
4506
4597
  return 0;
@@ -5849,6 +5940,9 @@ CODING AGENTS (Claude Code)
5849
5940
  refused: store the user's one-line reason it is different, then retry the same
5850
5941
  command - the request reaches the approver with that reason beside the refusal
5851
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
5852
5946
  npx @decidio/sdk hook pretool|posttool the hooks themselves: run by Claude Code with the tool call as JSON on
5853
5947
  stdin, never by hand. A gated command is held up to DECIDIO_HOOK_HOLD_MS
5854
5948
  (90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
@@ -5857,8 +5951,9 @@ CODING AGENTS (Claude Code)
5857
5951
  --unconnected=tell (written into a shared file by connect): a computer with no
5858
5952
  credential is refused once per session and command, then let through with a line.
5859
5953
  npx @decidio/sdk hook prompt the UserPromptSubmit hook: the person's own typed "decidio pause" / "decidio start"
5860
- pauses or resumes the guard for that session (never on an enterprise plan; every
5861
- pause and every command run under it is recorded); other prompts pass untouched.
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.
5862
5957
 
5863
5958
  CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
5864
5959
  npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
@@ -6094,7 +6189,7 @@ async function main(argv) {
6094
6189
  verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
6095
6190
  tokens: { flags: ["--mine"] },
6096
6191
  dev: { flags: [], valued: ["--target", "--port"] },
6097
- hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected"] },
6192
+ hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected", "--policy"] },
6098
6193
  github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
6099
6194
  deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
6100
6195
  controls: { flags: ["--json"] },
@@ -6299,10 +6394,10 @@ async function main(argv) {
6299
6394
  ticket: valuedOpt(argsIn, "--ticket"), holdEveryone: argsIn.includes("--hold-everyone") });
6300
6395
  case "disconnect": return cmdDisconnect();
6301
6396
  case "hook": {
6302
- const VALUED = ["--timeout", "--approver", "--cli", "--unconnected"];
6397
+ const VALUED = ["--timeout", "--approver", "--cli", "--unconnected", "--policy"];
6303
6398
  const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
6304
6399
  const sub = pos[0];
6305
- const allowed = { pretool: ["--unconnected"], posttool: [], session: [], prompt: [], 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": [] };
6306
6401
  const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
6307
6402
  if (sub && allowed[sub]) {
6308
6403
  const stray = given.find((f) => !allowed[sub].includes(f));
@@ -6312,7 +6407,10 @@ async function main(argv) {
6312
6407
  }
6313
6408
  }
6314
6409
  switch (sub) {
6315
- case "pretool": return cmdHookPretool({ unconnected: valuedOpt(argsIn, "--unconnected") === "tell" ? "tell" : "hold" });
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
+ }
6316
6414
  case "posttool": return cmdHookPosttool();
6317
6415
  case "session": return cmdHookSession();
6318
6416
  case "prompt": return cmdHookPrompt();
@@ -6327,8 +6425,9 @@ async function main(argv) {
6327
6425
  case "install": return cmdHookInstall({ project: argsIn.includes("--project"), approver: valuedOpt(argsIn, "--approver"), cli: valuedOpt(argsIn, "--cli") });
6328
6426
  case "rules": return cmdHookRules();
6329
6427
  case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
6428
+ case "managed-settings": return cmdHookManagedSettings();
6330
6429
  default:
6331
- err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool, posttool and session 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}`);
6332
6431
  return 2;
6333
6432
  }
6334
6433
  }
package/dist/demo-gate.js CHANGED
@@ -41,6 +41,7 @@ export async function startDemoGate(opts = {}) {
41
41
  let relayCode = null;
42
42
  let relayOk = null;
43
43
  let committed = null;
44
+ let claimedBy = null;
44
45
  const relayFetch = async (path, init = {}) => {
45
46
  if (!relayBase)
46
47
  return null;
@@ -112,6 +113,7 @@ export async function startDemoGate(opts = {}) {
112
113
  return send({ ok: true, changed: true });
113
114
  if (path === "/agent/authorize") {
114
115
  verdict = null;
116
+ claimedBy = null;
115
117
  committed = typeof body?.requestCommitment === "string" && /^[0-9a-f]{64}$/.test(body.requestCommitment) ? body.requestCommitment : null;
116
118
  return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1", requestCommitment: committed });
117
119
  }
@@ -128,6 +130,18 @@ export async function startDemoGate(opts = {}) {
128
130
  return send({ state: "rejected", reason: "quickstart: withdrawn — the tour stopped waiting", requestCommitment: committed });
129
131
  return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver", requestCommitment: committed } : { state: verdict === "approve" ? "approved" : "pending", requestCommitment: committed });
130
132
  }
133
+ if (path.startsWith("/agent/claim/")) {
134
+ const key = typeof body?.claimKey === "string" && /^[A-Za-z0-9._:-]{16,128}$/.test(body.claimKey) ? body.claimKey : null;
135
+ if (!key)
136
+ return send({ code: "VALIDATION", message: "claimKey must be 16-128 characters of letters, digits, dot, colon, underscore or dash" }, 400);
137
+ if (verdict !== "approve")
138
+ return send({ code: "NOT_CLAIMABLE", decisionId: DECISION_ID, state: verdict === "reject" ? "rejected" : "pending", message: "only a person's approval is collected here" }, 409);
139
+ if (claimedBy && claimedBy !== key)
140
+ return send({ code: "ALREADY_USED", decisionId: DECISION_ID, state: "approved", message: "another caller already collected this approval - ask again for a new one" }, 409);
141
+ const again = claimedBy === key;
142
+ claimedBy = key;
143
+ return send({ ok: true, claimed: true, decisionId: DECISION_ID, again });
144
+ }
131
145
  if (path.startsWith("/agent/withdraw/")) {
132
146
  if (verdict)
133
147
  return send({ code: "NOT_OPEN", state: verdict === "approve" ? "approved" : "rejected", decisionId: DECISION_ID }, 409);
package/dist/errors.js CHANGED
@@ -30,7 +30,9 @@ export class DecidioHttpError extends Error {
30
30
  ? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
31
31
  : /\/agent\/status/.test(endpoint)
32
32
  ? ` — this looks transient; polling again is safe (a status read changes nothing).`
33
- : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
33
+ : /\/agent\/authorize/.test(endpoint)
34
+ ? ` — this looks transient. This SDK already retried it with the same request key (a request still waiting is answered with the one already waiting); retrying is safe and can never double-execute.`
35
+ : ` — this looks transient; retrying is safe and can never double-execute.`)
34
36
  : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
35
37
  sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
36
38
  this.status = status;
package/dist/guard.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface GuardFacadeConfig {
17
17
  workspaceId?: string;
18
18
  /** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
19
19
  appUrl?: string;
20
+ /** The per-call id behind the default request key (0.12.0) - see GuardConfig.invocationId. Leave it unset. */
21
+ invocationId?: () => string;
20
22
  fetchImpl?: typeof fetch;
21
23
  }
22
24
  export interface ProtectOptions {
@@ -53,6 +55,9 @@ export interface ProtectOptions {
53
55
  * success indication, so instead of forcing your API into magic field names:
54
56
  * `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
55
57
  evidence?: (result: any) => unknown;
58
+ /** YOUR OWN REQUEST KEY for a call, from its arguments (0.12.0) - e.g. `(inv) => inv.id` - so the same call asked again,
59
+ * even from a new process, is the same request. Without it each call sends a fresh random key. See withApproval. */
60
+ requestKey?: (...args: any[]) => string;
56
61
  }
57
62
  export declare class Guard {
58
63
  config: GuardFacadeConfig;
package/dist/guard.js CHANGED
@@ -60,6 +60,7 @@ export class Guard {
60
60
  const base = {
61
61
  apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
62
62
  sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
63
+ ...(c.invocationId ? { invocationId: c.invocationId } : {}),
63
64
  pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
64
65
  };
65
66
  if ((opts?.mode ?? "durable") === "blocking") {
@@ -68,12 +69,12 @@ export class Guard {
68
69
  `(pollTimeoutMs), and when it does, this process stops waiting and the request is withdrawn in Decidio: ` +
69
70
  `a later approval does nothing. Only safe for short, watched approvals — omit mode:"blocking" for ` +
70
71
  `durable async resume on real (minutes-to-days) approvals.`);
71
- return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
72
+ return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}), ...(opts?.requestKey ? { requestKey: opts.requestKey } : {}) });
72
73
  }
73
74
  const resume = this.ensureResume();
74
75
  if (opts?.action)
75
76
  resume.register(opts.action, fn, opts?.evidence, opts?.withContext);
76
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
77
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}), ...(opts?.requestKey ? { requestKey: opts.requestKey } : {}) });
77
78
  }
78
79
  protectBestEffort(fn, describe, opts) {
79
80
  const wrapped = this.protect(fn, describe, opts);
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { ResumeController, ApprovalCheck } from "./resume.js";
1
+ import type { ResumeController, ApprovalCheck, ClaimOutcome } from "./resume.js";
2
2
  import { type CommittedRequest } from "./signing.js";
3
3
  import { type EvidenceOutcome } from "./evidence.js";
4
- export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
5
- export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus } from "./resume.js";
4
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval, claimApproval, newClaimKey } from "./resume.js";
5
+ export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus, ClaimOutcome, ClaimCode } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
7
7
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
8
8
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
9
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
9
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, invocationRequestKey, ownRequestKey, committedRequestJson } from "./signing.js";
10
10
  export type { BindingMaterial, CommittedRequest } from "./signing.js";
11
11
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
12
12
  export type { JsonValue, JsonPrimitive } from "./canonical.js";
@@ -61,6 +61,11 @@ export interface GuardConfig {
61
61
  * Sent on /agent/authorize so the decision carries it. Falls back to
62
62
  * resume.resumeUrl. Omit when using the poll worker. */
63
63
  resumeUrl?: string;
64
+ /** Where the per-call id behind the DEFAULT REQUEST KEY comes from (0.12.0, 11f). Every call that sends a request
65
+ * commitment also sends a request key derived from a fresh random id and that commitment, so this SDK's own retry of
66
+ * the authorize is the same request. Leave it unset: a fixed id makes every call the SAME request. It exists so a
67
+ * test or a conformance harness can pin the bytes it compares. */
68
+ invocationId?: () => string;
64
69
  fetchImpl?: typeof fetch;
65
70
  }
66
71
  export interface ApprovalContext {
@@ -115,11 +120,15 @@ export declare function coreRequest(ctx: {
115
120
  export declare class DecidioTimeoutError extends Error {
116
121
  decisionId: string;
117
122
  withdrawn: boolean;
123
+ shared: boolean;
118
124
  /** `withdrawn` (0.6.4+): true when the SDK told Decidio it gave up and the request was closed as
119
125
  * WITHDRAWN (the queue no longer carries it; a later approval does nothing); false when that
120
126
  * best-effort call failed, so the request may still be open in Decidio. Nothing was executed
121
- * locally either way. */
122
- constructor(decisionId: string, withdrawn?: boolean);
127
+ * locally either way.
128
+ * `shared` (0.12.0): true when the request was left open on purpose because it may be shared - the authorize
129
+ * answered an earlier request (`idempotentReplay`) or joined one another caller is waiting on (`openTwin`), or the
130
+ * withdraw was refused because another caller joined it (409 SHARED_REQUEST). A person still decides it. */
131
+ constructor(decisionId: string, withdrawn?: boolean, shared?: boolean);
123
132
  }
124
133
  /** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
125
134
  * operation, the underlying code, and the safety state a caller cares about most: whether
@@ -132,7 +141,10 @@ export declare class DecidioRateLimitError extends Error {
132
141
  limit: number | undefined;
133
142
  remaining: number | undefined;
134
143
  resetAt: string | undefined;
135
- constructor(limit: number | undefined, remaining: number | undefined, resetAt: string | undefined, serverMessage?: string);
144
+ code?: string | undefined;
145
+ /** `code` (0.12.0): the server's own code - `AGENT_RATE` for the workspace's hourly allowance, which is never retried;
146
+ * a 429 without it is a transport throttle the SDK retries before it gives up. */
147
+ constructor(limit: number | undefined, remaining: number | undefined, resetAt: string | undefined, serverMessage?: string, code?: string | undefined);
136
148
  }
137
149
  export declare class DecidioNetworkError extends Error {
138
150
  endpoint: string;
@@ -145,6 +157,18 @@ export declare class DecidioNetworkError extends Error {
145
157
  * every engine door reached the mock authorization path with a request the core would have refused.
146
158
  * Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
147
159
  export declare function assertDescribed(ctx: ApprovalContext): void;
160
+ /** THE ONE AUTHORIZE POST (0.12.0, 11f) - the core wrapper and every engine adapter send through here. A network failure
161
+ * or a transient answer is retried a bounded number of times with the SAME body: under a request key that is the same
162
+ * request (the gate answers the decision it already made, `idempotentReplay`), and a keyless request asked again while
163
+ * it waits joins the one waiting (`openTwin`). A body that carries a commitment WITHOUT a key is never retried - nothing
164
+ * would make the retry the same request. `rateLimit: "http"` keeps an adapter's 429 a DecidioHttpError, as it always was. */
165
+ export declare function sendAuthorize(config: GuardConfig, first: Record<string, unknown>, opts?: {
166
+ rateLimit?: "typed" | "http";
167
+ resign?: () => unknown;
168
+ }): Promise<any>;
169
+ /** The typed refusal for a claim that did not let this run go ahead (0.12.0) - DecidioBlockedError, so code that treats
170
+ * a block as "did not run" keeps doing so; the words say what happened and what to do ("ask again"). */
171
+ export declare function claimRefusal(claim: ClaimOutcome): DecidioBlockedError;
148
172
  /**
149
173
  * Wrap an async action behind the Decidio gate. `describe` extracts the approval
150
174
  * context (action, amount, scope) from the call arguments. Returns a function with
@@ -172,6 +196,9 @@ export interface ProtectedExecution<R> {
172
196
  /** What Decidio recorded. `reportRecorded` false means the execution happened and Decidio does
173
197
  * NOT know; `executionConfirmed` false means it knows but could not attest a commit. */
174
198
  confirmation: EvidenceOutcome;
199
+ /** The request key this call sent with its authorize (0.12.0, 11f) - absent when none was sent (no key material, so no
200
+ * commitment). It names the REQUEST to Decidio; your downstream idempotency key stays `decisionId`. */
201
+ requestKey?: string;
175
202
  }
176
203
  export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
177
204
  /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
@@ -183,4 +210,9 @@ export declare function withApproval<A extends any[], R>(fn: (...args: A) => Pro
183
210
  * idempotencyKey }`. Opt-in, never inferred from the function's arity — guessing wrong means
184
211
  * passing a context object as someone's first business argument. */
185
212
  withContext?: boolean;
213
+ /** YOUR OWN REQUEST KEY for a call (0.12.0, 11f), from its arguments - an order or invoice id - so the same call asked
214
+ * again, even from a new process, is the same request. Keyed with this agent's material before it is sent (your id
215
+ * never reaches Decidio in the clear). The same key for a CHANGED request is refused by the gate (409
216
+ * IDEMPOTENCY_CONFLICT), never a silent second request. Without it each call sends a fresh random key. */
217
+ requestKey?: (...args: A) => string;
186
218
  }): (...args: A) => Promise<ProtectedExecution<R>>;