@inetafrica/open-claudia 3.4.23 → 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/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;
@@ -10,6 +10,8 @@
10
10
 
11
11
  const path = require("path");
12
12
  const index = require(path.join(__dirname, "..", "core", "transcript-index"));
13
+ const { whoami } = require("./loopback-client");
14
+ const { transcriptOwnerOf } = require(path.join(__dirname, "..", "project-transcripts"));
13
15
 
14
16
  function parseArgs(argv) {
15
17
  const out = { query: [], all: false, project: null, limit: 8, raw: false, rebuild: false, json: false, help: false };
@@ -47,7 +49,41 @@ Options:
47
49
  Exit codes: 0 hits, 1 no hits, 2 usage error, 3 index unavailable.`);
48
50
  }
49
51
 
50
- function run(argv) {
52
+ // A non-owner's search must stay in their own room: no cross-room widening
53
+ // (--all / --project / --rebuild) and results scoped to the caller's OWN
54
+ // transcript. The room is decided by the RUNNING BOT (whoami — run-attested),
55
+ // never by the process's own env; and the scoped file is confirmed to belong
56
+ // to that principal, so an env-forged OC_TRANSCRIPT_PATH aimed at someone
57
+ // else's transcript is refused. An attributed owner is unrestricted.
58
+ async function resolveScope(opts) {
59
+ let caller;
60
+ try { caller = await whoami(); } catch (e) { caller = { attributed: false, owner: false }; }
61
+ if (caller && caller.owner === true) {
62
+ const file = (!opts.all && !opts.project) ? (process.env.OC_TRANSCRIPT_PATH || null) : null;
63
+ if (!opts.all && !opts.project && !file) {
64
+ console.error("No current-project transcript (OC_TRANSCRIPT_PATH unset). Use --all, --project <name>, or run inside a bot-spawned task.");
65
+ process.exit(2);
66
+ }
67
+ return { file, project: opts.project, all: opts.all };
68
+ }
69
+ if (opts.all || opts.project || opts.rebuild) {
70
+ console.error("Searching or reindexing other rooms (--all / --project / --rebuild) is owner-only. Your search is limited to this room.");
71
+ process.exit(2);
72
+ }
73
+ const file = process.env.OC_TRANSCRIPT_PATH || null;
74
+ if (!file) {
75
+ console.error("No room transcript to search (OC_TRANSCRIPT_PATH unset).");
76
+ process.exit(2);
77
+ }
78
+ const owner = transcriptOwnerOf(file);
79
+ if (!caller || !caller.attributed || !caller.canonicalUserId || owner !== caller.canonicalUserId) {
80
+ console.error("That transcript is not yours to search — search is limited to your own room.");
81
+ process.exit(2);
82
+ }
83
+ return { file, project: null, all: false };
84
+ }
85
+
86
+ async function run(argv) {
51
87
  const opts = parseArgs(argv);
52
88
  if (opts.help) { printHelp(); process.exit(0); }
53
89
 
@@ -56,6 +92,10 @@ function run(argv) {
56
92
  process.exit(3);
57
93
  }
58
94
 
95
+ // Resolve (and room-restrict) the scope BEFORE any index write: a non-owner
96
+ // may not --rebuild the whole index either.
97
+ const scope = await resolveScope(opts);
98
+
59
99
  if (opts.rebuild) {
60
100
  const r = index.rebuild();
61
101
  console.error(`Rebuilt index: ${r.added} entries from ${r.files} transcript files.`);
@@ -67,13 +107,7 @@ function run(argv) {
67
107
 
68
108
  if (!opts.query) { printHelp(); process.exit(2); }
69
109
 
70
- const file = (!opts.all && !opts.project) ? (process.env.OC_TRANSCRIPT_PATH || null) : null;
71
- if (!opts.all && !opts.project && !file) {
72
- console.error("No current-project transcript (OC_TRANSCRIPT_PATH unset). Use --all, --project <name>, or run inside a bot-spawned task.");
73
- process.exit(2);
74
- }
75
-
76
- const { hits, error } = index.search(opts.query, { project: opts.project, file, limit: opts.limit, raw: opts.raw });
110
+ const { hits, error } = index.search(opts.query, { project: scope.project, file: scope.file, limit: opts.limit, raw: opts.raw });
77
111
  if (error) { console.error(`Query error: ${error}${opts.raw ? "" : " (internal — please report)"}`); process.exit(2); }
78
112
  if (hits.length === 0) {
79
113
  console.error(`No matches for ${JSON.stringify(opts.query)}${opts.all ? " across all projects" : ""}.`);
@@ -10,6 +10,9 @@
10
10
  // 3. error out — we don't try to guess
11
11
 
12
12
  const fs = require("fs");
13
+ const path = require("path");
14
+ const { whoami } = require("./loopback-client");
15
+ const { transcriptOwnerOf } = require(path.join(__dirname, "..", "project-transcripts"));
13
16
 
14
17
  const DEFAULTS = {
15
18
  before: 2,
@@ -78,6 +81,32 @@ function resolveTranscriptPath(flagPath) {
78
81
  return null;
79
82
  }
80
83
 
84
+ // A non-owner may only read their OWN room's transcript: --path (an arbitrary
85
+ // file, e.g. the owner's) is refused, and the default OC_TRANSCRIPT_PATH is
86
+ // confirmed to belong to the caller (whoami — run-attested, not the process's
87
+ // own env) so an env-forged path aimed elsewhere is refused. Returns the
88
+ // transcript path to read, or exits with a refusal. Owner is unrestricted.
89
+ async function resolveScopedTranscriptPath(opts) {
90
+ let caller;
91
+ try { caller = await whoami(); } catch (e) { caller = { attributed: false, owner: false }; }
92
+ if (caller && caller.owner === true) return resolveTranscriptPath(opts.path);
93
+ if (opts.path) {
94
+ console.error("Reading another transcript with --path is owner-only. You can read only your own room's transcript.");
95
+ process.exit(2);
96
+ }
97
+ const file = process.env.OC_TRANSCRIPT_PATH || null;
98
+ if (!file) {
99
+ console.error("No room transcript to read (OC_TRANSCRIPT_PATH unset).");
100
+ process.exit(2);
101
+ }
102
+ const owner = transcriptOwnerOf(file);
103
+ if (!caller || !caller.attributed || !caller.canonicalUserId || owner !== caller.canonicalUserId) {
104
+ console.error("That transcript is not yours to read — you can read only your own room's transcript.");
105
+ process.exit(2);
106
+ }
107
+ return file;
108
+ }
109
+
81
110
  function loadTurns(transcriptPath) {
82
111
  const raw = fs.readFileSync(transcriptPath, "utf-8");
83
112
  const turns = [];
@@ -125,13 +154,13 @@ function formatTurn(turn, label, maxChars) {
125
154
  ].join("\n");
126
155
  }
127
156
 
128
- function run(argv) {
157
+ async function run(argv) {
129
158
  const opts = parseArgs(argv);
130
159
  if (opts.help || !opts.pattern) {
131
160
  printHelp();
132
161
  process.exit(opts.help ? 0 : 2);
133
162
  }
134
- const transcriptPath = resolveTranscriptPath(opts.path);
163
+ const transcriptPath = await resolveScopedTranscriptPath(opts);
135
164
  if (!transcriptPath) {
136
165
  console.error("No transcript path. Pass --path <file>, or run inside an active bot-spawned task (OC_TRANSCRIPT_PATH).");
137
166
  process.exit(2);
package/core/actions.js CHANGED
@@ -741,15 +741,26 @@ async function handleAction(envelope) {
741
741
  const reach = parts[2]; // "room" | "all"
742
742
  if (reach !== "room" && reach !== "all") return;
743
743
  if (!rec) return send("That request expired — nothing to grant from it.");
744
+ // Destructive is never a standing permission: each destructive run gets
745
+ // its own card, however often the owner has approved one before.
746
+ if (String(rec.tier || "").toLowerCase() === "destructive") {
747
+ return send("Destructive actions always ask; no standing permission. Use Approve once for this run.");
748
+ }
744
749
  if (!rec.speakerId || !rec.room) return send("I can't tell who asked apart from where they asked — no grant written.");
750
+ // A grant covers one identifiable person. An anonymous admin, a channel
751
+ // post, a missing sender or the bot itself would hand it to whoever
752
+ // hides behind that id.
753
+ const grantsLib = require("./grants");
754
+ const refusal = grantsLib.principalRefusal({ adapter: rec.targetAdapter || rec.adapter, senderId: rec.speakerId, room: rec.room });
755
+ if (refusal) {
756
+ return send(`No standing permission for ${refusal} — a grant needs one identifiable person. Use Approve once for this request.`);
757
+ }
745
758
  // A reply/triage card only ever cleared talking; an action card carries the
746
759
  // tier it asked for. Never grant more than the card actually showed.
747
- const capability = rec.kind === "action"
748
- ? (String(rec.tier || "") === "destructive" ? "destructive" : "act")
749
- : "read";
760
+ const capability = rec.kind === "action" ? "act" : "read";
750
761
  let record;
751
762
  try {
752
- record = require("./grants").grant({
763
+ record = grantsLib.grant({
753
764
  adapter: rec.targetAdapter || rec.adapter,
754
765
  senderId: rec.speakerId,
755
766
  room: rec.room,
package/core/approvals.js CHANGED
@@ -112,7 +112,7 @@ function prune() {
112
112
  // speaker), the vetted `payloadText` to deliver on a reply approval, the
113
113
  // `target*` external channel, and person/entity pointers for Always-allow.
114
114
  function create({
115
- tool, verb = "", tier = "destructive", command = "", channelId = "", adapter = "",
115
+ tool, verb = "", tier = "", command = "", channelId = "", adapter = "",
116
116
  canonicalUserId = "", userId = "", provider = "", providerSettings = {},
117
117
  project = "", projectDir = "", sessionId = "", previousSessionId = "", originRunId = "",
118
118
  kind = "tool", approverAdapter = "", approverChannel = "",
@@ -205,10 +205,30 @@ function detach(id) {
205
205
  } finally { releaseRecordLock(held); }
206
206
  }
207
207
 
208
+ // Risk tiers an approval can carry for a tool run. A record's `tier` is what
209
+ // the owner saw on the card; reply/triage records carry non-tool tiers.
210
+ const TIER_RANK = { "read-only": 0, write: 1, destructive: 2 };
211
+
212
+ function riskTier(value) {
213
+ const tier = String(value || "").trim().toLowerCase();
214
+ return Object.prototype.hasOwnProperty.call(TIER_RANK, tier) ? tier : "";
215
+ }
216
+
217
+ // The tier a redeemed approval authorizes: exactly the tier on the record the
218
+ // owner approved. A record without one (created before tiers were recorded)
219
+ // falls back to the tier the tool/verb declares for this run — never to
220
+ // "destructive" by default.
221
+ function approvedTier(record, runTier) {
222
+ return riskTier(record && record.tier) || riskTier(runTier) || "read-only";
223
+ }
224
+
208
225
  // One-shot token redemption for the async path. Fail-closed on every branch:
209
226
  // only an approved, un-consumed, un-expired record whose stored command is
210
227
  // byte-identical to what is about to run can be consumed — and only once.
211
- function consume(id, commandLine) {
228
+ // With `tier` (the run's resolved tier), a run that now resolves above the tier
229
+ // the owner approved (the verb was reclassified since) is refused WITHOUT
230
+ // consuming the token: an approval never stretches past what the card showed.
231
+ function consume(id, commandLine, { tier = "" } = {}) {
212
232
  try { fileOf(id); }
213
233
  catch (_) { return { ok: false, reason: "unknown or expired approval id" }; }
214
234
  const held = acquireRecordLock(id);
@@ -231,6 +251,11 @@ function consume(id, commandLine) {
231
251
  : Buffer.byteLength(String(rec.command || "")) <= MAX_COMMAND_BYTES
232
252
  && candidate === String(rec.command || "");
233
253
  if (!matches) return { ok: false, reason: "command does not match the approved payload" };
254
+ const approved = riskTier(rec.tier);
255
+ const wanted = riskTier(tier);
256
+ if (approved && wanted && TIER_RANK[wanted] > TIER_RANK[approved]) {
257
+ return { ok: false, reason: `approval covers a ${approved} run, but this run is ${wanted}` };
258
+ }
234
259
  rec.consumedAt = new Date().toISOString();
235
260
  writeRecord(rec);
236
261
  return { ok: true, record: rec };
@@ -277,6 +302,7 @@ function markMined(ids) {
277
302
  module.exports = {
278
303
  APPROVALS_DIR,
279
304
  MAX_COMMAND_BYTES,
305
+ approvedTier,
280
306
  commandHash,
281
307
  consume,
282
308
  create,