@inetafrica/open-claudia 3.4.24 → 3.5.1

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/core/handlers.js CHANGED
@@ -1928,13 +1928,19 @@ register({
1928
1928
 
1929
1929
  const live = grants.list();
1930
1930
  if (live.length === 0) {
1931
- return send("No capability grants yet.\n\nA grant is what lets a non-owner act: it covers ONE person in ONE room, so trusting someone in one group never trusts them elsewhere. Make one from an approval card (\"Always — here\" / \"Always — everywhere\").");
1931
+ return send("No capability grants yet.\n\nA grant is what lets a non-owner act: it covers ONE person in ONE room, so trusting someone in one group never trusts them elsewhere. Make one from an approval card (\"Always — here\" / \"Always — everywhere\"). Destructive actions always ask; they can't be granted.");
1932
1932
  }
1933
1933
  const lines = live.map((g) => {
1934
1934
  const where = g.room === grants.ALL_ROOMS ? "everywhere" : g.room;
1935
1935
  const until = g.expiresAt ? ` · until ${g.expiresAt.slice(0, 10)}` : "";
1936
1936
  const turns = g.turnsRemaining != null ? ` · ${g.turnsRemaining} turn(s) left` : "";
1937
- return `🔑 [${g.id}] ${g.principal.adapter}:${g.principal.channelId} → ${g.capability} in ${where}${until}${turns}`;
1937
+ // Legacy records: a destructive grant confers act only (destructive
1938
+ // always asks); one for an anonymous or unverifiable sender is ignored.
1939
+ const capability = grants.effectiveCapability(g);
1940
+ const legacy = grants.normalizeCapability(g.capability) !== capability ? ` (stored as ${g.capability}; destructive always asks)` : "";
1941
+ const refusal = grants.principalRefusal({ adapter: g.principal.adapter, senderId: g.principal.channelId, room: g.room });
1942
+ const ignored = refusal ? ` — ignored: ${refusal}` : "";
1943
+ return `🔑 [${g.id}] ${g.principal.adapter}:${g.principal.channelId} → ${capability}${legacy} in ${where}${until}${turns}${ignored}`;
1938
1944
  });
1939
1945
  return send(`Capability grants (${live.length}):\n\n${lines.join("\n")}\n\n/grants revoke <id> · /grants revoke-all <adapter>:<senderId>\nFile: ${grants.GRANTS_FILE}`);
1940
1946
  },
package/core/loopback.js CHANGED
@@ -81,7 +81,19 @@ function readBodyToFile(req, dest) {
81
81
  }
82
82
 
83
83
  const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
84
+ // Loopback kinds that CREATE a background job. Their creator identity is bound
85
+ // to callerOf(payload) (run-attested), not to the payload's forgeable claims.
86
+ const JOB_CREATE_KINDS = new Set(["schedule-wakeup", "cron-add", "watch-add"]);
87
+ // Owner-only kinds gated centrally on the run-attested caller: notes and tasks
88
+ // (private owner context) plus the people reads (the roster and records expose
89
+ // everyone the owner knows). people mutations keep their own inline gate too.
90
+ const OWNER_ONLY_KINDS = new Set([
91
+ "people-list", "people-show", "people-note",
92
+ "task-add", "task-plan", "task-update", "task-remove",
93
+ "task-list", "task-get", "task-tree", "task-clear-completed",
94
+ ]);
84
95
  const JSON_KINDS = new Set([
96
+ "whoami",
85
97
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
86
98
  "watch-add", "watch-list", "watch-remove", "watch-toggle",
87
99
  "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
@@ -91,22 +103,46 @@ const JSON_KINDS = new Set([
91
103
  "relay-send", "recent-fetch", "audit-tail",
92
104
  "auth-list", "auth-revoke",
93
105
  "approval-request",
106
+ "tool-gate",
94
107
  "say", "call-say", "call", "tts", "transcribe",
95
108
  ]);
96
109
 
97
- // Owner gate for identity-sensitive kinds. The claimed canonicalUserId comes
98
- // from the CLI's env, which the agent's shell can rewrite — so the claim only
99
- // counts when it carries the runner-minted attestation (an HMAC over the id
100
- // with a per-boot, memory-only secret; see core/speaker-attest). No attest,
101
- // a wrong attest, or an attest for a different id all fail closed: forging
102
- // `OC_CANONICAL_USER_ID=<owner-id>` from a guarded external turn no longer
103
- // reaches the people/auth/intros mutations the relationship guard depends on.
110
+ // Who is calling, decided by the bot: the principal of a LIVE run whose
111
+ // OC_RUN_ATTEST matches (see core/speaker-attest), else UNATTRIBUTED. Nothing
112
+ // the caller's env claims (OC_CHANNEL_*, OC_CANONICAL_USER_ID) is consulted,
113
+ // and unattributed never carries owner powers.
114
+ function callerOf(payload) {
115
+ const principal = speakerAttest.verifyRun(payload?.originRunId || payload?.runId, payload?.runAttest);
116
+ if (principal) {
117
+ const { registeredAt, ...caller } = principal;
118
+ return { attributed: true, ...caller };
119
+ }
120
+ return { attributed: false, owner: false, guarded: true, kind: "unattributed" };
121
+ }
122
+
123
+ // A durable, run-attested record of who created a job, stamped alongside its
124
+ // identity fields. Distinguishes a job created since this fix (has createdBy)
125
+ // from a legacy job on disk (no createdBy) — the scheduler runs the latter as
126
+ // before, but logs it. Never trusted for authorization; the fire path
127
+ // re-resolves the speaker from the job's channel/user via runPrincipal.
128
+ function jobCreatorRecord(creator) {
129
+ return {
130
+ canonicalUserId: creator.canonicalUserId || null,
131
+ userId: creator.userId || null,
132
+ channelId: creator.channelId || null,
133
+ adapterType: creator.adapterType || null,
134
+ owner: creator.owner === true,
135
+ name: creator.name || null,
136
+ at: new Date().toISOString(),
137
+ };
138
+ }
139
+
140
+ // Owner gate for every owner-only kind: true only for a LIVE run the bot
141
+ // registered as acting for the owner. The id attest (OC_SPEAKER_ATTEST) is no
142
+ // longer enough: it is valid for the whole boot and can be lifted from another
143
+ // process's environment. Identity fields in the payload are never consulted.
104
144
  function callerIsOwner(payload) {
105
- const caller = payload?.canonicalUserId;
106
- if (!caller) return false;
107
- if (!speakerAttest.verify(caller, payload?.speakerAttest)) return false;
108
- const person = peopleStore.findByCanonicalUserId(caller);
109
- return !!(person && person.isOwner);
145
+ return callerOf(payload).owner === true;
110
146
  }
111
147
 
112
148
  function readBodyAsString(req, max = 64 * 1024) {
@@ -172,6 +208,11 @@ async function handleJson(req, res, url, kind) {
172
208
  try { payload = JSON.parse(body); }
173
209
  catch (e) { return reply(res, 400, { error: "invalid JSON body" }); }
174
210
  }
211
+ // "Who am I?" needs no chat context: an unattributed caller (a process that
212
+ // outlived its turn, a stripped env) must still get a definite answer.
213
+ if (kind === "whoami") return reply(res, 200, { ok: true, caller: callerOf(payload) });
214
+ // Decided from the verified run alone, so it needs no chat fields either.
215
+ if (kind === "tool-gate") return handleToolGate(res, payload);
175
216
  const channelId = String(payload.channelId || url.searchParams.get("channelId") || "");
176
217
  const userId = String(payload.userId || url.searchParams.get("userId") || "");
177
218
  const adapterId = String(payload.adapter || url.searchParams.get("adapter") || "");
@@ -179,6 +220,40 @@ async function handleJson(req, res, url, kind) {
179
220
  const adapter = registry.findAdapter(adapterId);
180
221
  if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
181
222
 
223
+ // A background job belongs to WHOEVER the bot says is running the turn that
224
+ // created it (callerOf, run-bound) and to THAT person's room — never to the
225
+ // identity the payload claims (OC_CHANNEL_*/OC_CANONICAL_USER_ID, which the
226
+ // run's own shell can rewrite) and never to the bot's ambient owner state.
227
+ // So a follow-up a staff turn schedules fires AS that staff member, in their
228
+ // room, and all of their guards apply when it runs. An unattributed caller
229
+ // (a process detached from any live turn) cannot schedule anything.
230
+ let jobCreator = null;
231
+ if (JOB_CREATE_KINDS.has(kind)) {
232
+ const caller = callerOf(payload);
233
+ if (!caller.attributed) {
234
+ return reply(res, 403, { error: "background jobs can only be scheduled by an attributed run (no live turn is speaking for this process)" });
235
+ }
236
+ const creatorAdapter = caller.adapterId ? registry.findAdapter(caller.adapterId) : null;
237
+ jobCreator = {
238
+ canonicalUserId: caller.canonicalUserId || null,
239
+ userId: caller.userId || null,
240
+ channelId: caller.channelId || channelId,
241
+ adapter: (creatorAdapter && creatorAdapter.id) || caller.adapterId || adapterId,
242
+ adapterType: (creatorAdapter && creatorAdapter.type) || caller.adapterType || adapter.type,
243
+ owner: caller.owner === true,
244
+ name: caller.name || null,
245
+ };
246
+ }
247
+
248
+ // Owner-only kinds: notes, tasks, and the people reads. Tasks and people
249
+ // notes carry private owner context (plans, contact notes), and the people
250
+ // roster/record reads expose everyone the owner knows — none of it may be
251
+ // read or written on a non-owner (or unattributed) turn. Gated on the
252
+ // run-attested caller, never on payload claims.
253
+ if (OWNER_ONLY_KINDS.has(kind) && !callerIsOwner(payload)) {
254
+ return reply(res, 403, { error: "owner only" });
255
+ }
256
+
182
257
  if (kind === "call-say") {
183
258
  try {
184
259
  const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
@@ -285,11 +360,12 @@ async function handleJson(req, res, url, kind) {
285
360
  if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
286
361
  const job = scheduler.addJob({
287
362
  kind: "wakeup",
288
- adapter: adapterId,
289
- adapterType: adapter.type,
290
- channelId,
291
- canonicalUserId: payload.canonicalUserId || null,
292
- userId: payload.userId || null,
363
+ adapter: jobCreator.adapter,
364
+ adapterType: jobCreator.adapterType,
365
+ channelId: jobCreator.channelId,
366
+ canonicalUserId: jobCreator.canonicalUserId,
367
+ userId: jobCreator.userId,
368
+ createdBy: jobCreatorRecord(jobCreator),
293
369
  provider: payload.provider || null,
294
370
  providerSettings: payload.providerSettings || {},
295
371
  project: payload.project || null,
@@ -315,11 +391,12 @@ async function handleJson(req, res, url, kind) {
315
391
  if (!payload.schedule || !payload.prompt) return reply(res, 400, { error: "missing schedule/prompt" });
316
392
  const job = scheduler.addJob({
317
393
  kind: "cron",
318
- adapter: adapterId,
319
- adapterType: adapter.type,
320
- channelId,
321
- canonicalUserId: payload.canonicalUserId || null,
322
- userId: payload.userId || null,
394
+ adapter: jobCreator.adapter,
395
+ adapterType: jobCreator.adapterType,
396
+ channelId: jobCreator.channelId,
397
+ canonicalUserId: jobCreator.canonicalUserId,
398
+ userId: jobCreator.userId,
399
+ createdBy: jobCreatorRecord(jobCreator),
323
400
  provider: payload.provider || null,
324
401
  providerSettings: payload.providerSettings || {},
325
402
  project: payload.project || null,
@@ -362,11 +439,12 @@ async function handleJson(req, res, url, kind) {
362
439
  if (kind === "watch-add") {
363
440
  if (!payload.check || !payload.prompt) return reply(res, 400, { error: "missing check/prompt" });
364
441
  const job = scheduler.addWatch({
365
- adapter: adapterId,
366
- adapterType: adapter.type,
367
- channelId,
368
- canonicalUserId: payload.canonicalUserId || null,
369
- userId: payload.userId || null,
442
+ adapter: jobCreator.adapter,
443
+ adapterType: jobCreator.adapterType,
444
+ channelId: jobCreator.channelId,
445
+ canonicalUserId: jobCreator.canonicalUserId,
446
+ userId: jobCreator.userId,
447
+ createdBy: jobCreatorRecord(jobCreator),
370
448
  provider: payload.provider || null,
371
449
  providerSettings: payload.providerSettings || {},
372
450
  project: payload.project || null,
@@ -631,13 +709,13 @@ async function handleJson(req, res, url, kind) {
631
709
 
632
710
  if (kind === "relay-send") {
633
711
  if (!payload.text) return reply(res, 400, { error: "missing text" });
634
- // A guarded (external) requester may pass a message TO THE OWNER — that is
635
- // the "tell them for me" flow — but never use the bot as a relay to third
712
+ // A non-owner requester may pass a message TO THE OWNER — that is the
713
+ // "tell them for me" flow — but never use the bot as a relay to third
636
714
  // parties: sending as the bot to anyone else is impersonation surface.
637
- try {
638
- const relationship = require("./relationship");
639
- const speaker = relationship.speakerFor(adapter.type, channelId, userId);
640
- if (relationship.guardActive(speaker)) {
715
+ // Who is requesting is the run-attested caller, not the payload's claims.
716
+ {
717
+ const requester = callerOf(payload);
718
+ if (!requester.owner) {
641
719
  const target =
642
720
  (payload.targetPersonId && peopleStore.findById(payload.targetPersonId)) ||
643
721
  (payload.targetPersonName && peopleStore.findByName(payload.targetPersonName)) ||
@@ -648,7 +726,7 @@ async function handleJson(req, res, url, kind) {
648
726
  return reply(res, 403, { error: "relay from an external contact can only go to the owner — messages to anyone else are owner-only" });
649
727
  }
650
728
  }
651
- } catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
729
+ }
652
730
  try {
653
731
  const result = await relay.send({
654
732
  text: payload.text,
@@ -667,29 +745,36 @@ async function handleJson(req, res, url, kind) {
667
745
  }
668
746
 
669
747
  if (kind === "recent-fetch") {
670
- // Transcript reads are owner-side memory. A guarded (external, non-owner)
671
- // requester must not pull other channels' conversations through the CLI —
672
- // same default-deny as file/voice egress, same fail direction.
673
- try {
674
- const relationship = require("./relationship");
675
- const speaker = relationship.speakerFor(adapter.type, channelId, userId);
676
- if (relationship.guardActive(speaker)) {
677
- return reply(res, 403, { error: "transcript access is owner-only — an external contact cannot read other conversations" });
678
- }
679
- } catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
748
+ // Transcript reads are owner-side memory. The owner may pull any channel or
749
+ // person; a non-owner caller is confined to THEIR OWN room and may never
750
+ // point this at another conversation or an owner DM. Who is calling is the
751
+ // run-attested principal, not the payload's forgeable channel/user claims.
752
+ const caller = callerOf(payload);
680
753
  const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
681
754
  try {
682
- let targetAdapter = payload.adapter;
683
- let targetChannel = payload.channelId;
684
- if (payload.personName || payload.personId) {
685
- const person = peopleStore.findById(payload.personId || "") || peopleStore.findByName(payload.personName || "");
686
- if (!person) return reply(res, 404, { error: "person not found" });
687
- const handle = person.primaryChannel
688
- ? (person.handles || []).find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
689
- : (person.handles || [])[0];
690
- if (!handle) return reply(res, 404, { error: "person has no handles" });
691
- targetAdapter = handle.adapter;
692
- targetChannel = handle.channelId;
755
+ let targetAdapter;
756
+ let targetChannel;
757
+ if (caller.owner) {
758
+ targetAdapter = payload.adapter;
759
+ targetChannel = payload.channelId;
760
+ if (payload.personName || payload.personId) {
761
+ const person = peopleStore.findById(payload.personId || "") || peopleStore.findByName(payload.personName || "");
762
+ if (!person) return reply(res, 404, { error: "person not found" });
763
+ const handle = person.primaryChannel
764
+ ? (person.handles || []).find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
765
+ : (person.handles || [])[0];
766
+ if (!handle) return reply(res, 404, { error: "person has no handles" });
767
+ targetAdapter = handle.adapter;
768
+ targetChannel = handle.channelId;
769
+ }
770
+ } else {
771
+ // Non-owner: ignore every target selector and pin to the caller's own
772
+ // room. Unattributed (no live turn) has no room → refuse.
773
+ if (!caller.attributed || !caller.adapterType || !caller.channelId) {
774
+ return reply(res, 403, { error: "transcript access outside your own room is owner-only" });
775
+ }
776
+ targetAdapter = caller.adapterType;
777
+ targetChannel = caller.channelId;
693
778
  }
694
779
  if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
695
780
  const entries = readRecentTranscript(targetAdapter, targetChannel, limit);
@@ -712,15 +797,13 @@ async function handleJson(req, res, url, kind) {
712
797
  // Safety net: this path posts an Approve/Deny for a destructive command to
713
798
  // the REQUESTING channel. That must only ever be the owner — external
714
799
  // mutating runs are routed to the owner via the enforcer BEFORE reaching a
715
- // tool subprocess's chat-approval fallback. If the requester resolves to a
716
- // non-owner, refuse: never hand an external the approve button (R8/R10).
717
- try {
718
- const relationship = require("./relationship");
719
- const speaker = relationship.speakerFor(adapter.type, channelId, userId);
720
- if (relationship.guardActive(speaker)) {
721
- return reply(res, 403, { error: "approval requests are owner-only; external mutating runs escalate through the guardrail" });
722
- }
723
- } catch (e) { /* if classification fails, fall through — approvals stay owner-scoped by the apr: handler's isChatOwner gate */ }
800
+ // tool subprocess's chat-approval fallback. The requester is the
801
+ // run-attested caller (not the payload's forgeable claims); a non-owner
802
+ // (or unattributed) requester is refused — never hand an external the
803
+ // approve button (R8/R10).
804
+ if (!callerOf(payload).owner) {
805
+ return reply(res, 403, { error: "approval requests are owner-only; external mutating runs escalate through the guardrail" });
806
+ }
724
807
  const approvals = require("./approvals");
725
808
  // Spaces-origin runs: Spaces has no inline-button UI and its thread can be
726
809
  // watched by non-owners, so the BINDING Approve/Deny goes to the owner's
@@ -812,10 +895,15 @@ function readRecentTranscript(adapter, channelId, limit) {
812
895
  for (const line of lines) {
813
896
  try {
814
897
  const obj = JSON.parse(line);
815
- const objAdapter = (obj.adapter || obj.adapterType || "").toLowerCase();
816
- const objChannel = String(obj.channelId || "");
817
- if (objAdapter && objAdapter !== adapterPrefix) continue;
818
- if (objChannel && objChannel !== channelStr) continue;
898
+ // Project transcripts store the surface under obj.chat ({transport,id}),
899
+ // not top-level adapter/channelId — the old filter read the latter,
900
+ // found undefined, and so never actually scoped to the requested
901
+ // channel (every entry from every file leaked through). Read chat.*
902
+ // (with a legacy fallback) and REQUIRE a match on both.
903
+ const objAdapter = String(obj.chat?.transport || obj.adapter || obj.adapterType || "").toLowerCase();
904
+ const objChannel = String(obj.chat?.id ?? obj.channelId ?? "");
905
+ if (objAdapter !== adapterPrefix) continue;
906
+ if (objChannel !== channelStr) continue;
819
907
  out.push({
820
908
  at: obj.at || obj.timestamp || null,
821
909
  role: obj.role || obj.author || null,
@@ -830,6 +918,73 @@ function readRecentTranscript(adapter, channelId, limit) {
830
918
  } catch (e) { return []; }
831
919
  }
832
920
 
921
+ // ── tool-gate: a guarded run's write/destructive tool run, decided HERE ─────
922
+ // `open-claudia tool run` used to pick the speaker from its own env
923
+ // (OC_CHANNEL_*) and to vet + escalate in its own process. A staff turn's CLI
924
+ // has neither the AI credentials (scrubbed from a non-owner run) nor the chat
925
+ // adapters, so its escalations never reached an owner. Now the CLI asks the
926
+ // bot: the caller is the verified live run (callerOf — identity fields in the
927
+ // payload are never read), the speaker is rebuilt from that principal, the
928
+ // mandate vet runs with the bot's credentials, and an escalation posts its card
929
+ // through the bot's own adapters. The CLI treats every answer but allow/held
930
+ // as blocked (fail closed).
931
+ async function handleToolGate(res, body) {
932
+ const payload = body && typeof body === "object" && !Array.isArray(body) ? body : {};
933
+ const caller = callerOf(payload);
934
+ const text = (value) => (value == null ? "" : String(value));
935
+ const tool = text(payload.tool) || null;
936
+ const blocked = (reason, extra = {}) => {
937
+ try { audit.log("tool.gate", { decision: "blocked", tool, runId: caller.runId || null, reason: String(reason).slice(0, 160) }); } catch (_) {}
938
+ return reply(res, 200, { ok: true, decision: "blocked", reason, ...extra });
939
+ };
940
+ if (!caller.attributed) return blocked("not attributed to a live chat turn");
941
+ if (!caller.guarded) return blocked("not a guarded run — the owner's runs are gated by the tool runner itself");
942
+ const command = typeof payload.command === "string" ? payload.command : "";
943
+ const tier = text(payload.tier);
944
+ if (!command.trim()) return reply(res, 400, { error: "missing command" });
945
+ if (tier !== "write" && tier !== "destructive") return reply(res, 400, { error: "tier must be write or destructive" });
946
+
947
+ const relationship = require("./relationship");
948
+ let speaker = null;
949
+ try { speaker = relationship.speakerFor(caller.adapterType, caller.channelId, caller.userId); }
950
+ catch (e) { speaker = null; }
951
+ if (!speaker || !relationship.guardActive(speaker)) return blocked("the run's speaker could not be resolved — failing closed");
952
+ const name = speaker.name || caller.name || "";
953
+
954
+ const enforcer = require("./enforcer");
955
+ let vet;
956
+ try { vet = await enforcer.vetAction(speaker, { command, tier }); }
957
+ catch (e) { vet = { decision: "escalate", reason: "vet error — failing closed" }; }
958
+ if (vet && vet.decision === "allow") {
959
+ try { audit.log("tool.gate", { decision: "allow", tier, tool, runId: caller.runId, person: name || null }); } catch (_) {}
960
+ return reply(res, 200, { ok: true, decision: "allow", approvedVia: "mandate", name });
961
+ }
962
+ const providerSettings = payload.providerSettings && typeof payload.providerSettings === "object" && !Array.isArray(payload.providerSettings)
963
+ ? payload.providerSettings : {};
964
+ let esc;
965
+ try {
966
+ esc = await enforcer.escalate({
967
+ speaker, kind: "action", command, tier, reason: vet && vet.reason,
968
+ // WHO the approval is for comes from the verified run; the rest only
969
+ // pins where an approved re-run resumes (provider/project/session).
970
+ canonicalUserId: caller.canonicalUserId || "",
971
+ userId: caller.userId || "",
972
+ provider: text(payload.provider),
973
+ providerSettings,
974
+ project: text(payload.project),
975
+ projectDir: text(payload.projectDir),
976
+ sessionId: text(payload.sessionId),
977
+ previousSessionId: text(payload.previousSessionId),
978
+ originRunId: caller.runId || "",
979
+ });
980
+ } catch (e) { esc = { escalated: false, reason: e.message }; }
981
+ if (esc && esc.escalated && esc.id) {
982
+ try { audit.log("tool.gate", { decision: "held", tier, tool, runId: caller.runId, person: name || null, approvalId: esc.id }); } catch (_) {}
983
+ return reply(res, 200, { ok: true, decision: "held", approvalId: esc.id, name });
984
+ }
985
+ return blocked(`the owner could not be reached to approve (${(esc && esc.reason) || "escalation failed"})`, { unreachable: true, name });
986
+ }
987
+
833
988
  async function handle(req, res) {
834
989
  try {
835
990
  if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });
@@ -867,4 +1022,4 @@ function stop() {
867
1022
 
868
1023
  process.on("exit", deleteInfo);
869
1024
 
870
- module.exports = { start, stop, info, INFO_DIR, INFO_FILE };
1025
+ module.exports = { start, stop, info, callerOf, INFO_DIR, INFO_FILE };
@@ -31,6 +31,11 @@ function buildClaudeHookSettings(options = {}) {
31
31
  // blocks provider file-mutation tools (see tool-guard.js
32
32
  // externalFileMutationDenial). Owner/no-context turns pass through.
33
33
  { matcher: "^(Write|Edit|MultiEdit|NotebookEdit)$", hooks: [hook] },
34
+ // Guarded-speaker read gate: on external turns reads and searches that
35
+ // reach the bot's own state or credential stores are denied (see
36
+ // tool-guard.js evaluateGuardedRead). NotebookRead and LS exist only
37
+ // on older Claude Code builds. Owner/no-context turns pass through.
38
+ { matcher: "^(Read|Grep|Glob|NotebookRead|LS)$", hooks: [hook] },
34
39
  ],
35
40
  },
36
41
  };
package/core/runner.js CHANGED
@@ -1771,6 +1771,36 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1771
1771
  return { ok: true, chunks: chunks.length };
1772
1772
  }
1773
1773
 
1774
+ // The principal a run acts for. With no chat origin at all (bot-internal work)
1775
+ // that is the owner. With a chat origin it is the speaker the relationship
1776
+ // layer resolves from the inbound envelope — also for a compaction stage, which
1777
+ // summarises that speaker's conversation; if the speaker cannot be resolved,
1778
+ // or the origin names someone but not the transport and room it came from, the
1779
+ // run is treated as a non-owner (fail closed).
1780
+ const ORIGIN_FIELDS = Object.freeze(["adapterId", "transport", "channelId", "userId", "canonicalUserId"]);
1781
+ function runPrincipal(runContext) {
1782
+ const origin = runContext.origin || {};
1783
+ const channelId = runContext.channelId != null ? String(runContext.channelId) : origin.channelId;
1784
+ const base = {
1785
+ kind: "turn",
1786
+ purpose: runContext.purpose || null,
1787
+ canonicalUserId: runContext.canonicalUserId || origin.canonicalUserId || null,
1788
+ adapterId: origin.adapterId || null,
1789
+ adapterType: origin.transport || null,
1790
+ channelId: channelId || null,
1791
+ userId: origin.userId || null,
1792
+ };
1793
+ if (!base.adapterType || !base.channelId) {
1794
+ const partial = ORIGIN_FIELDS.some((key) => origin[key] != null && origin[key] !== "");
1795
+ return { ...base, owner: !partial };
1796
+ }
1797
+ let speaker = null;
1798
+ try { speaker = require("./relationship").speakerFor(base.adapterType, base.channelId, base.userId); }
1799
+ catch (error) { speaker = null; }
1800
+ if (!speaker) return { ...base, owner: false };
1801
+ return { ...base, owner: speaker.isOwner === true, name: speaker.name || null, room: speaker.room || null };
1802
+ }
1803
+
1774
1804
  function decorateForegroundInvocation(invocation, runContext, bundle) {
1775
1805
  const env = { ...invocation.env };
1776
1806
  delete env.OC_CALL_SPEECH_TOKEN;
@@ -1792,6 +1822,22 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1792
1822
  }
1793
1823
  env.OC_PROVIDER = runContext.provider;
1794
1824
  env.OC_RUN_ID = runContext.runId;
1825
+ // Run attestation: who this run acts for is decided HERE, in the bot
1826
+ // process, from the inbound origin — never from anything the run's own
1827
+ // shell can change. CLIs ask the loopback "who is this?" with
1828
+ // OC_RUN_ID + OC_RUN_ATTEST; the registration ends with the run (cleanup),
1829
+ // so a process that outlives its turn is unattributed.
1830
+ const principal = runPrincipal(runContext);
1831
+ env.OC_RUN_ATTEST = require("./speaker-attest").registerRun(runContext.runId, principal);
1832
+ // A non-owner's turn never sees the AI service credentials: Claude Code
1833
+ // strips its own auth variables from every subprocess it starts (shell,
1834
+ // hooks) when this is set. The agent process itself keeps them.
1835
+ if (principal.owner !== true) {
1836
+ env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
1837
+ // Read by the permission hook (which inherits the agent's env, not the
1838
+ // agent's shell's) so a guard that cannot load fails closed on this run.
1839
+ env.OC_GUARDED_RUN = "1";
1840
+ } else delete env.OC_GUARDED_RUN;
1795
1841
  if (runContext.project?.name) env.OC_PROJECT = String(runContext.project.name);
1796
1842
  if (runContext.project?.dir) env.OC_PROJECT_DIR = String(runContext.project.dir);
1797
1843
  if (runContext.sessionId) env.OC_SESSION_ID = String(runContext.sessionId);
@@ -2036,6 +2082,9 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
2036
2082
  },
2037
2083
  onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
2038
2084
  cleanup: async (proc, _details, runContext) => {
2085
+ // The run is over: its attest stops speaking for anyone, so a process
2086
+ // it left behind is unattributed from here on.
2087
+ try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
2039
2088
  callSpeechLeases.get(runContext.runId)?.close();
2040
2089
  callSpeechLeases.delete(runContext.runId);
2041
2090
  if (state.runningProcess === proc) state.runningProcess = null;
@@ -2422,6 +2471,7 @@ module.exports = {
2422
2471
  streamPreviewAllowed,
2423
2472
  capturedTranscriptMetadata,
2424
2473
  decorateForegroundInvocation,
2474
+ runPrincipal,
2425
2475
  createRunnerService,
2426
2476
  createForegroundService,
2427
2477
  createSideChatService,