@inetafrica/open-claudia 3.4.24 → 3.5.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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.0
4
+
5
+ Security fixes for the permission system: phase 0 of the redesign in the owner's 25 Sep review. The owner's own chat turns work as before. Staff and external turns lose the ways around the checks. First shipped as a canary on two bots.
6
+
7
+ - **Who a process acts for is now decided by the running bot.** The runner registers every run with the person it acts for, resolved from the inbound message, and stamps an attestation bound to that run. Tools, the keyring, notes and scheduling ask the bot through the loopback's new `whoami`. The following are all *unattributed*, and unattributed is never the owner:
8
+ - a shell that unsets or rewrites its environment;
9
+ - a process that outlives its turn;
10
+ - anything started outside a chat turn.
11
+ Before this release, the CLIs read the speaker from `OC_CHANNEL_*` variables that the agent's own shell could change, and an unset speaker counted as the owner.
12
+ - **Staff turns never see the AI service credentials.** Non-owner runs set `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`.
13
+ - **A staff write or destructive tool run is decided inside the bot.** The mandate check runs there. If the owner must approve, the bot posts the card itself. Requests raised from inside a tool used to die with "could not post to owner" or "no reachable owner" (18 dropped fleet-wide in 14 days).
14
+ - **A verb that a tool's manifest does not declare is refused for everyone,** and the refusal lists the declared verbs. Before, a staff turn's undeclared verb was classed as a write and sent to the owner.
15
+ - **An owner approval carries exactly the tier approved.** Redeeming any token used to export "destructive", so approving a write unlocked destructive for that run.
16
+ - **Changing tools, notes, the keyring and sub-agents is owner-only:**
17
+ - tool add, set, edit, note, remove and lib;
18
+ - entity, pack, lessons and ideas writes;
19
+ - every keyring verb, including `list`;
20
+ - `open-claudia agent`.
21
+ From a plain terminal, these now need an owner approval token.
22
+ - **Follow-ups set in a staff turn run at that person's level, in that room.** This covers wakeups, crons and watches. The job's creator is the attested run. Before, it came from fields the run's own shell could forge, with a fallback to the owner. Watch checks run without the bot's secrets, as their creator.
23
+ - **People notes and tasks are owner-only through the bot's commands.**
24
+ - **History stays in the caller's room.** For a non-owner, `recent`, `transcript-search` and `transcript-window` return only the caller's room. `recent-fetch`'s channel filter, which matched nothing, is fixed.
25
+ - **File reads are screened on staff turns.**
26
+ - Read, Grep, Glob, NotebookRead and LS cannot reach:
27
+ - the bot's config (keyring, transcripts, approvals, people);
28
+ - Claude and Codex credentials;
29
+ - SSH, cloud and kube credentials;
30
+ - other processes' environments.
31
+ - Paths are resolved through symlinks.
32
+ - The shell refuses the obvious equivalents.
33
+ - A permission hook that cannot run now refuses on a staff turn instead of letting the tool through.
34
+ - **Destructive is never a standing permission.**
35
+ - "Always" on a destructive card is refused.
36
+ - A destructive grant already on disk counts as act.
37
+ - Grants for anonymous or unverifiable senders (Telegram anonymous admins, channel posts, the bot itself) are refused.
38
+ - When an exact-room grant and an all-rooms grant both match, the stronger one applies.
39
+ - **Releases can be canaried.**
40
+ - A `canary-vX.Y.Z` tag publishes npm under `next` and pushes docker `:X.Y.Z` only.
41
+ - Pushing `vX.Y.Z` on the same commit later promotes that exact build (`latest`, no rebuild).
42
+
3
43
  ## 3.4.24
4
44
 
5
45
  - A repeating watch keeps polling while its triggered delivery waits for a busy room, waits to retry after a failed or `/stop`-killed run, or runs. A new hit folds into that one pending delivery instead of queueing another, and the delivered prompt says how many more times the check passed and whether its latest run had cleared. On 24 Sep the event-bus watch went unpolled for 7.5 h behind a busy room, and after a killed delivery attempt its polling never resumed. One-shot watches are unchanged, and the 15-minute "⏳ Watch … waiting" line still goes out once per occurrence.
package/bin/agent.js CHANGED
@@ -58,6 +58,10 @@ async function run(args) {
58
58
  if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
59
59
  console.log(HELP); process.exit(args[0] === "--help" || args[0] === "help" ? 0 : 2);
60
60
  }
61
+ // A sub-agent is an unguarded agent with a shell: only the owner's own run
62
+ // may start one. Decided by the running bot from the run's attestation
63
+ // (bin/owner-gate), never from this process's env.
64
+ if (!(await require("./owner-gate").requireOwner("starting a sub-agent"))) return;
61
65
  let parsed;
62
66
  try { parsed = parseAgentArgs(args); }
63
67
  catch (error) { console.error(error.message); process.exit(2); return; }
package/bin/cli.js CHANGED
@@ -146,7 +146,16 @@ switch (command) {
146
146
  const res = runHook(payload);
147
147
  if (res.stderr) console.error(res.stderr);
148
148
  code = res.exit;
149
- } catch (e) { code = 0; }
149
+ } catch (e) {
150
+ // The guard itself failed (it would not load, or stdin was unreadable).
151
+ // On a non-owner's turn that must not wave the tool through: the runner
152
+ // marks such runs OC_GUARDED_RUN=1 in the agent's env, which the hook
153
+ // inherits from Claude Code and the agent's own shell cannot change.
154
+ if (process.env.OC_GUARDED_RUN === "1") {
155
+ console.error("⛔ The permission check could not run, so this is refused on a non-owner's turn.");
156
+ code = 2;
157
+ } else code = 0;
158
+ }
150
159
  process.exit(code);
151
160
  break;
152
161
  }
package/bin/entity.js CHANGED
@@ -6,15 +6,26 @@
6
6
  // open-claudia entity persona <name> [--relationship owner|trusted|external] [--role ..] [--style ..] [--knows ..] [--mandate ..]
7
7
  // open-claudia entity remove <slug-or-name>
8
8
  // open-claudia entity reindex
9
+ //
10
+ // note/persona/remove/reindex change the notes (and persona mandates) the bot
11
+ // acts on: owner-only, decided by the running bot from the run's attestation
12
+ // (bin/owner-gate), never from OC_CHANNEL_* env. list/show/match are reads.
9
13
 
10
14
  const fs = require("fs");
11
15
  const path = require("path");
12
16
  const entities = require("../core/entities");
13
17
 
14
- function run(args) {
18
+ const OWNER_ONLY_VERBS = new Set(["note", "persona", "remove", "reindex"]);
19
+
20
+ async function run(args) {
15
21
  const cmd = (args[0] || "list").toLowerCase();
16
22
  const rest = args.slice(1);
17
23
 
24
+ if (OWNER_ONLY_VERBS.has(cmd)) {
25
+ const what = cmd === "persona" ? "setting a persona/mandate" : `"entity ${cmd}" changes the bot's notes`;
26
+ if (!(await require("./owner-gate").requireOwner(what))) return;
27
+ }
28
+
18
29
  switch (cmd) {
19
30
  case "list": {
20
31
  const all = entities.listEntities();
@@ -73,20 +84,11 @@ function run(args) {
73
84
  // is cleared to do/discuss with that person. Each flag replaces its section
74
85
  // wholesale; omit a flag to leave it unchanged.
75
86
  //
76
- // OWNER-ONLY: setting a mandate/relationship is a privilege operation, so
77
- // an external speaker must never reach it (self-escalation). No guarded
78
- // speaker (owner turn, CLI, or cron) may proceed. Note: relationship
79
- // "owner" set here is still coerced to "external" at runtime for a non-
80
- // owner person (R1) — a note can never confer owner powers.
81
- try {
82
- const relationship = require("../core/relationship");
83
- const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID, process.env.OC_CHANNEL_USER_ID);
84
- if (relationship.guardActive(sp)) {
85
- console.error("⛔ Only the owner can set a persona/mandate.");
86
- process.exitCode = 3; return;
87
- }
88
- } catch (e) { /* no context → owner-equivalent, allowed */ }
89
-
87
+ // OWNER-ONLY (gated above): setting a mandate/relationship is a privilege
88
+ // operation, so nobody but the owner may reach it (self-escalation) — a
89
+ // guarded turn and an unattributed process alike are refused. Note:
90
+ // relationship "owner" set here is still coerced to "external" at runtime
91
+ // for a non-owner person (R1) — a note can never confer owner powers.
90
92
  const takeFlag = (flag) => {
91
93
  const i = rest.indexOf(flag);
92
94
  if (i === -1) return null;
package/bin/ideas.js CHANGED
@@ -4,14 +4,23 @@
4
4
  // open-claudia ideas remove <id|text> — remove one
5
5
  // open-claudia ideas show — raw file
6
6
  // open-claudia ideas path — print the file path
7
+ //
8
+ // add/remove change the backlog the nightly dream works from: owner-only,
9
+ // decided by the running bot from the run's attestation (bin/owner-gate).
7
10
 
8
11
  const fs = require("fs");
9
12
  const ideas = require("../core/ideas");
10
13
 
11
- function run(args) {
14
+ const OWNER_ONLY_VERBS = new Set(["add", "remove", "rm"]);
15
+
16
+ async function run(args) {
12
17
  const cmd = (args[0] || "list").toLowerCase();
13
18
  const rest = args.slice(1);
14
19
 
20
+ if (OWNER_ONLY_VERBS.has(cmd)) {
21
+ if (!(await require("./owner-gate").requireOwner(`"ideas ${cmd}" changes the bot's ideas backlog`))) return;
22
+ }
23
+
15
24
  switch (cmd) {
16
25
  case "list": {
17
26
  const all = ideas.listIdeas();
package/bin/keyring.js CHANGED
@@ -15,29 +15,30 @@
15
15
  //
16
16
  // For genuine personal secrets the bot should NOT use unattended, use the
17
17
  // vault (/vault) instead — it stays encrypted and locked.
18
+ //
19
+ // Owner only, every verb (exec and list included): the caller must be a live
20
+ // run the bot attributes to the owner.
18
21
 
19
22
  const keyring = require("../core/keyring");
20
23
 
21
- function run(args) {
24
+ async function run(args) {
22
25
  // The keyring is owner infrastructure wholesale: exec runs an arbitrary
23
26
  // command with every credential in env, get prints raw values, set/remove
24
- // mutate them. No external request ever needs any of it — and the Bash
25
- // control-plane gate exempts `keyring exec` as an authorised channel, so
26
- // the speaker gate must live here, on the authorised side. Same fail
27
- // directions as the file gate: dormant without channel env (owner-local),
28
- // denied when resolution fails.
29
- const speaker = require("../core/tool-guard").guardedSpeakerFromEnv();
30
- if (speaker) {
31
- try {
32
- require("../core/tool-guard").logGuardEvent({
33
- kind: "deny", rule: "external-keyring", command: `keyring ${args.join(" ")}`.slice(0, 300),
34
- });
35
- } catch (e) { /* logging is best-effort */ }
36
- console.error(`⛔ External-speaker guard: the keyring is owner-only — this turn's speaker${speaker.name ? ` (${speaker.name})` : ""} is not the owner.`);
37
- console.error("Describe what you need instead; write-tier tool runs escalate to the owner for approval.");
38
- process.exitCode = 1;
39
- return;
40
- }
27
+ // mutate them, and even list/path map what exists. No other request ever
28
+ // needs any of it — and the Bash control-plane gate exempts `keyring exec`
29
+ // as an authorised channel, so the gate must live here, on the authorised
30
+ // side. EVERY verb needs an attributed owner: whether this process acts for
31
+ // the owner is answered by the running bot from the run's attestation
32
+ // (bin/owner-gate), never from OC_CHANNEL_* env — a stripped or rewritten
33
+ // env, and a process with no live run behind it, are refused.
34
+ const owner = await require("./owner-gate").requireOwner("the keyring", {
35
+ exitCode: 1,
36
+ onRefuse: (caller) => require("../core/tool-guard").logGuardEvent({
37
+ kind: "deny", rule: "external-keyring", caller: caller && caller.attributed ? "guarded" : "unattributed",
38
+ command: `keyring ${args.join(" ")}`.slice(0, 300),
39
+ }),
40
+ });
41
+ if (!owner) return;
41
42
  const cmd = (args[0] || "list").toLowerCase();
42
43
  const rest = args.slice(1);
43
44
 
package/bin/lessons.js CHANGED
@@ -4,14 +4,23 @@
4
4
  // open-claudia lessons remove <id|text> — remove one
5
5
  // open-claudia lessons show — raw file + injected block
6
6
  // open-claudia lessons path — print the file path
7
+ //
8
+ // add/remove change rules loaded into EVERY conversation: owner-only, decided
9
+ // by the running bot from the run's attestation (bin/owner-gate).
7
10
 
8
11
  const fs = require("fs");
9
12
  const lessons = require("../core/lessons");
10
13
 
11
- function run(args) {
14
+ const OWNER_ONLY_VERBS = new Set(["add", "remove", "rm"]);
15
+
16
+ async function run(args) {
12
17
  const cmd = (args[0] || "list").toLowerCase();
13
18
  const rest = args.slice(1);
14
19
 
20
+ if (OWNER_ONLY_VERBS.has(cmd)) {
21
+ if (!(await require("./owner-gate").requireOwner(`"lessons ${cmd}" changes the bot's always-loaded rules`))) return;
22
+ }
23
+
15
24
  switch (cmd) {
16
25
  case "list": {
17
26
  const all = lessons.listLessons();
@@ -46,6 +46,9 @@ function context() {
46
46
  // Runner-minted HMAC binding canonicalUserId to what the bot stamped for
47
47
  // this turn; the loopback's owner gates verify it (see core/speaker-attest).
48
48
  speakerAttest: process.env.OC_SPEAKER_ATTEST || null,
49
+ // Runner-minted attest for THIS run; the bot answers who the run acts
50
+ // for only while the run is live (see core/speaker-attest registerRun).
51
+ runAttest: process.env.OC_RUN_ATTEST || null,
49
52
  provider: process.env.OC_PROVIDER || null,
50
53
  providerSettings,
51
54
  project: process.env.OC_PROJECT || null,
@@ -81,6 +84,7 @@ function postJson(kind, body) {
81
84
  userId: ctx.userId,
82
85
  canonicalUserId: ctx.canonicalUserId,
83
86
  speakerAttest: ctx.speakerAttest,
87
+ runAttest: ctx.runAttest,
84
88
  provider: ctx.provider,
85
89
  providerSettings: ctx.providerSettings,
86
90
  project: requestedProject,
@@ -127,4 +131,45 @@ function postJson(kind, body) {
127
131
  });
128
132
  }
129
133
 
130
- module.exports = { context, requireContext, postJson, readLoopbackInfo };
134
+ // Who this process acts for, as the RUNNING BOT decides it. Needs no chat
135
+ // context: a process with no live run behind it (stripped env, detached from
136
+ // a finished turn, started outside the bot) comes back unattributed, and so
137
+ // does every failure to ask. Unattributed is never the owner.
138
+ const UNATTRIBUTED = Object.freeze({ attributed: false, owner: false, guarded: true, kind: "unattributed" });
139
+
140
+ function whoami({ timeoutMs = 5000 } = {}) {
141
+ const ctx = context();
142
+ if (!ctx.runId || !ctx.runAttest || !ctx.sendUrl || !ctx.sendToken) return Promise.resolve(UNATTRIBUTED);
143
+ let u;
144
+ try { u = new URL(ctx.sendUrl); } catch (_) { return Promise.resolve(UNATTRIBUTED); }
145
+ const payload = JSON.stringify({ runId: ctx.runId, originRunId: ctx.runId, runAttest: ctx.runAttest });
146
+ return new Promise((resolve) => {
147
+ const req = http.request({
148
+ method: "POST",
149
+ hostname: u.hostname,
150
+ port: u.port,
151
+ path: "/whoami",
152
+ timeout: timeoutMs,
153
+ headers: {
154
+ Authorization: `Bearer ${ctx.sendToken}`,
155
+ "Content-Type": "application/json",
156
+ "Content-Length": Buffer.byteLength(payload),
157
+ },
158
+ }, (res) => {
159
+ let out = "";
160
+ res.on("data", (c) => { out += c; });
161
+ res.on("end", () => {
162
+ try {
163
+ const caller = res.statusCode === 200 ? JSON.parse(out).caller : null;
164
+ resolve(caller && caller.attributed === true ? caller : UNATTRIBUTED);
165
+ } catch (_) { resolve(UNATTRIBUTED); }
166
+ });
167
+ });
168
+ req.on("timeout", () => { req.destroy(); resolve(UNATTRIBUTED); });
169
+ req.on("error", () => resolve(UNATTRIBUTED));
170
+ req.write(payload);
171
+ req.end();
172
+ });
173
+ }
174
+
175
+ module.exports = { context, requireContext, postJson, readLoopbackInfo, whoami, UNATTRIBUTED };
@@ -0,0 +1,38 @@
1
+ // Owner-only CLI verbs (permission Phase 0). Whether a process acts for the
2
+ // owner is decided by the RUNNING BOT (loopback whoami over OC_RUN_ID +
3
+ // OC_RUN_ATTEST), never by this process's own env: OC_CHANNEL_* and
4
+ // OC_CANONICAL_USER_ID are ignored, and a process with no live run behind it
5
+ // (a stripped env, a detached leftover, a plain terminal) is unattributed —
6
+ // which is never the owner.
7
+
8
+ const { whoami } = require("./loopback-client");
9
+
10
+ function hasRunAttest(env = process.env) {
11
+ return !!(env.OC_RUN_ID && env.OC_RUN_ATTEST);
12
+ }
13
+
14
+ function refusal(what, caller) {
15
+ const why = !(caller && caller.attributed) ? "this process is not attributed to an owner chat turn"
16
+ : caller.name ? `this turn acts for ${caller.name}, not the owner`
17
+ : "this turn does not act for the owner";
18
+ return `⛔ Owner only: ${what} — ${why}.`;
19
+ }
20
+
21
+ // Resolves the caller when it is an attributed owner, else prints one refusal
22
+ // line, sets process.exitCode and resolves null. With no run attestation at
23
+ // all the refusal happens synchronously (before the first await), so callers
24
+ // that do not await still see the exit code at once.
25
+ async function requireOwner(what, { exitCode = 3, onRefuse = null } = {}) {
26
+ const refuse = (caller) => {
27
+ if (typeof onRefuse === "function") { try { onRefuse(caller); } catch (_) { /* best-effort */ } }
28
+ console.error(refusal(what, caller));
29
+ process.exitCode = exitCode;
30
+ return null;
31
+ };
32
+ if (!hasRunAttest()) return refuse({ attributed: false });
33
+ const caller = await whoami();
34
+ if (caller && caller.attributed === true && caller.owner === true) return caller;
35
+ return refuse(caller);
36
+ }
37
+
38
+ module.exports = { requireOwner, hasRunAttest, refusal };
package/bin/pack.js CHANGED
@@ -6,15 +6,25 @@
6
6
  // open-claudia pack migrate — fold ~/.claude/skills into packs
7
7
  // open-claudia pack remove <dir>
8
8
  // open-claudia pack reindex
9
+ //
10
+ // skill/kind/archive/restore/remove/reindex/migrate change the packs the bot
11
+ // works from: owner-only, decided by the running bot from the run's
12
+ // attestation (bin/owner-gate). The listing/show/match/search verbs are reads.
9
13
 
10
14
  const fs = require("fs");
11
15
  const path = require("path");
12
16
  const packs = require("../core/packs");
13
17
 
14
- function run(args) {
18
+ const OWNER_ONLY_VERBS = new Set(["skill", "kind", "archive", "restore", "remove", "reindex", "migrate"]);
19
+
20
+ async function run(args) {
15
21
  const cmd = (args[0] || "list").toLowerCase();
16
22
  const rest = args.slice(1);
17
23
 
24
+ if (OWNER_ONLY_VERBS.has(cmd)) {
25
+ if (!(await require("./owner-gate").requireOwner(`"pack ${cmd}" changes the bot's packs`))) return;
26
+ }
27
+
18
28
  switch (cmd) {
19
29
  case "list": {
20
30
  const all = packs.listPacks();
package/bin/tool.js CHANGED
@@ -31,6 +31,14 @@
31
31
  // --yes-destructive — get the user's approval first; the flag records that
32
32
  // deliberate step. Read-only tools run freely.
33
33
  //
34
+ // Who a run acts for is decided by the RUNNING BOT (loopback whoami over the
35
+ // run's attestation), never by this process's env: the owner's turn keeps the
36
+ // flag gate above; a staff turn's write/destructive run is decided in the bot
37
+ // (tool-gate: mandate vet, else a held owner approval); a process with no live
38
+ // run behind it may run write/destructive only with an owner's --approval
39
+ // token. Changing tools (add/set/scaffold/edit/note/remove/migrate/lib set) is
40
+ // owner-only.
41
+ //
34
42
  // Fix before fork: when a tool is wrong, prefer `tool set` (metadata), `tool
35
43
  // note` (docs), or `tool edit` (body) over adding a near-duplicate. Hardcoded
36
44
  // secrets BLOCK a save; other lints (requires/keyring mismatch, syntax,
@@ -110,10 +118,22 @@ function reportSafety(t) {
110
118
  }
111
119
  }
112
120
 
121
+ // Verbs that change the tool set, a tool's source/metadata, its TOOL.md, or the
122
+ // shared _lib: owner-only. Reads (list/search/show/lib list|show/path) and
123
+ // `run` (gated per tier below) are not in this set.
124
+ const OWNER_ONLY_VERBS = new Set(["add", "save", "set", "fix", "scaffold", "new", "edit", "note", "migrate", "remove", "rm"]);
125
+ const OWNER_ONLY_LIB_VERBS = new Set(["set", "add"]);
126
+
113
127
  async function run(args) {
114
128
  const cmd = (args[0] || "list").toLowerCase();
115
129
  const rest = args.slice(1);
116
130
 
131
+ const libVerb = cmd === "lib" ? String(rest[0] || "list").toLowerCase() : "";
132
+ if (OWNER_ONLY_VERBS.has(cmd) || OWNER_ONLY_LIB_VERBS.has(libVerb)) {
133
+ const what = `"tool ${libVerb ? `lib ${libVerb}` : cmd}" changes the bot's tools`;
134
+ if (!(await require("./owner-gate").requireOwner(what))) return;
135
+ }
136
+
117
137
  // Auto-migrate legacy flat-file tools to the directory layout on first use
118
138
  // (idempotent, one git commit per tool, telemetry carried over).
119
139
  if (cmd !== "path") {
@@ -376,9 +396,14 @@ async function run(args) {
376
396
  // calling this tool) gate on the tier the OUTERMOST invocation approved
377
397
  // instead — argv flags can't fabricate approval.
378
398
  const gate = tools.runGate(t, toolArgs, process.env);
399
+ // A tool's manifest is the contract: a verb it does not declare is
400
+ // refused for EVERY caller, before any vet, escalation or approval
401
+ // redemption — there is no tier to judge or to approve.
402
+ if (gate.undeclared) { console.error(gate.message); process.exitCode = 3; return; }
379
403
  const approvals = require("../core/approvals");
380
404
  const commandLine = approvals.formatApprovedCommand(["open-claudia", "tool", "run", t.name, ...toolArgs]);
381
405
  let approvedVia = "";
406
+ let approvedTier = "";
382
407
 
383
408
  // B10: creds preflight BEFORE any approval machinery. A missing keyring
384
409
  // key used to be discovered only after the one-shot --approval token was
@@ -395,56 +420,53 @@ async function run(args) {
395
420
  }
396
421
  }
397
422
 
398
- // ── Phase 3.5: per-speaker external gate ──────────────────────────────
399
- // When this run is FOR an external (non-owner) speaker, a write/destructive
400
- // tool cannot rely on the agent's own --yes flag — the external could have
401
- // talked it into passing it. Route mutating runs through the independent
402
- // mandate vet: an action the owner-authored mandate clearly covers carries
403
- // the owner's authority and runs; anything else is HELD and the owner is
404
- // asked (Approve/Deny/Always-allow), then re-run via a one-shot --approval
405
- // token. A token already present means the owner just approved → skip.
406
- // Owner / no-context speakers are never gated here, so the legacy owner
407
- // path is byte-for-byte unchanged (R9/R14).
408
- if (!approvalId && (gate.tier === "write" || gate.tier === "destructive")) {
409
- let speaker = null;
410
- try {
411
- const relationship = require("../core/relationship");
412
- const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID, process.env.OC_CHANNEL_USER_ID);
413
- if (relationship.guardActive(sp)) speaker = sp; // else owner/no-context → ungated
414
- } catch (e) { speaker = null; }
415
- if (speaker) {
416
- const enforcer = require("../core/enforcer");
417
- let vet;
418
- try { vet = await enforcer.vetAction(speaker, { command: commandLine, tier: gate.tier }); }
419
- catch (e) { vet = { decision: "escalate", reason: "vet error — failing closed" }; }
420
- if (vet.decision === "allow") {
423
+ // ── Who this run acts for, decided by the RUNNING BOT ─────────────────
424
+ // Read-only runs are ungated for everyone. For write/destructive the
425
+ // bot answers from the run's attestation (whoami), never from this
426
+ // process's env (OC_CHANNEL_* can be unset or rewritten by the shell):
427
+ // • the owner's run → the flag/approval gate below, unchanged;
428
+ // • a guarded (non-owner) run → a --yes the speaker may have talked the
429
+ // agent into proves nothing, so the bot decides (tool-gate): an action
430
+ // the owner-authored mandate clearly covers carries the owner's
431
+ // authority; anything else is HELD with an owner card (then re-run
432
+ // via a one-shot --approval token) or blocked. A token already
433
+ // present means the owner approved → redeem it below;
434
+ // • no live run behind this process → refused unless it presents an
435
+ // owner's --approval token.
436
+ if (gate.tier === "write" || gate.tier === "destructive") {
437
+ const client = require("./loopback-client");
438
+ const caller = await client.whoami();
439
+ if (!caller.attributed) {
440
+ if (!approvalId) {
441
+ console.error(`⛔ ${t.name} (${gate.tier}) refused — not attributed to a live chat turn; write actions need a chat turn or an owner approval token.`);
442
+ process.exitCode = 3; return;
443
+ }
444
+ } else if (!caller.owner && !approvalId) {
445
+ let decision = null;
446
+ try {
447
+ const ctx = client.context();
448
+ if (!ctx.sendUrl || !ctx.sendToken || !ctx.channelId || !ctx.adapterId) throw new Error("no chat context to ask the bot from");
449
+ decision = await client.postJson("tool-gate", {
450
+ tool: t.name, verb: tools.invokedVerb(toolArgs), tier: gate.tier, command: commandLine,
451
+ });
452
+ } catch (e) {
453
+ decision = { decision: "blocked", reason: `the bot could not vet this action (${e.message})` };
454
+ }
455
+ const who = (decision && decision.name) || caller.name || "the external contact";
456
+ if (decision && decision.decision === "allow") {
421
457
  // The owner's mandate pre-authorizes this class of action → it carries
422
458
  // the owner's approval, satisfying the risk gate without a redundant --yes.
423
459
  approvedVia = "mandate";
460
+ } else if (decision && decision.decision === "held" && decision.approvalId) {
461
+ console.error(`🛡️ Held for owner approval — ${who} is outside mandate for this ${gate.tier} action.`);
462
+ console.error(`Approval ${decision.approvalId} was raised to the owner. End the turn and tell them you're checking; a wakeup fires when they decide. Do NOT retry or re-request.`);
463
+ process.exitCode = 5; return;
424
464
  } else {
425
- const esc = await enforcer.escalate({
426
- speaker, kind: "action", command: commandLine, tier: gate.tier, reason: vet.reason,
427
- canonicalUserId: process.env.OC_CANONICAL_USER_ID || "",
428
- userId: process.env.OC_CHANNEL_USER_ID || "",
429
- provider: process.env.OC_PROVIDER || "",
430
- providerSettings: (() => {
431
- try {
432
- const value = JSON.parse(process.env.OC_PROVIDER_SETTINGS || "{}");
433
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
434
- } catch (_) { return {}; }
435
- })(),
436
- project: process.env.OC_PROJECT || "",
437
- projectDir: process.env.OC_PROJECT_DIR || "",
438
- sessionId: process.env.OC_SESSION_ID || process.env.OC_LAST_SESSION_ID || "",
439
- previousSessionId: process.env.OC_PREVIOUS_SESSION_ID || "",
440
- originRunId: process.env.OC_RUN_ID || process.env.OC_ORIGIN_RUN_ID || "",
441
- });
442
- if (esc.escalated) {
443
- console.error(`🛡️ Held for owner approval — ${speaker.name || "the external contact"} is outside mandate for this ${gate.tier} action.`);
444
- console.error(`Approval ${esc.id} was raised to the owner. End the turn and tell them you're checking; a wakeup fires when they decide. Do NOT retry or re-request.`);
445
- process.exitCode = 5; return;
465
+ if (decision && decision.unreachable) {
466
+ console.error(`⛔ Blocked — ${who} is outside mandate and the owner could not be reached to approve.`);
467
+ } else {
468
+ console.error(`⛔ Blocked — ${(decision && decision.reason) || "the bot did not clear this action"}; failing closed.`);
446
469
  }
447
- console.error(`⛔ Blocked — ${speaker.name || "the external contact"} is outside mandate and the owner could not be reached to approve.`);
448
470
  process.exitCode = 3; return;
449
471
  }
450
472
  }
@@ -455,12 +477,15 @@ async function run(args) {
455
477
  console.error("⛔ --approval tokens are only honored on top-level runs, not nested tool calls.");
456
478
  process.exitCode = 3; return;
457
479
  }
458
- const redeemed = approvals.consume(approvalId, commandLine);
480
+ // Bound to the tier the owner approved: a run that now resolves above
481
+ // it is refused without burning the token.
482
+ const redeemed = approvals.consume(approvalId, commandLine, { tier: gate.tier });
459
483
  if (!redeemed.ok) {
460
484
  console.error(`⛔ Approval ${approvalId} rejected: ${redeemed.reason}. The run stays blocked.`);
461
485
  process.exitCode = 3; return;
462
486
  }
463
487
  approvedVia = "chat-approval";
488
+ approvedTier = approvals.approvedTier(redeemed.record, gate.tier);
464
489
  console.error(`✅ Approval ${approvalId} redeemed (single use) — running.`);
465
490
  } else if (!gate.ok && approvedVia !== "mandate") {
466
491
  // Chat-approval fallback: an unflagged destructive top-level run inside
@@ -497,12 +522,13 @@ async function run(args) {
497
522
  `Re-run with --yes-destructive only after the user explicitly approves.`);
498
523
  process.exitCode = 3; return;
499
524
  }
500
- const redeemed = approvals.consume(reqId, commandLine);
525
+ const redeemed = approvals.consume(reqId, commandLine, { tier: gate.tier });
501
526
  if (!redeemed.ok) {
502
527
  console.error(`⛔ Approval ${reqId || "(missing)"} rejected: ${redeemed.reason}. The run stays blocked.`);
503
528
  process.exitCode = 3; return;
504
529
  }
505
530
  approvedVia = "chat-approval";
531
+ approvedTier = approvals.approvedTier(redeemed.record, gate.tier);
506
532
  console.error(`✅ Approval ${reqId} redeemed (single use) — running.`);
507
533
  }
508
534
 
@@ -516,12 +542,14 @@ async function run(args) {
516
542
  } catch (e) { /* advisory */ }
517
543
  // Composition inheritance: export the approved tier + call chain so any
518
544
  // tool this tool shells into is gated by what the human actually approved
519
- // at the top — an already-inherited tier passes through UNCHANGED.
545
+ // at the top — an already-inherited tier passes through UNCHANGED. A
546
+ // redeemed approval exports exactly the tier on the owner's card (a write
547
+ // approval never unlocks a nested destructive tool).
520
548
  const env = tools.runEnv(t);
521
549
  const parentStack = String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim();
522
550
  env.OPEN_CLAUDIA_APPROVED_TIER = parentStack
523
551
  ? (tools.normalizeRisk(process.env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only")
524
- : (approvedVia === "chat-approval" ? "destructive"
552
+ : (approvedVia === "chat-approval" ? (approvedTier || gate.tier)
525
553
  : approvedVia === "mandate" ? gate.tier
526
554
  : tools.approvedTierFromArgv(toolArgs));
527
555
  env.OPEN_CLAUDIA_TOOL_STACK = parentStack ? `${parentStack}>${t.name}` : t.name;