@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/CHANGELOG.md +55 -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 +62 -3
- package/bin/owner-gate.js +39 -0
- package/bin/pack.js +11 -1
- 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/bot-endpoint.js +194 -0
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +224 -69
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +50 -0
- package/core/scheduler.js +100 -2
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +395 -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-language.js +14 -1
- package/test-provider-parity-e2e.js +8 -2
- package/test-provider-scheduler.js +14 -0
- package/test-provider-tool-hooks.js +11 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.1
|
|
4
|
+
|
|
5
|
+
Hardening after an adversarial review of 3.5.0, before any of it reaches the fleet. Canary first.
|
|
6
|
+
|
|
7
|
+
- **A CLI believes only its own bot about who it acts for.** `whoami` and the staff `tool-gate` answer are asked only of a loopback that the CLI process itself or one of its ancestors is listening on, and never of one started inside a staff turn. An address in `OC_SEND_URL` is no longer enough: in 3.5.0 a staff turn could run its own server that answered "owner", and the keyring and the other owner-only verbs believed it. Linux reads `/proc`; macOS asks `/bin/ps` and `/usr/sbin/lsof`. Anything that cannot be checked is unattributed, which is never the owner.
|
|
8
|
+
- **A staff member's watch check gets only the shell essentials.** It runs with an allowlist (PATH, HOME, user, locale, terminal, temp dirs, the config dir), its run stamp and `OC_GUARDED_RUN=1`. 3.5.0 dropped credentials by name pattern, so ones like `DATABASE_URL` or `MONGO_URI` still reached staff checks.
|
|
9
|
+
- **The owner's watch checks get the bot's environment again,** so an owner watch that reads a credential from env works as it did before 3.5.0.
|
|
10
|
+
- **A run with an incomplete chat origin is not the owner.** An origin that names a person or room but not the transport and room it came from counts as a non-owner. A run with no origin at all is still bot-internal work.
|
|
11
|
+
- **The staff shell screen is wider, and it is not a boundary.** A staff turn's shell command may not name process environments or argument blocks, the keyring, the bot's config dir, per-process `/proc` paths, or credential stores. Correction to 3.5.0: the shell does not reliably refuse credential reads. A staff turn that has a shell can still read what the bot's OS user can read. Phase 2 takes the shell away from staff turns.
|
|
12
|
+
- **The owner's routes outside a chat turn** (terminal, ssh, system cron), where 3.5.0 refuses owner-only work:
|
|
13
|
+
- the keyring: `/keyring set`, `/keyring get` and `/keyring remove` in the owner's chat;
|
|
14
|
+
- tool, pack, lessons, entity and ideas edits, and `open-claudia agent`: ask in the owner's chat;
|
|
15
|
+
- a write or destructive tool run: run it from a chat turn, or re-run it with the one-shot `--approval <id>` from a card the owner approved.
|
|
16
|
+
- **Tests:** the provider parity test names the suite that timed out and allows 180 s per suite. A loaded CI runner overran 90 s on canary-v3.5.0's first build.
|
|
17
|
+
|
|
18
|
+
## 3.5.0
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
- **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:
|
|
23
|
+
- a shell that unsets or rewrites its environment;
|
|
24
|
+
- a process that outlives its turn;
|
|
25
|
+
- anything started outside a chat turn.
|
|
26
|
+
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.
|
|
27
|
+
- **Staff turns never see the AI service credentials.** Non-owner runs set `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`.
|
|
28
|
+
- **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).
|
|
29
|
+
- **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.
|
|
30
|
+
- **An owner approval carries exactly the tier approved.** Redeeming any token used to export "destructive", so approving a write unlocked destructive for that run.
|
|
31
|
+
- **Changing tools, notes, the keyring and sub-agents is owner-only:**
|
|
32
|
+
- tool add, set, edit, note, remove and lib;
|
|
33
|
+
- entity, pack, lessons and ideas writes;
|
|
34
|
+
- every keyring verb, including `list`;
|
|
35
|
+
- `open-claudia agent`.
|
|
36
|
+
From a plain terminal these are refused; 3.5.1 lists the owner's routes.
|
|
37
|
+
- **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.
|
|
38
|
+
- **People notes and tasks are owner-only through the bot's commands.**
|
|
39
|
+
- **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.
|
|
40
|
+
- **File reads are screened on staff turns.**
|
|
41
|
+
- Read, Grep, Glob, NotebookRead and LS cannot reach:
|
|
42
|
+
- the bot's config (keyring, transcripts, approvals, people);
|
|
43
|
+
- Claude and Codex credentials;
|
|
44
|
+
- SSH, cloud and kube credentials;
|
|
45
|
+
- other processes' environments.
|
|
46
|
+
- Paths are resolved through symlinks.
|
|
47
|
+
- The shell screens a few common equivalents. This is not a boundary (corrected in 3.5.1).
|
|
48
|
+
- A permission hook that cannot run now refuses on a staff turn instead of letting the tool through.
|
|
49
|
+
- **Destructive is never a standing permission.**
|
|
50
|
+
- "Always" on a destructive card is refused.
|
|
51
|
+
- A destructive grant already on disk counts as act.
|
|
52
|
+
- Grants for anonymous or unverifiable senders (Telegram anonymous admins, channel posts, the bot itself) are refused.
|
|
53
|
+
- When an exact-room grant and an all-rooms grant both match, the stronger one applies.
|
|
54
|
+
- **Releases can be canaried.**
|
|
55
|
+
- A `canary-vX.Y.Z` tag publishes npm under `next` and pushes docker `:X.Y.Z` only.
|
|
56
|
+
- Pushing `vX.Y.Z` on the same commit later promotes that exact build (`latest`, no rebuild).
|
|
57
|
+
|
|
3
58
|
## 3.4.24
|
|
4
59
|
|
|
5
60
|
- 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) {
|
|
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
|
-
|
|
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
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// "owner" set here is still coerced to "external" at runtime
|
|
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
|
-
|
|
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
|
|
25
|
-
// control-plane gate exempts `keyring exec`
|
|
26
|
-
// the
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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();
|
package/bin/loopback-client.js
CHANGED
|
@@ -7,6 +7,7 @@ const fs = require("fs");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const http = require("http");
|
|
9
9
|
const configDir = require(path.join(__dirname, "..", "config-dir"));
|
|
10
|
+
const botEndpoint = require(path.join(__dirname, "..", "core", "bot-endpoint"));
|
|
10
11
|
|
|
11
12
|
function readLoopbackInfo() {
|
|
12
13
|
try {
|
|
@@ -46,6 +47,9 @@ function context() {
|
|
|
46
47
|
// Runner-minted HMAC binding canonicalUserId to what the bot stamped for
|
|
47
48
|
// this turn; the loopback's owner gates verify it (see core/speaker-attest).
|
|
48
49
|
speakerAttest: process.env.OC_SPEAKER_ATTEST || null,
|
|
50
|
+
// Runner-minted attest for THIS run; the bot answers who the run acts
|
|
51
|
+
// for only while the run is live (see core/speaker-attest registerRun).
|
|
52
|
+
runAttest: process.env.OC_RUN_ATTEST || null,
|
|
49
53
|
provider: process.env.OC_PROVIDER || null,
|
|
50
54
|
providerSettings,
|
|
51
55
|
project: process.env.OC_PROJECT || null,
|
|
@@ -68,9 +72,19 @@ function requireContext() {
|
|
|
68
72
|
return ctx;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
// `verified: true` is for answers this process acts on (tool-gate): the
|
|
76
|
+
// request only goes to an endpoint served by this process's own bot (see
|
|
77
|
+
// core/bot-endpoint), never to whatever OC_SEND_URL names.
|
|
78
|
+
function postJson(kind, body, { verified = false } = {}) {
|
|
72
79
|
const ctx = requireContext();
|
|
73
|
-
|
|
80
|
+
let u;
|
|
81
|
+
if (verified) {
|
|
82
|
+
const check = botEndpoint.verifyEndpointCached(ctx.sendUrl);
|
|
83
|
+
if (!check.ok) return Promise.reject(new Error(`the bot's address could not be verified: ${check.reason}`));
|
|
84
|
+
u = new URL(check.url);
|
|
85
|
+
} else {
|
|
86
|
+
u = new URL(ctx.sendUrl);
|
|
87
|
+
}
|
|
74
88
|
const requestedProject = body && body.project ? String(body.project) : ctx.project;
|
|
75
89
|
const sameProject = !ctx.project || !requestedProject || requestedProject === ctx.project;
|
|
76
90
|
const payload = JSON.stringify({
|
|
@@ -81,6 +95,7 @@ function postJson(kind, body) {
|
|
|
81
95
|
userId: ctx.userId,
|
|
82
96
|
canonicalUserId: ctx.canonicalUserId,
|
|
83
97
|
speakerAttest: ctx.speakerAttest,
|
|
98
|
+
runAttest: ctx.runAttest,
|
|
84
99
|
provider: ctx.provider,
|
|
85
100
|
providerSettings: ctx.providerSettings,
|
|
86
101
|
project: requestedProject,
|
|
@@ -127,4 +142,48 @@ function postJson(kind, body) {
|
|
|
127
142
|
});
|
|
128
143
|
}
|
|
129
144
|
|
|
130
|
-
|
|
145
|
+
// Who this process acts for, as the RUNNING BOT decides it. Needs no chat
|
|
146
|
+
// context: a process with no live run behind it (stripped env, detached from
|
|
147
|
+
// a finished turn, started outside the bot) comes back unattributed, and so
|
|
148
|
+
// does every failure to ask. Unattributed is never the owner. The question
|
|
149
|
+
// only goes to an endpoint served by this process's own bot — a server the
|
|
150
|
+
// env points at is not believed (core/bot-endpoint).
|
|
151
|
+
const UNATTRIBUTED = Object.freeze({ attributed: false, owner: false, guarded: true, kind: "unattributed" });
|
|
152
|
+
|
|
153
|
+
function whoami({ timeoutMs = 5000 } = {}) {
|
|
154
|
+
const ctx = context();
|
|
155
|
+
if (!ctx.runId || !ctx.runAttest || !ctx.sendUrl || !ctx.sendToken) return Promise.resolve(UNATTRIBUTED);
|
|
156
|
+
const check = botEndpoint.verifyEndpointCached(ctx.sendUrl);
|
|
157
|
+
if (!check.ok) return Promise.resolve(Object.freeze({ ...UNATTRIBUTED, reason: check.reason }));
|
|
158
|
+
const u = new URL(check.url);
|
|
159
|
+
const payload = JSON.stringify({ runId: ctx.runId, originRunId: ctx.runId, runAttest: ctx.runAttest });
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const req = http.request({
|
|
162
|
+
method: "POST",
|
|
163
|
+
hostname: u.hostname,
|
|
164
|
+
port: u.port,
|
|
165
|
+
path: "/whoami",
|
|
166
|
+
timeout: timeoutMs,
|
|
167
|
+
headers: {
|
|
168
|
+
Authorization: `Bearer ${ctx.sendToken}`,
|
|
169
|
+
"Content-Type": "application/json",
|
|
170
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
171
|
+
},
|
|
172
|
+
}, (res) => {
|
|
173
|
+
let out = "";
|
|
174
|
+
res.on("data", (c) => { out += c; });
|
|
175
|
+
res.on("end", () => {
|
|
176
|
+
try {
|
|
177
|
+
const caller = res.statusCode === 200 ? JSON.parse(out).caller : null;
|
|
178
|
+
resolve(caller && caller.attributed === true ? caller : UNATTRIBUTED);
|
|
179
|
+
} catch (_) { resolve(UNATTRIBUTED); }
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
req.on("timeout", () => { req.destroy(); resolve(UNATTRIBUTED); });
|
|
183
|
+
req.on("error", () => resolve(UNATTRIBUTED));
|
|
184
|
+
req.write(payload);
|
|
185
|
+
req.end();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { context, requireContext, postJson, readLoopbackInfo, whoami, UNATTRIBUTED };
|
|
@@ -0,0 +1,39 @@
|
|
|
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)
|
|
16
|
+
? `this process is not attributed to an owner chat turn${caller && caller.reason ? ` (${caller.reason})` : ""}`
|
|
17
|
+
: caller.name ? `this turn acts for ${caller.name}, not the owner`
|
|
18
|
+
: "this turn does not act for the owner";
|
|
19
|
+
return `⛔ Owner only: ${what} — ${why}.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolves the caller when it is an attributed owner, else prints one refusal
|
|
23
|
+
// line, sets process.exitCode and resolves null. With no run attestation at
|
|
24
|
+
// all the refusal happens synchronously (before the first await), so callers
|
|
25
|
+
// that do not await still see the exit code at once.
|
|
26
|
+
async function requireOwner(what, { exitCode = 3, onRefuse = null } = {}) {
|
|
27
|
+
const refuse = (caller) => {
|
|
28
|
+
if (typeof onRefuse === "function") { try { onRefuse(caller); } catch (_) { /* best-effort */ } }
|
|
29
|
+
console.error(refusal(what, caller));
|
|
30
|
+
process.exitCode = exitCode;
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
if (!hasRunAttest()) return refuse({ attributed: false });
|
|
34
|
+
const caller = await whoami();
|
|
35
|
+
if (caller && caller.attributed === true && caller.owner === true) return caller;
|
|
36
|
+
return refuse(caller);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
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
|
-
|
|
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
|
-
// ──
|
|
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.${caller.reason ? ` (${caller.reason})` : ""}`);
|
|
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
|
+
}, { verified: true });
|
|
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;
|