@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/CHANGELOG.md +50 -0
- package/bin/agent.js +4 -0
- package/bin/cli.js +10 -1
- package/bin/entity.js +17 -15
- package/bin/ideas.js +10 -1
- package/bin/keyring.js +19 -18
- package/bin/lessons.js +10 -1
- package/bin/loopback-client.js +46 -1
- package/bin/owner-gate.js +38 -0
- package/bin/pack.js +11 -1
- package/bin/schedule.js +45 -11
- package/bin/tool.js +77 -49
- package/bin/transcript-search.js +42 -8
- package/bin/transcript-window.js +31 -2
- package/core/actions.js +15 -4
- package/core/approvals.js +28 -2
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +237 -71
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +47 -2
- package/core/scheduler.js +307 -37
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +380 -11
- package/package.json +2 -2
- package/project-transcripts.js +25 -0
- package/test-approval-async.js +22 -0
- package/test-approval-cards.js +40 -0
- package/test-provider-compaction.js +93 -0
- package/test-provider-language.js +15 -1
- package/test-provider-pack-review.js +25 -2
- package/test-provider-parity-e2e.js +5 -3
- package/test-provider-scheduler.js +504 -3
- package/test-provider-tool-hooks.js +11 -0
- package/test-run-lock.js +2 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
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
|
-
// ──
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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" ?
|
|
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;
|
package/bin/transcript-search.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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" : ""}.`);
|
package/bin/transcript-window.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 = "
|
|
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
|
-
|
|
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,
|