@decidio/sdk 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +517 -89
- package/openapi.yaml +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,55 @@ allowlist. Each entry names what changed, what a reader must do on upgrade, and
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
-
## 0.
|
|
10
|
+
## 0.11.0 — connect once, on in every project; no surprises; the connector connects a computer
|
|
11
|
+
|
|
12
|
+
0.10.0 was never published (its tag was held for this release), so 0.11.0 is the first release with `connect`. It carries
|
|
13
|
+
cycle connect-once's slice 1, 2a and 2b (founder 2026-10-29: one tag, after they merge).
|
|
14
|
+
|
|
15
|
+
- **One connect per computer.** A connection now covers every repository on the computer, and `connect` switches the hook
|
|
16
|
+
on in your own `~/.claude/settings.json`, which every project reads. A repository connected the 0.10.0 way keeps its own
|
|
17
|
+
connection (read first). A cloud session and `--team` still use the repository's `.claude/settings.json`.
|
|
18
|
+
- **The hook connects the computer itself.** A destructive command on a computer with no credential is refused with a link
|
|
19
|
+
and a short code (to Claude, and in a line to the person); once the workspace owner allows it, the next try finishes
|
|
20
|
+
connecting and asks for the approval in the same run. It never writes Claude Code's settings - in auto mode the assistant
|
|
21
|
+
may not, and a person put the hook there. Two hooks at once share one link. It connects only the Decidio your shell
|
|
22
|
+
names (or the default), never one a repository's `.env` names, and only over HTTPS; a project whose `.env` names an agent
|
|
23
|
+
with no token is refused by name rather than given the computer's connection. The run that connects tells the person which
|
|
24
|
+
workspace and who allowed it, even when the command then runs at once.
|
|
25
|
+
- **The computer-wide hook is the pinned `npx -y @decidio/sdk@0.11.0` command**, so it works in every project; a project
|
|
26
|
+
install still writes `$CLAUDE_PROJECT_DIR` unescaped, so Claude Code expands it. The settings file is written whole, and
|
|
27
|
+
through a symlink.
|
|
28
|
+
- **No surprises** (founder 2026-10-29: "the user is fully aware and not surprised"):
|
|
29
|
+
- `hook session`, a SessionStart hook: every session opens with one line - approvals are on here, what waits and for whom,
|
|
30
|
+
or that this computer isn't connected yet. Local only, never blocks;
|
|
31
|
+
- `connect` says what waits for approval and that everything else runs as before;
|
|
32
|
+
- a held command names who approves and where they see it;
|
|
33
|
+
- the plugin skill says what connecting does before doing it, and has the person run `! npx @decidio/sdk connect` when
|
|
34
|
+
auto mode refuses it.
|
|
35
|
+
- **The plugin** (0.5.0) carries the SessionStart hook, and a marketplace file ready to publish - not published (founder:
|
|
36
|
+
nothing public until the listing is ready).
|
|
37
|
+
- **The connector connects a computer** (slice 2a):
|
|
38
|
+
- `connect --ticket <ticket>` finishes the link the Decidio connector allowed (`connect_device` returns the ticket): the
|
|
39
|
+
poll carries this computer's own secret and the ticket, and only the credential is written;
|
|
40
|
+
- the hook names the connector's way first when a computer isn't connected (call `connect_device` with the code, run what
|
|
41
|
+
it returns), then the link and code; a link the connector allowed is kept while it waits for its ticket.
|
|
42
|
+
- **Pause and resume** (slice 2b): `hook prompt`, a UserPromptSubmit hook that `hook install`, `connect` and the plugin add
|
|
43
|
+
behind a shell pre-filter, acts on the person's own typed "decidio pause" / "decidio start" (exact words, `source: user`
|
|
44
|
+
only) and keeps the words from the model. While paused a gated command runs with a line and no decision - asked of Decidio only
|
|
45
|
+
in a session paused on this computer; the settings guard (now run over the whole line, inside `sh -c` too, for the pause and
|
|
46
|
+
the shared file's notice alike) and the self-decide refusal stay on; an agent running `hook prompt` itself is refused. The
|
|
47
|
+
session line says how to pause, or that the organisation keeps the guard on.
|
|
48
|
+
- **A shared file tells, then lets through** (slice 2a): `hook pretool --unconnected=tell`, which `connect` writes into a
|
|
49
|
+
shared file, refuses a computer with no credential once per session and command, then lets the same call pass with a line
|
|
50
|
+
to the person - never an `allow`, so Claude Code's own permission check still decides. No flag, a value it doesn't know,
|
|
51
|
+
or no session id holds, as before. `connect --team --hold-everyone` writes the holding hook instead.
|
|
52
|
+
|
|
53
|
+
On upgrade: nothing to do; 0.10.0 repository connections keep working. Proved by: cli-smoke.ts (378 -> 400; slice 1: one connect for
|
|
54
|
+
two repositories, the 0.10.0 file read first, the hook's own link - wait - collect-then-ask without touching settings, two
|
|
55
|
+
hooks sharing a link, a refused link, the approver in holds, the session line, SessionStart installed; slice 2a: the
|
|
56
|
+
connector's way, the ticket's usage, secret and ticket on the poll, tell once then a line, hold by default, --hold-everyone).
|
|
57
|
+
|
|
58
|
+
## 0.10.0 — connect a coding assistant in one tap (not published; folded into 0.11.0)
|
|
11
59
|
|
|
12
60
|
- **`npx @decidio/sdk connect`** (cycle connect). A coding assistant sets Decidio up for you: it runs `connect`, which prints a
|
|
13
61
|
link and a short code (on a real terminal also a QR, and it opens the browser); the workspace owner opens the link signed in,
|
|
@@ -16,8 +64,8 @@ allowlist. Each entry names what changed, what a reader must do on upgrade, and
|
|
|
16
64
|
ask-only credential - never a session - kept OUTSIDE the repository (the config dir, owner-only, keyed by the repository root
|
|
17
65
|
and the Decidio), and the hooks switch on in `.claude/settings.local.json` (`--team`: the shared `.claude/settings.json`).
|
|
18
66
|
No `.env`, nothing to paste, the word "agent" nowhere in the setup.
|
|
19
|
-
- **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads
|
|
20
|
-
|
|
67
|
+
- **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads the repository's files, not your
|
|
68
|
+
computer's, and only the committed shared file reaches the next cloud machine, so that is where they go. connect leaves it uncommitted and says to ask
|
|
21
69
|
the person before committing it (committed, every cloud session of the repository and every teammate's clone ask too). The
|
|
22
70
|
credential is that cloud machine's: a fresh one refuses destructive commands until it connects again.
|
|
23
71
|
- **The machine proves its key.** The start is signed with the machine's own key (the request-bound proof every signed
|
package/dist/cli.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export declare function defaultAgentId(dir?: string): string;
|
|
|
18
18
|
* distinct decision ids can never share a file (first-run review of 0.4.1: dots were STRIPPED, `a.b` and `ab`
|
|
19
19
|
* collided). `~` is outside the allowed set, so the escape is unambiguous. Exported for the smokes. */
|
|
20
20
|
export declare function receiptFileName(id: string): string;
|
|
21
|
+
/** Exactly what the person types to pause or resume the guard - nothing else is read. PURE. */
|
|
22
|
+
export declare function guardWords(prompt: unknown): "pause" | "start" | null;
|
|
21
23
|
/** Scoped packs (GitHub App design §2): a pack's scope in words - the organisation's, a unit's (its type, and its name when
|
|
22
24
|
* the server sent one: names that fail its person check are withheld there), or the reader's own. Null for an older server. */
|
|
23
25
|
export declare function packScopeLabel(scope: any): string | null;
|
package/dist/cli.js
CHANGED
|
@@ -76,9 +76,17 @@ 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
|
+
let CONNECTED_APPROVER = null;
|
|
82
|
+
let CONNECTED_UNANSWERED = false;
|
|
83
|
+
let CONNECTED_APP_URL = null;
|
|
84
|
+
let CONNECTED_WORKSPACE = null;
|
|
85
|
+
let GUARD_PAUSABLE = true;
|
|
86
|
+
let connectNotice = null;
|
|
79
87
|
let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
|
|
80
88
|
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 =
|
|
89
|
+
const c = activeConnection(projectRoot(), API);
|
|
82
90
|
if (c) {
|
|
83
91
|
promoteConnection(c);
|
|
84
92
|
CREDS_FROM = "connection";
|
|
@@ -2764,6 +2772,14 @@ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], de
|
|
|
2764
2772
|
}
|
|
2765
2773
|
return null;
|
|
2766
2774
|
}
|
|
2775
|
+
function hookEditsGuard(command, at) {
|
|
2776
|
+
try {
|
|
2777
|
+
return !!hookMatch(command, at, HOOK_RULES.filter((r) => r.category === "decidio-guard"));
|
|
2778
|
+
}
|
|
2779
|
+
catch {
|
|
2780
|
+
return true;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2767
2783
|
function hookSelfDecides(command, depth = 0) {
|
|
2768
2784
|
const hit = hookSegments(command).some((s) => {
|
|
2769
2785
|
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
@@ -3038,7 +3054,7 @@ function hookSubject(category, segs, cwdAbs, projectDir, at) {
|
|
|
3038
3054
|
}
|
|
3039
3055
|
return textOnly("text binds this category");
|
|
3040
3056
|
}
|
|
3041
|
-
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3057
|
+
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool|session|prompt)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3042
3058
|
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
3059
|
function hookOurHandlers(settings) {
|
|
3044
3060
|
const out = [];
|
|
@@ -3398,8 +3414,91 @@ async function hookHold(cfg, decisionId, holdMs) {
|
|
|
3398
3414
|
}
|
|
3399
3415
|
return { status, lastError };
|
|
3400
3416
|
}
|
|
3401
|
-
|
|
3402
|
-
|
|
3417
|
+
const ORGANISATION_KEEPS_ON = "Your organisation keeps Decidio on for every session.";
|
|
3418
|
+
const GUARD_PAUSE_HOURS = 12;
|
|
3419
|
+
export function guardWords(prompt) {
|
|
3420
|
+
const m = typeof prompt === "string" ? /^\s*decidio\s+(pause|start)\s*[.!]?\s*$/i.exec(prompt) : null;
|
|
3421
|
+
return m ? m[1].toLowerCase() : null;
|
|
3422
|
+
}
|
|
3423
|
+
const guardSessionKey = (material, session) => idempotencyKey(material, { purpose: "guard-pause", host: "claude-code", session });
|
|
3424
|
+
async function cmdHookPrompt() {
|
|
3425
|
+
const block = (reason) => { process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n"); return 0; };
|
|
3426
|
+
let action = null;
|
|
3427
|
+
try {
|
|
3428
|
+
const read = await readHookStdin();
|
|
3429
|
+
if (!read.ok)
|
|
3430
|
+
return 0;
|
|
3431
|
+
const input = read.input;
|
|
3432
|
+
action = guardWords(input.prompt);
|
|
3433
|
+
if (!action)
|
|
3434
|
+
return 0;
|
|
3435
|
+
const not = action === "pause" ? "the guard stays on" : "nothing changed";
|
|
3436
|
+
if (input.source !== "user")
|
|
3437
|
+
return block(`Decidio: "decidio ${action}" works only on a prompt you type yourself - ${not}.`);
|
|
3438
|
+
const session = typeof input.session_id === "string" ? input.session_id : "";
|
|
3439
|
+
if (!session)
|
|
3440
|
+
return block(`Decidio: this session has no id to pause the guard for - ${not}.`);
|
|
3441
|
+
const cfg = hookConfig();
|
|
3442
|
+
if ("missing" in cfg)
|
|
3443
|
+
return block(`Decidio isn't connected on this computer, so there is no guard to ${action === "pause" ? "pause" : "resume"} here.`);
|
|
3444
|
+
const material = bindingMaterial(cfg);
|
|
3445
|
+
if (!material)
|
|
3446
|
+
return block(`Decidio: this computer's credential can't key the pause - ${not}. Ask the person to run npx @decidio/sdk doctor.`);
|
|
3447
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
3448
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
|
|
3449
|
+
body: JSON.stringify({ session: guardSessionKey(material, session), action }), signal: AbortSignal.timeout(10_000) });
|
|
3450
|
+
const body = await r.json().catch(() => null);
|
|
3451
|
+
if (r.status === 403 && body?.code === "ORGANISATION_KEEPS_ON") {
|
|
3452
|
+
notePause(session, false);
|
|
3453
|
+
return block(ORGANISATION_KEEPS_ON);
|
|
3454
|
+
}
|
|
3455
|
+
if (r.ok && body?.state === "paused") {
|
|
3456
|
+
notePause(session, true);
|
|
3457
|
+
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
|
+
}
|
|
3459
|
+
if (r.ok && body?.state === "on") {
|
|
3460
|
+
notePause(session, false);
|
|
3461
|
+
return block(body.resumed ? "Decidio guard is back on for this session." : "Decidio guard is on for this session (it wasn't paused).");
|
|
3462
|
+
}
|
|
3463
|
+
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"}.`);
|
|
3464
|
+
}
|
|
3465
|
+
catch (e) {
|
|
3466
|
+
if (!action)
|
|
3467
|
+
return 0;
|
|
3468
|
+
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
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
function hookForgesPrompt(command, depth = 0) {
|
|
3472
|
+
const hit = hookSegments(command).some((s) => {
|
|
3473
|
+
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
3474
|
+
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);
|
|
3475
|
+
const runsCli = s.program === "decidio" || s.program === "decidio.js"
|
|
3476
|
+
|| ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
|
|
3477
|
+
const i = a.indexOf("hook");
|
|
3478
|
+
return runsCli && i >= 0 && a.slice(i + 1).some((x) => x.toLowerCase() === "prompt");
|
|
3479
|
+
});
|
|
3480
|
+
return hit || (depth < 2 && hookShellInner(command).some((c) => hookForgesPrompt(c, depth + 1)));
|
|
3481
|
+
}
|
|
3482
|
+
function cmdHookSession() {
|
|
3483
|
+
try {
|
|
3484
|
+
const cfg = hookConfig();
|
|
3485
|
+
const msg = "missing" in cfg
|
|
3486
|
+
? "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)."
|
|
3487
|
+
: `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. `
|
|
3488
|
+
+ (GUARD_PAUSABLE ? `Type "decidio pause" to pause it for this session, "decidio start" to resume.` : ORGANISATION_KEEPS_ON);
|
|
3489
|
+
process.stdout.write(JSON.stringify({ systemMessage: msg, hookSpecificOutput: { hookEventName: "SessionStart",
|
|
3490
|
+
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");
|
|
3491
|
+
}
|
|
3492
|
+
catch { }
|
|
3493
|
+
return 0;
|
|
3494
|
+
}
|
|
3495
|
+
async function cmdHookPretool(opts = { unconnected: "hold" }) {
|
|
3496
|
+
const out = (o) => {
|
|
3497
|
+
if (connectNotice)
|
|
3498
|
+
o = { ...o, systemMessage: o?.systemMessage ? `${connectNotice} ${o.systemMessage}` : connectNotice };
|
|
3499
|
+
process.stdout.write(JSON.stringify(o) + "\n");
|
|
3500
|
+
return 0;
|
|
3501
|
+
};
|
|
3403
3502
|
const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
|
|
3404
3503
|
const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
|
|
3405
3504
|
try {
|
|
@@ -3416,6 +3515,8 @@ async function cmdHookPretool() {
|
|
|
3416
3515
|
const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
|
|
3417
3516
|
if (!command.trim())
|
|
3418
3517
|
return 0;
|
|
3518
|
+
if (hookForgesPrompt(command))
|
|
3519
|
+
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
3520
|
if (hookSelfDecides(command))
|
|
3420
3521
|
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
3522
|
const segs = hookSegments(command);
|
|
@@ -3435,7 +3536,8 @@ async function cmdHookPretool() {
|
|
|
3435
3536
|
const canonical = command.replace(/\s+/g, " ").trim();
|
|
3436
3537
|
const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
|
|
3437
3538
|
const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
|
|
3438
|
-
|
|
3539
|
+
const offSwitch = rule.category === "decidio-guard" || hookEditsGuard(command, at);
|
|
3540
|
+
g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs, offSwitch,
|
|
3439
3541
|
...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
|
|
3440
3542
|
}
|
|
3441
3543
|
else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
|
|
@@ -3443,7 +3545,7 @@ async function cmdHookPretool() {
|
|
|
3443
3545
|
if (!fc)
|
|
3444
3546
|
return 0;
|
|
3445
3547
|
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 };
|
|
3548
|
+
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
3549
|
}
|
|
3448
3550
|
else
|
|
3449
3551
|
return 0;
|
|
@@ -3451,7 +3553,21 @@ async function cmdHookPretool() {
|
|
|
3451
3553
|
? { 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
3554
|
: { 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
3555
|
const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
|
|
3454
|
-
|
|
3556
|
+
let cfg = hookConfig();
|
|
3557
|
+
if ("missing" in cfg) {
|
|
3558
|
+
const got = await hookProvision();
|
|
3559
|
+
if (!got.ok) {
|
|
3560
|
+
if (opts.unconnected === "tell" && !g.offSwitch) {
|
|
3561
|
+
const told = toldOnce(typeof input.session_id === "string" ? input.session_id : "", g.binding);
|
|
3562
|
+
if (told === "passed")
|
|
3563
|
+
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.` });
|
|
3564
|
+
if (told === "noted")
|
|
3565
|
+
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);
|
|
3566
|
+
}
|
|
3567
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran}`, got.user);
|
|
3568
|
+
}
|
|
3569
|
+
cfg = hookConfig();
|
|
3570
|
+
}
|
|
3455
3571
|
if ("missing" in cfg)
|
|
3456
3572
|
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
3573
|
const rel = relative(projectDir, cwdAbs);
|
|
@@ -3464,6 +3580,13 @@ async function cmdHookPretool() {
|
|
|
3464
3580
|
catch (e) {
|
|
3465
3581
|
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
3582
|
}
|
|
3583
|
+
if (material && session && !g.offSwitch) {
|
|
3584
|
+
const paused = existsSync(pauseNote(session)) ? await guardPausedNow(cfg, guardSessionKey(material, session), g.category) : "on";
|
|
3585
|
+
if (paused === "on")
|
|
3586
|
+
notePause(session, false);
|
|
3587
|
+
if (paused === "paused")
|
|
3588
|
+
return out({ systemMessage: `Decidio guard is paused for this session: "${short}" ran without a person's approval (recorded). Type "decidio start" to resume.` });
|
|
3589
|
+
}
|
|
3467
3590
|
if (!material)
|
|
3468
3591
|
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
3592
|
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 +3631,7 @@ async function cmdHookPretool() {
|
|
|
3508
3631
|
if (auth.decision === "proceed") {
|
|
3509
3632
|
writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
|
|
3510
3633
|
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;
|
|
3634
|
+
return connectNotice ? out({}) : 0;
|
|
3512
3635
|
}
|
|
3513
3636
|
const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
|
|
3514
3637
|
const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
|
|
@@ -3516,7 +3639,7 @@ async function cmdHookPretool() {
|
|
|
3516
3639
|
if (!st)
|
|
3517
3640
|
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
3641
|
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}
|
|
3642
|
+
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
3643
|
if (st.state === "rejected" || st.state === "blocked")
|
|
3521
3644
|
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
3645
|
const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
|
|
@@ -3675,10 +3798,10 @@ function cmdHookRules() {
|
|
|
3675
3798
|
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
3799
|
return 0;
|
|
3677
3800
|
}
|
|
3678
|
-
function hookRunner() {
|
|
3801
|
+
function hookRunner(portable = false) {
|
|
3679
3802
|
const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3680
3803
|
const entry = process.argv[1] ?? "";
|
|
3681
|
-
if (!entry || /[\\/]_npx[\\/]/.test(entry))
|
|
3804
|
+
if (portable || !entry || /[\\/]_npx[\\/]/.test(entry))
|
|
3682
3805
|
return `npx -y @decidio/sdk@${VERSION}`;
|
|
3683
3806
|
let real = entry;
|
|
3684
3807
|
try {
|
|
@@ -3687,12 +3810,13 @@ function hookRunner() {
|
|
|
3687
3810
|
catch { }
|
|
3688
3811
|
const localRoot = resolvePath("node_modules") + pathSep;
|
|
3689
3812
|
if (real.startsWith(localRoot))
|
|
3690
|
-
|
|
3813
|
+
return `node "$CLAUDE_PROJECT_DIR/node_modules/${real.slice(localRoot.length).split(pathSep).join("/").replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3691
3814
|
real = real.split(pathSep).join("/");
|
|
3692
3815
|
if (/\.ts$/.test(real))
|
|
3693
3816
|
return `npx tsx ${q(real)}`;
|
|
3694
3817
|
return `node ${q(real)}`;
|
|
3695
3818
|
}
|
|
3819
|
+
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
3820
|
const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
|
|
3697
3821
|
const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
|
|
3698
3822
|
async function cmdHookInstall(opts) {
|
|
@@ -3716,14 +3840,14 @@ async function cmdHookInstall(opts) {
|
|
|
3716
3840
|
return 2;
|
|
3717
3841
|
}
|
|
3718
3842
|
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();
|
|
3843
|
+
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner(opts.portable === true);
|
|
3720
3844
|
const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
|
|
3721
3845
|
const place = (event, matcher, iff, command, extra) => {
|
|
3722
3846
|
const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
|
|
3723
3847
|
const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
|
|
3724
3848
|
let state = "added";
|
|
3725
3849
|
for (const g of list) {
|
|
3726
|
-
if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
|
|
3850
|
+
if (!g || typeof g !== "object" || (g.matcher ?? "") !== matcher || !Array.isArray(g.hooks))
|
|
3727
3851
|
continue;
|
|
3728
3852
|
g.hooks = g.hooks.flatMap((h) => {
|
|
3729
3853
|
if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
|
|
@@ -3738,12 +3862,14 @@ async function cmdHookInstall(opts) {
|
|
|
3738
3862
|
}
|
|
3739
3863
|
hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
3740
3864
|
if (state === "added")
|
|
3741
|
-
hooks[event].push({ matcher, hooks: [want] });
|
|
3865
|
+
hooks[event].push(matcher ? { matcher, hooks: [want] } : { hooks: [want] });
|
|
3742
3866
|
return state;
|
|
3743
3867
|
};
|
|
3744
|
-
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3868
|
+
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool${opts.unconnected === "tell" ? " --unconnected=tell" : ""}`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3745
3869
|
const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
|
|
3746
3870
|
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 });
|
|
3747
3873
|
const guard = { added: 0, updated: 0, kept: 0 };
|
|
3748
3874
|
for (const t of HOOK_FILE_TOOLS)
|
|
3749
3875
|
for (const p of HOOK_GUARD_PATHS) {
|
|
@@ -3751,7 +3877,9 @@ async function cmdHookInstall(opts) {
|
|
|
3751
3877
|
guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
|
|
3752
3878
|
}
|
|
3753
3879
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
3754
|
-
|
|
3880
|
+
const target = realOrSame(settingsPath), tmpSettings = `${target}.decidio-${process.pid}.tmp`;
|
|
3881
|
+
writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + "\n", { mode: existsSync(target) ? statSync(target).mode & 0o777 : 0o644 });
|
|
3882
|
+
renameSync(tmpSettings, target);
|
|
3755
3883
|
lastHookInstall = { settingsPath, pre, post };
|
|
3756
3884
|
if (opts.quiet)
|
|
3757
3885
|
return 0;
|
|
@@ -3795,7 +3923,7 @@ function projectRoot() {
|
|
|
3795
3923
|
catch { }
|
|
3796
3924
|
return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
|
|
3797
3925
|
}
|
|
3798
|
-
function connectionKey(root) { return createHash("sha256").update(`${API}\n${process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
|
|
3926
|
+
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
3927
|
function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
|
|
3800
3928
|
function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
|
|
3801
3929
|
function readJsonQuiet(f) {
|
|
@@ -3806,6 +3934,8 @@ function readJsonQuiet(f) {
|
|
|
3806
3934
|
return null;
|
|
3807
3935
|
}
|
|
3808
3936
|
}
|
|
3937
|
+
function activeConnection(root, api) { return loadConnection(root, api) ?? loadConnection(COMPUTER, api); }
|
|
3938
|
+
const connectScope = (root) => (connectionRecord(root, API) ? root : COMPUTER);
|
|
3809
3939
|
function loadConnection(root, api) {
|
|
3810
3940
|
const c = connectionRecord(root, api);
|
|
3811
3941
|
return c && !c.disconnected && c.token ? c : null;
|
|
@@ -3814,7 +3944,15 @@ function connectionRecord(root, api) {
|
|
|
3814
3944
|
const c = readJsonQuiet(connectionFile(root));
|
|
3815
3945
|
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
3946
|
}
|
|
3947
|
+
function printableName(v) {
|
|
3948
|
+
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;
|
|
3949
|
+
}
|
|
3817
3950
|
function promoteConnection(c) {
|
|
3951
|
+
CONNECTED_APPROVER = printableName(c.approver);
|
|
3952
|
+
CONNECTED_UNANSWERED = c.answered === false;
|
|
3953
|
+
CONNECTED_APP_URL = typeof c.appUrl === "string" && /^https?:\/\/[^\s"<>]+$/.test(c.appUrl) ? c.appUrl.replace(/\/+$/, "") : null;
|
|
3954
|
+
CONNECTED_WORKSPACE = printableName(c.workspaceName);
|
|
3955
|
+
GUARD_PAUSABLE = c.guardPausable !== false;
|
|
3818
3956
|
process.env.DECIDIO_API_TOKEN = c.token;
|
|
3819
3957
|
process.env.DECIDIO_AGENT_ID = c.agentId;
|
|
3820
3958
|
process.env.DECIDIO_AGENT_DID = c.did;
|
|
@@ -3889,29 +4027,24 @@ function openInBrowser(url) {
|
|
|
3889
4027
|
}
|
|
3890
4028
|
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
4029
|
let lastHookInstall = null;
|
|
3892
|
-
|
|
4030
|
+
let connectError = "";
|
|
4031
|
+
async function startConnect(root, host, quiet = false) {
|
|
4032
|
+
const fail = (m) => { connectError = m; if (!quiet)
|
|
4033
|
+
err(`✘ ${m}`); return null; };
|
|
3893
4034
|
const prior = connectionRecord(root, API);
|
|
3894
4035
|
const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
|
|
3895
4036
|
const cloud = inCloudSession();
|
|
3896
|
-
const project = projectLabel(root).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
|
|
4037
|
+
const project = (root === COMPUTER ? EVERY_PROJECT : projectLabel(root)).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
|
|
3897
4038
|
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
4039
|
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
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
if (r.status ===
|
|
3904
|
-
|
|
3905
|
-
|
|
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
|
-
}
|
|
4040
|
+
if (r.status === 0)
|
|
4041
|
+
return fail(`couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
|
|
4042
|
+
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})`);
|
|
4044
|
+
if (r.status === 429)
|
|
4045
|
+
return fail(`${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
|
|
4046
|
+
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.`);
|
|
3915
4048
|
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
4049
|
pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
|
|
3917
4050
|
mkdirSync(connectDir(), { recursive: true });
|
|
@@ -3925,6 +4058,170 @@ function readPendingConnect(root) {
|
|
|
3925
4058
|
const p = readJsonQuiet(pendingConnectFile(root));
|
|
3926
4059
|
return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
|
|
3927
4060
|
}
|
|
4061
|
+
async function hookProvision() {
|
|
4062
|
+
const scope = COMPUTER;
|
|
4063
|
+
if (process.env.DECIDIO_AGENT_ID) {
|
|
4064
|
+
const named = printableName(process.env.DECIDIO_AGENT_ID) ?? "(unprintable)";
|
|
4065
|
+
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` };
|
|
4066
|
+
}
|
|
4067
|
+
const own = (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
4068
|
+
if (API !== own) {
|
|
4069
|
+
const host = printableName(redactUrl(API)) ?? "(unprintable)";
|
|
4070
|
+
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)` };
|
|
4071
|
+
}
|
|
4072
|
+
try {
|
|
4073
|
+
assertSafeTransport(API, true);
|
|
4074
|
+
}
|
|
4075
|
+
catch (e) {
|
|
4076
|
+
return { ok: false, reason: `${String(e?.message ?? e).replace(/^\[decidio\] /, "")} - the connection's secret would cross in clear.`, user: "Decidio: refusing a plaintext connection" };
|
|
4077
|
+
}
|
|
4078
|
+
const linkWords = (p, fresh) => {
|
|
4079
|
+
const mins = minutesLeft(p.expiresAt) || 1;
|
|
4080
|
+
return { ok: false,
|
|
4081
|
+
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
|
+
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
|
+
};
|
|
4084
|
+
let p = readPendingConnect(scope);
|
|
4085
|
+
if (p && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
|
|
4086
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
|
|
4087
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4088
|
+
const c = storeCollected(scope, p, r.body);
|
|
4089
|
+
if (typeof c === "string")
|
|
4090
|
+
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" };
|
|
4091
|
+
promoteConnection(c);
|
|
4092
|
+
CREDS_FROM = "connection";
|
|
4093
|
+
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.`;
|
|
4094
|
+
return { ok: true };
|
|
4095
|
+
}
|
|
4096
|
+
if (r.status === 202 || r.status === 429 || r.status >= 500)
|
|
4097
|
+
return linkWords(p, false);
|
|
4098
|
+
if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch"))
|
|
4099
|
+
return { ok: false,
|
|
4100
|
+
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
|
+
user: "Decidio: finishing this computer's connection (allowed through the Decidio connector) - destructive commands wait until it's done" };
|
|
4102
|
+
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" };
|
|
4104
|
+
if (r.status === 410 && r.body?.status === "collected") {
|
|
4105
|
+
const won = activeConnection(projectRoot(), API);
|
|
4106
|
+
if (won) {
|
|
4107
|
+
promoteConnection(won);
|
|
4108
|
+
CREDS_FROM = "connection";
|
|
4109
|
+
return { ok: true };
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4113
|
+
dropFile(pendingConnectFile(scope));
|
|
4114
|
+
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" };
|
|
4115
|
+
}
|
|
4116
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4117
|
+
dropFile(pendingConnectFile(scope));
|
|
4118
|
+
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" };
|
|
4119
|
+
}
|
|
4120
|
+
dropFile(pendingConnectFile(scope));
|
|
4121
|
+
}
|
|
4122
|
+
const lock = pendingConnectFile(scope) + ".lock";
|
|
4123
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4124
|
+
let fd = null;
|
|
4125
|
+
try {
|
|
4126
|
+
fd = openSync(lock, "wx", 0o600);
|
|
4127
|
+
}
|
|
4128
|
+
catch {
|
|
4129
|
+
try {
|
|
4130
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000) {
|
|
4131
|
+
rmSync(lock, { force: true });
|
|
4132
|
+
fd = openSync(lock, "wx", 0o600);
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
catch {
|
|
4136
|
+
fd = null;
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
if (fd !== null && (p = readPendingConnect(scope)) && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
|
|
4140
|
+
try {
|
|
4141
|
+
closeSync(fd);
|
|
4142
|
+
}
|
|
4143
|
+
catch { }
|
|
4144
|
+
dropFile(lock);
|
|
4145
|
+
return linkWords(p, false);
|
|
4146
|
+
}
|
|
4147
|
+
if (fd === null) {
|
|
4148
|
+
for (let i = 0; i < 20 && !(p = readPendingConnect(scope)); i++)
|
|
4149
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
4150
|
+
if (p)
|
|
4151
|
+
return linkWords(p, false);
|
|
4152
|
+
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
|
+
}
|
|
4154
|
+
try {
|
|
4155
|
+
const started = await startConnect(scope, "claude-code", true);
|
|
4156
|
+
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" };
|
|
4158
|
+
return linkWords(started, true);
|
|
4159
|
+
}
|
|
4160
|
+
finally {
|
|
4161
|
+
try {
|
|
4162
|
+
closeSync(fd);
|
|
4163
|
+
}
|
|
4164
|
+
catch { }
|
|
4165
|
+
dropFile(lock);
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
function toldOnce(session, binding) {
|
|
4169
|
+
if (!session)
|
|
4170
|
+
return "hold";
|
|
4171
|
+
const dir = joinPath(connectDir(), "told");
|
|
4172
|
+
const f = joinPath(dir, createHash("sha256").update(`${API}\n${session}\n${binding}`).digest("hex").slice(0, 40));
|
|
4173
|
+
try {
|
|
4174
|
+
if (existsSync(f))
|
|
4175
|
+
return "passed";
|
|
4176
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4177
|
+
const week = Date.now() - 7 * 86_400_000;
|
|
4178
|
+
for (const old of readdirSync(dir)) {
|
|
4179
|
+
try {
|
|
4180
|
+
if (statSync(joinPath(dir, old)).mtimeMs < week)
|
|
4181
|
+
rmSync(joinPath(dir, old), { force: true });
|
|
4182
|
+
}
|
|
4183
|
+
catch { }
|
|
4184
|
+
}
|
|
4185
|
+
writeFileSync(f, "", { mode: 0o600, flag: "wx" });
|
|
4186
|
+
return "noted";
|
|
4187
|
+
}
|
|
4188
|
+
catch {
|
|
4189
|
+
return "hold";
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
async function guardPausedNow(cfg, sessionKey, category) {
|
|
4193
|
+
try {
|
|
4194
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
4195
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard/check", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
|
|
4196
|
+
body: JSON.stringify({ session: sessionKey, category }), signal: AbortSignal.timeout(10_000) });
|
|
4197
|
+
const body = await r.json().catch(() => null);
|
|
4198
|
+
return r.ok && body?.paused === true ? "paused" : r.ok && body?.paused === false ? "on" : "unknown";
|
|
4199
|
+
}
|
|
4200
|
+
catch {
|
|
4201
|
+
return "unknown";
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
function pauseNote(session) {
|
|
4205
|
+
return joinPath(connectDir(), "paused", createHash("sha256").update(`${API}\n${session}`).digest("hex").slice(0, 40));
|
|
4206
|
+
}
|
|
4207
|
+
function notePause(session, paused) {
|
|
4208
|
+
const f = pauseNote(session);
|
|
4209
|
+
try {
|
|
4210
|
+
if (!paused)
|
|
4211
|
+
return dropFile(f);
|
|
4212
|
+
const dir = dirname(f);
|
|
4213
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4214
|
+
for (const old of readdirSync(dir)) {
|
|
4215
|
+
try {
|
|
4216
|
+
if (statSync(joinPath(dir, old)).mtimeMs < Date.now() - GUARD_PAUSE_HOURS * 3_600_000)
|
|
4217
|
+
rmSync(joinPath(dir, old), { force: true });
|
|
4218
|
+
}
|
|
4219
|
+
catch { }
|
|
4220
|
+
}
|
|
4221
|
+
writeFileSync(f, "", { mode: 0o600 });
|
|
4222
|
+
}
|
|
4223
|
+
catch { }
|
|
4224
|
+
}
|
|
3928
4225
|
function dropFile(f) { try {
|
|
3929
4226
|
rmSync(f, { force: true });
|
|
3930
4227
|
}
|
|
@@ -3950,6 +4247,14 @@ async function cmdConnect(opts) {
|
|
|
3950
4247
|
err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
|
|
3951
4248
|
return 2;
|
|
3952
4249
|
}
|
|
4250
|
+
if (opts.ticket !== undefined && (opts.wait || opts.team || opts.force || opts.holdEveryone)) {
|
|
4251
|
+
err("✘ --ticket finishes a link the Decidio connector allowed and takes no other option.");
|
|
4252
|
+
return 2;
|
|
4253
|
+
}
|
|
4254
|
+
if (opts.holdEveryone && !opts.team && !inCloudSession()) {
|
|
4255
|
+
err("✘ --hold-everyone is for the shared file (--team, or a cloud session): it keeps refusing a computer that isn't connected.");
|
|
4256
|
+
return 2;
|
|
4257
|
+
}
|
|
3953
4258
|
try {
|
|
3954
4259
|
assertSafeTransport(API, true);
|
|
3955
4260
|
}
|
|
@@ -3958,37 +4263,122 @@ async function cmdConnect(opts) {
|
|
|
3958
4263
|
return 1;
|
|
3959
4264
|
}
|
|
3960
4265
|
const root = projectRoot();
|
|
4266
|
+
if (opts.ticket !== undefined)
|
|
4267
|
+
return connectWithTicket(root, opts.ticket);
|
|
3961
4268
|
const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
3962
4269
|
if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
|
|
3963
4270
|
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
4271
|
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
4272
|
return finishConnect(root, null, opts, false);
|
|
3966
4273
|
}
|
|
3967
|
-
const existing =
|
|
4274
|
+
const existing = activeConnection(root, API);
|
|
3968
4275
|
if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
|
|
3969
4276
|
return finishConnect(root, existing, opts, false);
|
|
4277
|
+
const scope = connectScope(root);
|
|
3970
4278
|
if (opts.wait)
|
|
3971
|
-
return waitConnect(root, opts, false);
|
|
3972
|
-
let p = readPendingConnect(
|
|
4279
|
+
return waitConnect(root, scope, opts, false);
|
|
4280
|
+
let p = readPendingConnect(scope);
|
|
3973
4281
|
if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
|
|
3974
|
-
p = await startConnect(
|
|
4282
|
+
p = await startConnect(scope, host);
|
|
3975
4283
|
if (!p)
|
|
3976
4284
|
return 1;
|
|
3977
4285
|
const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
|
|
3978
4286
|
showConnectLink(p, opened, tty);
|
|
3979
4287
|
if (tty) {
|
|
3980
4288
|
log("");
|
|
3981
|
-
return waitConnect(root, opts, true);
|
|
4289
|
+
return waitConnect(root, scope, opts, true);
|
|
3982
4290
|
}
|
|
3983
4291
|
log("");
|
|
3984
4292
|
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
4293
|
log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
|
|
3986
4294
|
return 0;
|
|
3987
4295
|
}
|
|
3988
|
-
async function
|
|
3989
|
-
|
|
4296
|
+
async function connectWithTicket(root, ticket) {
|
|
4297
|
+
if (!/^[A-Za-z0-9_-]{32,128}$/.test(ticket)) {
|
|
4298
|
+
err("✘ --ticket needs the ticket connect_device returned (letters, digits, - and _).");
|
|
4299
|
+
return 2;
|
|
4300
|
+
}
|
|
4301
|
+
const scope = connectScope(root);
|
|
4302
|
+
const p = readPendingConnect(scope);
|
|
4303
|
+
if (!p || new Date(p.expiresAt).getTime() <= Date.now()) {
|
|
4304
|
+
const c = activeConnection(root, API);
|
|
4305
|
+
if (c) {
|
|
4306
|
+
log(`✔ This computer is already connected to Decidio${printableName(c.workspaceName) ? ` - workspace ${printableName(c.workspaceName)}` : ""}. Retry the command.`);
|
|
4307
|
+
return 0;
|
|
4308
|
+
}
|
|
4309
|
+
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.");
|
|
4310
|
+
return 1;
|
|
4311
|
+
}
|
|
4312
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret, "x-decidio-connect-ticket": ticket }, body: "{}" }, "", true);
|
|
4313
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4314
|
+
const c = storeCollected(scope, p, r.body);
|
|
4315
|
+
if (typeof c === "string") {
|
|
4316
|
+
err(`✘ connected, but this computer's credential couldn't be written (${c}) - run npx @decidio/sdk doctor.`);
|
|
4317
|
+
return 1;
|
|
4318
|
+
}
|
|
4319
|
+
const ws = printableName(c.workspaceName), approver = printableName(c.approver);
|
|
4320
|
+
log(`✔ Connected this computer to Decidio${ws ? ` - workspace ${ws}` : ""}, through the Decidio connector.`);
|
|
4321
|
+
log(`✔ Retry the command Decidio refused: it now asks ${approver ?? "the approver"} before it runs.`);
|
|
4322
|
+
if (r.body.trust === "unconfirmed")
|
|
4323
|
+
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."));
|
|
4324
|
+
else if (r.body.trust === "cloud")
|
|
4325
|
+
log(dim(" This cloud machine always asks a person for its destructive commands."));
|
|
4326
|
+
return 0;
|
|
4327
|
+
}
|
|
4328
|
+
if (r.status === 202) {
|
|
4329
|
+
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.`);
|
|
4330
|
+
return 3;
|
|
4331
|
+
}
|
|
4332
|
+
if (r.status === 409 && r.body?.status === "ticket_mismatch") {
|
|
4333
|
+
err(`✘ that ticket is for another link - call connect_device with this computer's code (${p.code}) and use the ticket it returns.`);
|
|
4334
|
+
return 3;
|
|
4335
|
+
}
|
|
4336
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4337
|
+
dropFile(pendingConnectFile(scope));
|
|
4338
|
+
err("✘ this computer's link was refused on the web (Not me). Nothing was saved.");
|
|
4339
|
+
return 3;
|
|
4340
|
+
}
|
|
4341
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4342
|
+
dropFile(pendingConnectFile(scope));
|
|
4343
|
+
err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved - ask them before trying again.");
|
|
4344
|
+
return 3;
|
|
4345
|
+
}
|
|
4346
|
+
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4347
|
+
const won = r.body.status === "collected" ? activeConnection(root, API) : null;
|
|
4348
|
+
if (won) {
|
|
4349
|
+
log("✔ This computer is connected (another run finished it first). Retry the command.");
|
|
4350
|
+
return 0;
|
|
4351
|
+
}
|
|
4352
|
+
dropFile(pendingConnectFile(scope));
|
|
4353
|
+
err("✘ this computer's link expired before it was finished - retry the command Decidio refused for a fresh code, then call connect_device again.");
|
|
4354
|
+
return 3;
|
|
4355
|
+
}
|
|
4356
|
+
if (r.status === 0) {
|
|
4357
|
+
err(`✘ couldn't reach Decidio at ${API} - check the connection and run this again (the ticket stays good for ten minutes).`);
|
|
4358
|
+
return 1;
|
|
4359
|
+
}
|
|
4360
|
+
err(`✘ Decidio answered ${r.status}${r.body?.message ? `: ${String(r.body.message).slice(0, 200)}` : ""}`);
|
|
4361
|
+
return 1;
|
|
4362
|
+
}
|
|
4363
|
+
function storeCollected(scope, p, body) {
|
|
4364
|
+
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
|
+
token: body.token, jti: String(body.jti), expiresAt: String(body.expiresAt), workspaceId: String(body.workspaceId), workspaceName: body.workspace?.name ?? null,
|
|
4366
|
+
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 } : {}) };
|
|
4368
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4369
|
+
try {
|
|
4370
|
+
writeSecretFile(connectionFile(scope), JSON.stringify(c) + "\n");
|
|
4371
|
+
}
|
|
4372
|
+
catch (e) {
|
|
4373
|
+
return String(e?.message ?? e);
|
|
4374
|
+
}
|
|
4375
|
+
dropFile(pendingConnectFile(scope));
|
|
4376
|
+
return c;
|
|
4377
|
+
}
|
|
4378
|
+
async function waitConnect(root, scope, opts, interactive) {
|
|
4379
|
+
const p = readPendingConnect(scope);
|
|
3990
4380
|
if (!p) {
|
|
3991
|
-
const c =
|
|
4381
|
+
const c = activeConnection(root, API);
|
|
3992
4382
|
if (c)
|
|
3993
4383
|
return finishConnect(root, c, opts, false);
|
|
3994
4384
|
err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
|
|
@@ -4004,29 +4394,41 @@ async function waitConnect(root, opts, interactive) {
|
|
|
4004
4394
|
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4005
4395
|
if (interactive && TTY_OUT)
|
|
4006
4396
|
process.stderr.write("\r\x1b[K");
|
|
4007
|
-
const c =
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
mkdirSync(connectDir(), { recursive: true });
|
|
4011
|
-
try {
|
|
4012
|
-
writeSecretFile(connectionFile(root), JSON.stringify(c) + "\n");
|
|
4013
|
-
}
|
|
4014
|
-
catch (e) {
|
|
4015
|
-
err(`✘ connected, but this machine's credentials could not be written (${e?.message ?? e}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
|
|
4397
|
+
const c = storeCollected(scope, p, r.body);
|
|
4398
|
+
if (typeof c === "string") {
|
|
4399
|
+
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
4400
|
return 1;
|
|
4017
4401
|
}
|
|
4018
|
-
dropFile(pendingConnectFile(root));
|
|
4019
4402
|
return finishConnect(root, c, opts, true);
|
|
4020
4403
|
}
|
|
4021
4404
|
if (r.status === 403 && r.body?.status === "denied") {
|
|
4022
|
-
dropFile(pendingConnectFile(
|
|
4405
|
+
dropFile(pendingConnectFile(scope));
|
|
4023
4406
|
if (interactive && TTY_OUT)
|
|
4024
4407
|
process.stderr.write("\r\x1b[K");
|
|
4025
4408
|
err("✘ the connection was refused on the web (Not me). Nothing was saved.");
|
|
4026
4409
|
return 3;
|
|
4027
4410
|
}
|
|
4411
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4412
|
+
dropFile(pendingConnectFile(scope));
|
|
4413
|
+
if (interactive && TTY_OUT)
|
|
4414
|
+
process.stderr.write("\r\x1b[K");
|
|
4415
|
+
err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved.");
|
|
4416
|
+
return 3;
|
|
4417
|
+
}
|
|
4418
|
+
if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch")) {
|
|
4419
|
+
if (interactive && TTY_OUT)
|
|
4420
|
+
process.stderr.write("\r\x1b[K");
|
|
4421
|
+
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.");
|
|
4422
|
+
return 3;
|
|
4423
|
+
}
|
|
4028
4424
|
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4029
|
-
|
|
4425
|
+
const won = r.body.status === "collected" ? activeConnection(root, API) : null;
|
|
4426
|
+
if (won) {
|
|
4427
|
+
if (interactive && TTY_OUT)
|
|
4428
|
+
process.stderr.write("\r\x1b[K");
|
|
4429
|
+
return finishConnect(root, won, opts, true);
|
|
4430
|
+
}
|
|
4431
|
+
dropFile(pendingConnectFile(scope));
|
|
4030
4432
|
if (interactive && TTY_OUT)
|
|
4031
4433
|
process.stderr.write("\r\x1b[K");
|
|
4032
4434
|
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 +4454,26 @@ async function finishConnect(root, c, opts, fresh) {
|
|
|
4052
4454
|
}
|
|
4053
4455
|
const cloud = inCloudSession();
|
|
4054
4456
|
const shared = opts.team || cloud;
|
|
4457
|
+
const everywhere = !shared && c?.root === COMPUTER;
|
|
4458
|
+
const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
4055
4459
|
const plugin = !cloud && decidioPluginEnabled(root);
|
|
4056
4460
|
if (!plugin) {
|
|
4057
|
-
const rc = await cmdHookInstall({ project: shared, dir: root, quiet: true });
|
|
4461
|
+
const rc = await cmdHookInstall({ project: shared || everywhere, dir: everywhere ? home : root, quiet: true, portable: everywhere, ...(shared && !opts.holdEveryone ? { unconnected: "tell" } : {}) });
|
|
4058
4462
|
if (rc !== 0)
|
|
4059
4463
|
return rc;
|
|
4060
4464
|
}
|
|
4061
4465
|
const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
|
|
4062
4466
|
const verified = probe.status === 404;
|
|
4063
|
-
const settings = lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
|
|
4467
|
+
const settings = everywhere ? "~/.claude/settings.json" : lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
|
|
4064
4468
|
const hostName = connectHostLabel(c?.host ?? "claude-code");
|
|
4469
|
+
const approverName = printableName(c?.approver);
|
|
4065
4470
|
if (c) {
|
|
4066
|
-
|
|
4067
|
-
log(`✔
|
|
4471
|
+
const wsName = printableName(c.workspaceName), allowedBy = printableName(c.approvedBy);
|
|
4472
|
+
log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${wsName ? ` - workspace ${wsName}` : ""}${fresh && allowedBy ? `, allowed by ${allowedBy}` : ""}`);
|
|
4473
|
+
if (c.answered === false)
|
|
4474
|
+
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.`);
|
|
4475
|
+
else
|
|
4476
|
+
log(`✔ Approvals go to ${approverName ?? "its approver"} - on the phone app, Slack, Teams or the web`);
|
|
4068
4477
|
}
|
|
4069
4478
|
if (!verified) {
|
|
4070
4479
|
err(probe.status === 401 || probe.status === 403
|
|
@@ -4076,19 +4485,24 @@ async function finishConnect(root, c, opts, fresh) {
|
|
|
4076
4485
|
const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
|
|
4077
4486
|
log(plugin
|
|
4078
4487
|
? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
|
|
4079
|
-
: `✔ ${hostName} asks before destructive commands in this project (${settings})`);
|
|
4488
|
+
: `✔ ${hostName} asks before destructive commands in ${everywhere ? "every project on this computer" : "this project"} (${settings})`);
|
|
4489
|
+
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
4490
|
if (changed)
|
|
4081
4491
|
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
4492
|
if (c)
|
|
4083
|
-
log(dim(` Credentials: ${connectionFile(root)} (outside the repository, owner-only) · until ${c.expiresAt.slice(0, 10)}${cloud ?
|
|
4084
|
-
if (!plugin && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
|
|
4493
|
+
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" : ""}` : ""}`));
|
|
4494
|
+
if (!plugin && !everywhere && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
|
|
4085
4495
|
log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
|
|
4086
4496
|
if (cloud && !opts.team)
|
|
4087
|
-
log(`• A cloud session reads
|
|
4497
|
+
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
4498
|
else if (shared)
|
|
4089
4499
|
log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
|
|
4090
|
-
if (
|
|
4091
|
-
log(dim(
|
|
4500
|
+
if (shared && !plugin)
|
|
4501
|
+
log(dim(opts.holdEveryone
|
|
4502
|
+
? " 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"));
|
|
4504
|
+
if (fresh && c?.answered !== false)
|
|
4505
|
+
log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${approverName ?? "the approver"} on their phone.`));
|
|
4092
4506
|
return 0;
|
|
4093
4507
|
}
|
|
4094
4508
|
const DECIDIO_PLUGIN = "decidio-approvals";
|
|
@@ -4106,21 +4520,23 @@ function decidioPluginEnabled(root) {
|
|
|
4106
4520
|
}
|
|
4107
4521
|
async function cmdDisconnect() {
|
|
4108
4522
|
const root = projectRoot();
|
|
4109
|
-
const
|
|
4110
|
-
|
|
4523
|
+
const scope = connectScope(root);
|
|
4524
|
+
const c = loadConnection(scope, API);
|
|
4525
|
+
dropFile(pendingConnectFile(scope));
|
|
4526
|
+
const where = scope === COMPUTER ? "this computer" : "this project on this machine";
|
|
4111
4527
|
if (!c) {
|
|
4112
|
-
log(
|
|
4528
|
+
log(`• ${where} isn't connected - nothing to do.`);
|
|
4113
4529
|
return 0;
|
|
4114
4530
|
}
|
|
4115
4531
|
try {
|
|
4116
|
-
writeSecretFile(connectionFile(
|
|
4532
|
+
writeSecretFile(connectionFile(scope), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
|
|
4117
4533
|
}
|
|
4118
4534
|
catch (e) {
|
|
4119
|
-
err(`✘ couldn't update ${connectionFile(
|
|
4535
|
+
err(`✘ couldn't update ${connectionFile(scope)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
|
|
4120
4536
|
return 1;
|
|
4121
4537
|
}
|
|
4122
|
-
log(`✔ disconnected
|
|
4123
|
-
log(` Destructive commands are now refused until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
|
|
4538
|
+
log(`✔ disconnected ${where} - its token is deleted.`);
|
|
4539
|
+
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
4540
|
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
4541
|
return 0;
|
|
4126
4542
|
}
|
|
@@ -5403,17 +5819,21 @@ APPROVALS AND RECEIPTS
|
|
|
5403
5819
|
(never the receipt's own issuer field; --allow-unpinned: structural check only)
|
|
5404
5820
|
|
|
5405
5821
|
CODING AGENTS (Claude Code)
|
|
5406
|
-
npx @decidio/sdk connect connect this
|
|
5407
|
-
code (a QR and the browser too, on a real terminal); the workspace
|
|
5408
|
-
it on the web, then this
|
|
5409
|
-
repository) and the hooks below switch on
|
|
5822
|
+
npx @decidio/sdk connect connect this computer's coding assistant once, for every project on it: prints a
|
|
5823
|
+
link and a short code (a QR and the browser too, on a real terminal); the workspace
|
|
5824
|
+
owner allows it on the web, then this computer gets an ask-only credential (kept
|
|
5825
|
+
outside every repository) and the hooks below switch on in ~/.claude/settings.json.
|
|
5410
5826
|
npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
|
|
5411
5827
|
(exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
|
|
5412
|
-
--team: the shared .claude/settings.json every clone reads (
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5828
|
+
--team: the shared .claude/settings.json every clone reads (a cloud session reads
|
|
5829
|
+
the repository's files, not a computer's, so there it is always the shared one); a
|
|
5830
|
+
computer that isn't connected is told once, then let through - --hold-everyone keeps
|
|
5831
|
+
refusing it · --no-browser · --force: a fresh token for this computer
|
|
5832
|
+
npx @decidio/sdk connect --ticket <t>
|
|
5833
|
+
finish the link the Decidio connector allowed (its connect_device tool returns the
|
|
5834
|
+
ticket): writes this computer's credential, nothing else
|
|
5835
|
+
npx @decidio/sdk disconnect delete this computer's connection (destructive commands are then refused until
|
|
5836
|
+
you connect again; the hooks stay on)
|
|
5417
5837
|
npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
|
|
5418
5838
|
a person approves Claude Code's destructive shell commands before they run
|
|
5419
5839
|
(rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
|
|
@@ -5434,6 +5854,11 @@ CODING AGENTS (Claude Code)
|
|
|
5434
5854
|
(90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
|
|
5435
5855
|
retried after approval runs once - its execution is recorded and that
|
|
5436
5856
|
approval is spent. No credentials, no gate, no answer = refused (fail closed).
|
|
5857
|
+
--unconnected=tell (written into a shared file by connect): a computer with no
|
|
5858
|
+
credential is refused once per session and command, then let through with a line.
|
|
5859
|
+
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.
|
|
5437
5862
|
|
|
5438
5863
|
CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
|
|
5439
5864
|
npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
|
|
@@ -5661,7 +6086,7 @@ async function main(argv) {
|
|
|
5661
6086
|
logout: { flags: [] },
|
|
5662
6087
|
phone: { flags: [] },
|
|
5663
6088
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
5664
|
-
connect: { flags: ["--wait", "--no-browser", "--team", "--force"], valued: ["--host"] },
|
|
6089
|
+
connect: { flags: ["--wait", "--no-browser", "--team", "--force", "--hold-everyone"], valued: ["--host", "--ticket"] },
|
|
5665
6090
|
disconnect: { flags: [] },
|
|
5666
6091
|
doctor: { flags: [] },
|
|
5667
6092
|
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
@@ -5669,7 +6094,7 @@ async function main(argv) {
|
|
|
5669
6094
|
verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
|
|
5670
6095
|
tokens: { flags: ["--mine"] },
|
|
5671
6096
|
dev: { flags: [], valued: ["--target", "--port"] },
|
|
5672
|
-
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
|
|
6097
|
+
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected"] },
|
|
5673
6098
|
github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
|
|
5674
6099
|
deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
|
|
5675
6100
|
controls: { flags: ["--json"] },
|
|
@@ -5870,13 +6295,14 @@ async function main(argv) {
|
|
|
5870
6295
|
}
|
|
5871
6296
|
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
6297
|
}
|
|
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")
|
|
6298
|
+
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"),
|
|
6299
|
+
ticket: valuedOpt(argsIn, "--ticket"), holdEveryone: argsIn.includes("--hold-everyone") });
|
|
5874
6300
|
case "disconnect": return cmdDisconnect();
|
|
5875
6301
|
case "hook": {
|
|
5876
|
-
const VALUED = ["--timeout", "--approver", "--cli"];
|
|
6302
|
+
const VALUED = ["--timeout", "--approver", "--cli", "--unconnected"];
|
|
5877
6303
|
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
5878
6304
|
const sub = pos[0];
|
|
5879
|
-
const allowed = { pretool: [], posttool: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
|
|
6305
|
+
const allowed = { pretool: ["--unconnected"], posttool: [], session: [], prompt: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
|
|
5880
6306
|
const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
|
|
5881
6307
|
if (sub && allowed[sub]) {
|
|
5882
6308
|
const stray = given.find((f) => !allowed[sub].includes(f));
|
|
@@ -5886,8 +6312,10 @@ async function main(argv) {
|
|
|
5886
6312
|
}
|
|
5887
6313
|
}
|
|
5888
6314
|
switch (sub) {
|
|
5889
|
-
case "pretool": return cmdHookPretool();
|
|
6315
|
+
case "pretool": return cmdHookPretool({ unconnected: valuedOpt(argsIn, "--unconnected") === "tell" ? "tell" : "hold" });
|
|
5890
6316
|
case "posttool": return cmdHookPosttool();
|
|
6317
|
+
case "session": return cmdHookSession();
|
|
6318
|
+
case "prompt": return cmdHookPrompt();
|
|
5891
6319
|
case "wait": {
|
|
5892
6320
|
const t = valuedOpt(argsIn, "--timeout") ?? "600";
|
|
5893
6321
|
if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
|
|
@@ -5900,7 +6328,7 @@ async function main(argv) {
|
|
|
5900
6328
|
case "rules": return cmdHookRules();
|
|
5901
6329
|
case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
|
|
5902
6330
|
default:
|
|
5903
|
-
err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool and
|
|
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}`);
|
|
5904
6332
|
return 2;
|
|
5905
6333
|
}
|
|
5906
6334
|
}
|
package/openapi.yaml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
openapi: 3.1.0
|
|
6
6
|
info:
|
|
7
7
|
title: Decidio Agent Gate
|
|
8
|
-
version: 0.
|
|
8
|
+
version: 0.11.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
|
|
9
9
|
description: >
|
|
10
10
|
Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
|
|
11
11
|
receive the signed resume webhook when a routed action is decided. Every outcome seals a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@decidio/verify": "0.
|
|
87
|
+
"@decidio/verify": "0.11.0"
|
|
88
88
|
},
|
|
89
89
|
"author": "OmniTwin Technologies Inc.",
|
|
90
90
|
"bugs": {
|