@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/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,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Which loopback endpoint may a CLI believe about WHO it acts for?
|
|
2
|
+
//
|
|
3
|
+
// Owner-only CLI verbs and staff tool writes are decided by asking the bot
|
|
4
|
+
// (whoami, tool-gate). The address to ask comes from this process's env
|
|
5
|
+
// (OC_SEND_URL), and a staff turn's shell can point that at a server of its
|
|
6
|
+
// own that answers "owner" (Phase 0 review, CRITICAL). So an endpoint is
|
|
7
|
+
// believed for those answers only when:
|
|
8
|
+
// 1. the process LISTENING on it is this process or one of its ancestors —
|
|
9
|
+
// the bot spawns every run, so the real bot is always above the CLI,
|
|
10
|
+
// while a server a turn starts is beside it at best; and
|
|
11
|
+
// 2. nothing above that listener was STARTED with OC_GUARDED_RUN=1 in its
|
|
12
|
+
// environment — the listener was not itself started inside a staff turn
|
|
13
|
+
// (such a server could run the CLI as its own child, making itself an
|
|
14
|
+
// ancestor). A process's start-up environment is fixed by whoever spawned
|
|
15
|
+
// it; a turn's shell cannot rewrite its provider's.
|
|
16
|
+
// Linux reads /proc; macOS asks /bin/ps and /usr/sbin/lsof by absolute path
|
|
17
|
+
// (PATH belongs to the caller). Anything that cannot be established is a
|
|
18
|
+
// refusal, and a refused caller is unattributed — never the owner.
|
|
19
|
+
//
|
|
20
|
+
// Residual, closed by Phase 2's tool-only staff turns: a staff shell that
|
|
21
|
+
// detaches a server from its turn and runs the CLI beneath that server is no
|
|
22
|
+
// longer below the guarded provider, so rule 2 cannot see it.
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const { execFileSync } = require("child_process");
|
|
26
|
+
|
|
27
|
+
const MAX_DEPTH = 64;
|
|
28
|
+
const PS = "/bin/ps";
|
|
29
|
+
const LSOF = "/usr/sbin/lsof";
|
|
30
|
+
const EXEC_TIMEOUT_MS = 5000;
|
|
31
|
+
|
|
32
|
+
function parsePort(url) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try { parsed = new URL(String(url)); } catch (_) { return null; }
|
|
35
|
+
if (parsed.protocol !== "http:") return null;
|
|
36
|
+
if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") return null;
|
|
37
|
+
const port = Number(parsed.port);
|
|
38
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Linux: /proc ─────────────────────────────────────────────────────────────
|
|
42
|
+
// The loopback binds 127.0.0.1 itself, never a wildcard, and nothing else can
|
|
43
|
+
// bind that exact address and port while it listens (no SO_REUSEPORT), so the
|
|
44
|
+
// only listener that counts is an IPv4 127.0.0.1 socket. /proc/net/tcp shows
|
|
45
|
+
// the address as little-endian hex.
|
|
46
|
+
const LINUX_LOOPBACK_HEX = "0100007F";
|
|
47
|
+
|
|
48
|
+
function linuxSystem(procRoot = "/proc") {
|
|
49
|
+
return {
|
|
50
|
+
parentOf(pid) {
|
|
51
|
+
const stat = fs.readFileSync(`${procRoot}/${pid}/stat`, "utf8");
|
|
52
|
+
// "pid (comm) state ppid ..." — comm may hold spaces and parentheses.
|
|
53
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
54
|
+
const ppid = Number(fields[1]);
|
|
55
|
+
return Number.isInteger(ppid) ? ppid : null;
|
|
56
|
+
},
|
|
57
|
+
// The pid in `pids` that owns the 127.0.0.1:port listening socket, else null.
|
|
58
|
+
listenerAmong(port, pids) {
|
|
59
|
+
const inodes = new Set();
|
|
60
|
+
const text = fs.readFileSync(`${procRoot}/net/tcp`, "utf8");
|
|
61
|
+
for (const line of text.split("\n").slice(1)) {
|
|
62
|
+
const cols = line.trim().split(/\s+/);
|
|
63
|
+
if (cols.length < 10 || cols[3] !== "0A") continue; // 0A = LISTEN
|
|
64
|
+
const [addr, portHex] = cols[1].split(":");
|
|
65
|
+
if (addr === LINUX_LOOPBACK_HEX && parseInt(portHex, 16) === port) inodes.add(cols[9]);
|
|
66
|
+
}
|
|
67
|
+
if (!inodes.size) return null;
|
|
68
|
+
for (const pid of pids) {
|
|
69
|
+
let fds = [];
|
|
70
|
+
try { fds = fs.readdirSync(`${procRoot}/${pid}/fd`); } catch (_) { continue; }
|
|
71
|
+
for (const fd of fds) {
|
|
72
|
+
let link = "";
|
|
73
|
+
try { link = fs.readlinkSync(`${procRoot}/${pid}/fd/${fd}`); } catch (_) { continue; }
|
|
74
|
+
const match = /^socket:\[(\d+)\]$/.exec(link);
|
|
75
|
+
if (match && inodes.has(match[1])) return pid;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
startEnvHas(pid, key, value) {
|
|
81
|
+
const raw = fs.readFileSync(`${procRoot}/${pid}/environ`, "utf8");
|
|
82
|
+
return raw.split("\0").includes(`${key}=${value}`);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── macOS: /bin/ps + /usr/sbin/lsof ─────────────────────────────────────────
|
|
88
|
+
function run(file, args) {
|
|
89
|
+
return execFileSync(file, args, {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
92
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
93
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
94
|
+
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "C", LC_ALL: "C" },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function darwinSystem() {
|
|
99
|
+
let parents = null;
|
|
100
|
+
return {
|
|
101
|
+
parentOf(pid) {
|
|
102
|
+
if (!parents) {
|
|
103
|
+
parents = new Map();
|
|
104
|
+
for (const line of run(PS, ["-Ao", "pid=,ppid="]).split("\n")) {
|
|
105
|
+
const [p, pp] = line.trim().split(/\s+/).map(Number);
|
|
106
|
+
if (Number.isInteger(p) && Number.isInteger(pp)) parents.set(p, pp);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return parents.has(pid) ? parents.get(pid) : null;
|
|
110
|
+
},
|
|
111
|
+
listenerAmong(port, pids) {
|
|
112
|
+
let out = "";
|
|
113
|
+
try { out = run(LSOF, ["-nP", "-a", "-p", pids.join(","), `-iTCP@127.0.0.1:${port}`, "-sTCP:LISTEN", "-Fpn"]); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Exit 1 whenever some listed pid matched nothing (or is another
|
|
116
|
+
// user's); what did match is still on stdout.
|
|
117
|
+
if (!error || error.status !== 1) throw error;
|
|
118
|
+
out = String(error.stdout || "");
|
|
119
|
+
}
|
|
120
|
+
const owners = new Set();
|
|
121
|
+
let current = null;
|
|
122
|
+
for (const line of out.split("\n")) {
|
|
123
|
+
if (line.startsWith("p")) current = Number(line.slice(1));
|
|
124
|
+
else if (line === `n127.0.0.1:${port}` && current != null) owners.add(current);
|
|
125
|
+
}
|
|
126
|
+
for (const pid of pids) if (owners.has(pid)) return pid;
|
|
127
|
+
return null;
|
|
128
|
+
},
|
|
129
|
+
// ps -E prints the command line followed by the environment the process
|
|
130
|
+
// started with; the environment is what follows the plain command line.
|
|
131
|
+
startEnvHas(pid, key, value) {
|
|
132
|
+
const withEnv = run(PS, ["-E", "-ww", "-o", "command=", "-p", String(pid)]).replace(/\n+$/, "");
|
|
133
|
+
const bare = run(PS, ["-ww", "-o", "command=", "-p", String(pid)]).replace(/\n+$/, "");
|
|
134
|
+
if (!withEnv.startsWith(bare)) return false;
|
|
135
|
+
return withEnv.slice(bare.length).split(" ").includes(`${key}=${value}`);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function currentSystem() {
|
|
141
|
+
if (process.platform === "linux") return linuxSystem();
|
|
142
|
+
if (process.platform === "darwin") return darwinSystem();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ancestry(pid, system) {
|
|
147
|
+
const chain = [pid];
|
|
148
|
+
let current = pid;
|
|
149
|
+
while (chain.length < MAX_DEPTH) {
|
|
150
|
+
const parent = system.parentOf(current);
|
|
151
|
+
if (!Number.isInteger(parent) || parent <= 0 || chain.includes(parent)) break;
|
|
152
|
+
chain.push(parent);
|
|
153
|
+
current = parent;
|
|
154
|
+
}
|
|
155
|
+
return chain;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function refuse(reason) {
|
|
159
|
+
return { ok: false, reason };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// { ok: true, url, port, listenerPid } when `url` may be believed about who
|
|
163
|
+
// this process acts for; { ok: false, reason } otherwise.
|
|
164
|
+
function verifyEndpoint(url, { pid = process.pid, system = currentSystem() } = {}) {
|
|
165
|
+
const port = parsePort(url);
|
|
166
|
+
if (!port) return refuse("the loopback address is not a 127.0.0.1 endpoint");
|
|
167
|
+
if (!system) return refuse(`cannot inspect processes on ${process.platform}`);
|
|
168
|
+
let chain;
|
|
169
|
+
let listener;
|
|
170
|
+
try {
|
|
171
|
+
chain = ancestry(pid, system);
|
|
172
|
+
listener = system.listenerAmong(port, chain);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return refuse(`cannot read the process tree (${error && error.message ? error.message : error})`);
|
|
175
|
+
}
|
|
176
|
+
if (listener == null) return refuse("the loopback endpoint is not served by this process or an ancestor");
|
|
177
|
+
for (const above of chain.slice(chain.indexOf(listener) + 1)) {
|
|
178
|
+
let guarded = false;
|
|
179
|
+
// Unreadable = another user's process, never a guarded provider (the bot
|
|
180
|
+
// starts providers as its own user).
|
|
181
|
+
try { guarded = system.startEnvHas(above, "OC_GUARDED_RUN", "1") === true; } catch (_) { guarded = false; }
|
|
182
|
+
if (guarded) return refuse("the loopback endpoint was started inside a guarded (non-owner) run");
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, url: `http://127.0.0.1:${port}`, port, listenerPid: listener };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const verified = new Map();
|
|
188
|
+
function verifyEndpointCached(url) {
|
|
189
|
+
const key = String(url || "");
|
|
190
|
+
if (!verified.has(key)) verified.set(key, verifyEndpoint(url));
|
|
191
|
+
return verified.get(key);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { verifyEndpoint, verifyEndpointCached, parsePort, linuxSystem, darwinSystem, ancestry };
|
package/core/grants.js
CHANGED
|
@@ -25,6 +25,13 @@ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
|
25
25
|
// maps a proposed action onto the same ladder and refuses anything above.
|
|
26
26
|
const CAPABILITIES = ["read", "act", "destructive"];
|
|
27
27
|
|
|
28
|
+
// Destructive is never a standing permission: every destructive run gets its
|
|
29
|
+
// own owner card. grant() refuses it, and a legacy `destructive` record still
|
|
30
|
+
// on disk counts as `act` — the owner plainly trusted that person to act, but
|
|
31
|
+
// a destructive run keeps asking. The file itself is left untouched.
|
|
32
|
+
const STANDING_CEILING = "act";
|
|
33
|
+
const DESTRUCTIVE_REFUSAL = "Destructive actions always ask; no standing permission.";
|
|
34
|
+
|
|
28
35
|
const ALL_ROOMS = "*";
|
|
29
36
|
|
|
30
37
|
function nowIso() { return new Date().toISOString(); }
|
|
@@ -39,6 +46,78 @@ function normalizeCapability(capability) {
|
|
|
39
46
|
return CAPABILITIES.includes(c) ? c : "read";
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
function grantError(code, message) {
|
|
50
|
+
const error = new Error(message);
|
|
51
|
+
error.code = code;
|
|
52
|
+
return error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Logged once per record per process, so a busy room doesn't flood the audit.
|
|
56
|
+
const noted = new Set();
|
|
57
|
+
function noteOnce(kind, g, extra = {}) {
|
|
58
|
+
const key = `${kind}:${g && g.id}`;
|
|
59
|
+
if (noted.has(key)) return;
|
|
60
|
+
noted.add(key);
|
|
61
|
+
try {
|
|
62
|
+
require("./audit").log(kind, {
|
|
63
|
+
id: g && g.id,
|
|
64
|
+
principal: principalKey(g && g.principal && g.principal.adapter, g && g.principal && g.principal.channelId),
|
|
65
|
+
room: g && g.room,
|
|
66
|
+
...extra,
|
|
67
|
+
});
|
|
68
|
+
} catch (e) { /* best effort */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The capability a stored grant actually confers.
|
|
72
|
+
function effectiveCapability(g) {
|
|
73
|
+
const stored = normalizeCapability(g && g.capability);
|
|
74
|
+
if (capabilityRank(stored) <= capabilityRank(STANDING_CEILING)) return stored;
|
|
75
|
+
noteOnce("grant.destructive-downgraded", g, { effective: STANDING_CEILING });
|
|
76
|
+
return STANDING_CEILING;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Senders that are not one identifiable person. A grant to any of them would
|
|
80
|
+
// cover whoever hides behind them, so they are refused. Telegram gives
|
|
81
|
+
// messages sent on behalf of a chat (Bot API `sender_chat`) a fake `from`,
|
|
82
|
+
// and each of these ids is shared by EVERY such sender in every group.
|
|
83
|
+
const TELEGRAM_STAND_IN_SENDERS = new Map([
|
|
84
|
+
["1087968824", "an anonymous group admin"], // @GroupAnonymousBot
|
|
85
|
+
["136817688", "someone posting as a channel"], // @Channel_Bot
|
|
86
|
+
["777000", "a channel post forwarded by Telegram"], // Telegram service account
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// The bot's own ids, from the operator's config (never the token itself).
|
|
90
|
+
function botSelfIds(adapter) {
|
|
91
|
+
let cfg = {};
|
|
92
|
+
try { cfg = require("./config").config || {}; } catch (e) { return []; }
|
|
93
|
+
const ids = [];
|
|
94
|
+
if (adapter === "telegram") ids.push(String(cfg.TELEGRAM_BOT_TOKEN || "").split(":")[0].trim());
|
|
95
|
+
if (adapter === "kazee" || adapter === "spaces") ids.push(String(cfg.KAZEE_BOT_USER_ID || "").trim());
|
|
96
|
+
return ids.filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// null when (adapter, sender) is one verifiable person; otherwise who it is
|
|
100
|
+
// instead, for the refusal message. `room` is the `<surface>:<id>` key.
|
|
101
|
+
function principalRefusal({ adapter, senderId, room = "" } = {}) {
|
|
102
|
+
const a = String(adapter || "").trim().toLowerCase();
|
|
103
|
+
const s = String(senderId || "").trim();
|
|
104
|
+
if (!a || !s) return "a sender with no id";
|
|
105
|
+
if (botSelfIds(a).includes(s)) return "the bot itself";
|
|
106
|
+
if (a === "telegram") {
|
|
107
|
+
if (TELEGRAM_STAND_IN_SENDERS.has(s)) return TELEGRAM_STAND_IN_SENDERS.get(s);
|
|
108
|
+
// Telegram people have positive numeric ids; groups and channels negative.
|
|
109
|
+
if (!/^\d+$/.test(s)) return "a group or channel rather than a person";
|
|
110
|
+
}
|
|
111
|
+
if (a === "spaces" && s.startsWith("unknown-actor:")) return "an unidentified Spaces actor";
|
|
112
|
+
// An adapter that lost the sender falls back to the room id (Kazee does);
|
|
113
|
+
// only a Telegram DM legitimately has sender id == chat id.
|
|
114
|
+
const r = String(room || "");
|
|
115
|
+
if (a !== "telegram" && r.startsWith(`${a}:`) && r.slice(a.length + 1) === s) {
|
|
116
|
+
return "the room itself (no sender id)";
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
42
121
|
// Rooms are keyed `<surface>:<id>`, matching core/identity.conversationKeyFor
|
|
43
122
|
// so a caller can hand us a conversation key directly.
|
|
44
123
|
function roomKey(surface, roomId) {
|
|
@@ -72,48 +151,87 @@ function isLive(grant, at = Date.now()) {
|
|
|
72
151
|
return true;
|
|
73
152
|
}
|
|
74
153
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
function resolve({ adapter, senderId, room }) {
|
|
154
|
+
// Every live grant for this principal that reaches this room. Records for a
|
|
155
|
+
// principal that could not be granted today (anonymous, the bot itself) are
|
|
156
|
+
// ignored rather than honoured.
|
|
157
|
+
function matchingGrants({ adapter, senderId, room }) {
|
|
80
158
|
const principal = principalKey(adapter, senderId);
|
|
159
|
+
if (!principal) return [];
|
|
81
160
|
const target = String(room || "");
|
|
82
|
-
if (!principal) return null;
|
|
83
161
|
const at = Date.now();
|
|
84
|
-
|
|
85
|
-
isLive(g, at)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const exact = (b.room === target) - (a.room === target);
|
|
92
|
-
if (exact) return exact;
|
|
93
|
-
return capabilityRank(b.capability) - capabilityRank(a.capability);
|
|
162
|
+
return load().filter((g) => {
|
|
163
|
+
if (!isLive(g, at)) return false;
|
|
164
|
+
if (principalKey(g.principal?.adapter, g.principal?.channelId) !== principal) return false;
|
|
165
|
+
if (g.room !== target && g.room !== ALL_ROOMS) return false;
|
|
166
|
+
const refusal = principalRefusal({ adapter: g.principal?.adapter, senderId: g.principal?.channelId, room: g.room });
|
|
167
|
+
if (refusal) { noteOnce("grant.unverifiable-ignored", g, { refusal }); return false; }
|
|
168
|
+
return true;
|
|
94
169
|
});
|
|
95
|
-
return candidates[0];
|
|
96
170
|
}
|
|
97
171
|
|
|
98
|
-
//
|
|
172
|
+
// The record as it applies now: a legacy destructive grant reads as `act`.
|
|
173
|
+
function asEffective(g) {
|
|
174
|
+
const effective = effectiveCapability(g);
|
|
175
|
+
return effective === normalizeCapability(g.capability) ? g : { ...g, capability: effective, storedCapability: g.capability };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Among grants that all answer the question: a standing grant before a
|
|
179
|
+
// turn-limited one (never burn a "just this once" while a standing grant
|
|
180
|
+
// covers it), then the exact room before all-rooms (the audit names the most
|
|
181
|
+
// specific record), then the newest.
|
|
182
|
+
function preference(target) {
|
|
183
|
+
return (a, b) => ((a.turnsRemaining != null) - (b.turnsRemaining != null))
|
|
184
|
+
|| ((b.room === target) - (a.room === target))
|
|
185
|
+
|| String(b.grantedAt || "").localeCompare(String(a.grantedAt || ""));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function strongestFirst(target) {
|
|
189
|
+
const prefer = preference(target);
|
|
190
|
+
return (a, b) => (capabilityRank(effectiveCapability(b)) - capabilityRank(effectiveCapability(a))) || prefer(a, b);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The deterministic lookup that replaces the LLM as the gate. Grants add up:
|
|
194
|
+
// the effective grant is the STRONGEST live match, so an exact-room grant
|
|
195
|
+
// never weakens an all-rooms one or the other way round. To take access
|
|
196
|
+
// away, revoke; a narrower grant does not.
|
|
197
|
+
function resolve({ adapter, senderId, room }) {
|
|
198
|
+
const candidates = matchingGrants({ adapter, senderId, room });
|
|
199
|
+
if (!candidates.length) return null;
|
|
200
|
+
candidates.sort(strongestFirst(String(room || "")));
|
|
201
|
+
return asEffective(candidates[0]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// True when this principal may take an action at `needed` strength in `room`:
|
|
205
|
+
// some matching grant is strong enough and its scope covers the subject.
|
|
206
|
+
// Destructive never passes — no standing grant confers it.
|
|
99
207
|
// Owners never reach here — they are resolved before any grant lookup.
|
|
100
208
|
function allows({ adapter, senderId, room, needed = "read", subject = null }) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
209
|
+
const target = String(room || "");
|
|
210
|
+
const candidates = matchingGrants({ adapter, senderId, room });
|
|
211
|
+
if (!candidates.length) return { ok: false, reason: "no_grant", grant: null };
|
|
212
|
+
const strong = candidates.filter((g) => capabilityRank(effectiveCapability(g)) >= capabilityRank(needed));
|
|
213
|
+
if (!strong.length) {
|
|
214
|
+
candidates.sort(strongestFirst(target));
|
|
215
|
+
return { ok: false, reason: "capability_too_low", grant: asEffective(candidates[0]) };
|
|
105
216
|
}
|
|
106
|
-
const scope = Array.isArray(grant.scope) ? grant.scope : [];
|
|
107
217
|
// An empty scope means "anything within this capability" — a scoped grant
|
|
108
218
|
// only covers the subjects it names.
|
|
109
|
-
|
|
110
|
-
|
|
219
|
+
const covers = (g) => {
|
|
220
|
+
const scope = Array.isArray(g.scope) ? g.scope : [];
|
|
221
|
+
return !scope.length || !subject || scope.includes(String(subject));
|
|
222
|
+
};
|
|
223
|
+
const covering = strong.filter(covers);
|
|
224
|
+
if (!covering.length) {
|
|
225
|
+
strong.sort(preference(target));
|
|
226
|
+
return { ok: false, reason: "out_of_scope", grant: asEffective(strong[0]) };
|
|
111
227
|
}
|
|
112
|
-
|
|
228
|
+
covering.sort(preference(target));
|
|
229
|
+
return { ok: true, reason: "granted", grant: asEffective(covering[0]) };
|
|
113
230
|
}
|
|
114
231
|
|
|
115
232
|
// Only an owner may call this — enforced by the caller, which holds the turn
|
|
116
|
-
// context needed to know who is asking.
|
|
233
|
+
// context needed to know who is asking. Refuses destructive (never standing)
|
|
234
|
+
// and any principal that is not one identifiable person.
|
|
117
235
|
function grant({
|
|
118
236
|
adapter,
|
|
119
237
|
senderId,
|
|
@@ -130,6 +248,13 @@ function grant({
|
|
|
130
248
|
if (!principal) throw new Error("adapter and senderId are required");
|
|
131
249
|
const target = allRooms ? ALL_ROOMS : String(room || "");
|
|
132
250
|
if (!target) throw new Error("room is required unless allRooms is set");
|
|
251
|
+
if (capabilityRank(normalizeCapability(capability)) > capabilityRank(STANDING_CEILING)) {
|
|
252
|
+
throw grantError("GRANT_DESTRUCTIVE_REFUSED", DESTRUCTIVE_REFUSAL);
|
|
253
|
+
}
|
|
254
|
+
const refusal = principalRefusal({ adapter, senderId, room });
|
|
255
|
+
if (refusal) {
|
|
256
|
+
throw grantError("GRANT_PRINCIPAL_UNVERIFIABLE", `No standing permission for ${refusal} — a grant needs one identifiable person.`);
|
|
257
|
+
}
|
|
133
258
|
const grants = load();
|
|
134
259
|
// Re-granting the same pair replaces rather than stacks, so revoking one
|
|
135
260
|
// record can never leave a weaker duplicate behind still granting access.
|
|
@@ -203,12 +328,16 @@ function listForRoom(room, { includeExpired = false } = {}) {
|
|
|
203
328
|
|
|
204
329
|
module.exports = {
|
|
205
330
|
CAPABILITIES,
|
|
331
|
+
STANDING_CEILING,
|
|
332
|
+
DESTRUCTIVE_REFUSAL,
|
|
206
333
|
ALL_ROOMS,
|
|
207
334
|
GRANTS_FILE,
|
|
208
335
|
roomKey,
|
|
209
336
|
principalKey,
|
|
337
|
+
principalRefusal,
|
|
210
338
|
capabilityRank,
|
|
211
339
|
normalizeCapability,
|
|
340
|
+
effectiveCapability,
|
|
212
341
|
isLive,
|
|
213
342
|
resolve,
|
|
214
343
|
allows,
|