@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/core/scheduler.js
CHANGED
|
@@ -73,6 +73,74 @@ function uniqueId(prefix = "occ") {
|
|
|
73
73
|
: `${prefix}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// ── watch-check environment ────────────────────────────────────────────────
|
|
77
|
+
// A watch's `check` runs a shell command in the bot's process tree. Whose
|
|
78
|
+
// environment it gets depends on who set the watch:
|
|
79
|
+
// • a staff member's check gets an ALLOWLIST — the shell essentials and the
|
|
80
|
+
// Open Claudia config dir, nothing else — plus OC_GUARDED_RUN=1 so hooks
|
|
81
|
+
// and CLIs below it know they run for a non-owner. A denylist was not
|
|
82
|
+
// enough: any credential whose name matched no pattern (DATABASE_URL,
|
|
83
|
+
// MONGO_URI, …) reached a staff check (Phase 0 review, MEDIUM);
|
|
84
|
+
// • the owner's check keeps the bot's environment as it always had, so an
|
|
85
|
+
// owner watch that reads a credential from env keeps working.
|
|
86
|
+
// Either way a short-lived, creator-scoped run is stamped on top (below) so
|
|
87
|
+
// the check's reusable tools attribute to whoever created the watch.
|
|
88
|
+
const WATCH_CHECK_ENV_KEEP = new Set([
|
|
89
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL",
|
|
90
|
+
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
|
|
91
|
+
"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR",
|
|
92
|
+
"TMPDIR", "TMP", "TEMP",
|
|
93
|
+
"OPEN_CLAUDIA_CONFIG_DIR",
|
|
94
|
+
"SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
|
|
95
|
+
]);
|
|
96
|
+
function buildWatchCheckEnv(source, stamp = {}, { owner = false } = {}) {
|
|
97
|
+
const env = {};
|
|
98
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
99
|
+
if (value == null) continue;
|
|
100
|
+
if (!owner && !WATCH_CHECK_ENV_KEEP.has(key)) continue;
|
|
101
|
+
env[key] = String(value);
|
|
102
|
+
}
|
|
103
|
+
for (const [key, value] of Object.entries(stamp)) {
|
|
104
|
+
if (value == null || value === "") continue;
|
|
105
|
+
env[key] = String(value);
|
|
106
|
+
}
|
|
107
|
+
if (owner) delete env.OC_GUARDED_RUN;
|
|
108
|
+
else env.OC_GUARDED_RUN = "1";
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The principal a watch-check's short-lived run acts for: the job's creator,
|
|
113
|
+
// resolved from its stored channel/user the same way runPrincipal resolves a
|
|
114
|
+
// turn's speaker. Owner → the check attributes as the owner (unchanged); a
|
|
115
|
+
// staff creator → the check runs at their level. Fail closed to non-owner.
|
|
116
|
+
function watchCheckPrincipal(job) {
|
|
117
|
+
const adapterType = job && job.adapterType ? String(job.adapterType) : null;
|
|
118
|
+
const channelId = job && job.channelId != null ? String(job.channelId) : null;
|
|
119
|
+
const userId = job && job.userId != null ? String(job.userId) : null;
|
|
120
|
+
let owner = false;
|
|
121
|
+
let name = null;
|
|
122
|
+
let room = null;
|
|
123
|
+
if (adapterType && channelId) {
|
|
124
|
+
try {
|
|
125
|
+
const sp = require("./relationship").speakerFor(adapterType, channelId, userId);
|
|
126
|
+
owner = !!(sp && sp.isOwner === true);
|
|
127
|
+
name = (sp && sp.name) || null;
|
|
128
|
+
room = (sp && sp.room) || null;
|
|
129
|
+
} catch (_) { owner = false; }
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
kind: "watch-check",
|
|
133
|
+
owner,
|
|
134
|
+
canonicalUserId: job && job.canonicalUserId ? String(job.canonicalUserId) : null,
|
|
135
|
+
name,
|
|
136
|
+
adapterId: job && job.adapter ? String(job.adapter) : null,
|
|
137
|
+
adapterType,
|
|
138
|
+
channelId,
|
|
139
|
+
userId,
|
|
140
|
+
room,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
76
144
|
function parseFallbacks(value) {
|
|
77
145
|
if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
|
|
78
146
|
return [...new Set(String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
@@ -155,23 +223,50 @@ function createSchedulerService(dependencies = {}) {
|
|
|
155
223
|
const timers = new Map();
|
|
156
224
|
const checksInFlight = new Set();
|
|
157
225
|
const firesInFlight = new Set();
|
|
226
|
+
// Jobs already on disk before the creator-binding fix carry no `createdBy`.
|
|
227
|
+
// We keep running them AS THEY ARE (their stored channel/user is what the
|
|
228
|
+
// fire path resolves the speaker from — the owner's own watches and wakeups
|
|
229
|
+
// must not silently break), but log the first time each fires so an audit
|
|
230
|
+
// can spot a pre-fix job that outranks a non-owner. Logged once per id/boot.
|
|
231
|
+
const loggedLegacyCreators = new Set();
|
|
232
|
+
function noteLegacyCreatorOnce(job) {
|
|
233
|
+
if (!job || job.createdBy || loggedLegacyCreators.has(job.id)) return;
|
|
234
|
+
loggedLegacyCreators.add(job.id);
|
|
235
|
+
try {
|
|
236
|
+
console.warn(`[scheduler] job ${job.id} (${job.kind}${job.check ? "/watch" : ""}) predates creator binding — running with its stored identity ${job.canonicalUserId || "?"} in ${job.adapterType || "?"}:${job.channelId || "?"}`);
|
|
237
|
+
} catch (_) { /* logging is best-effort */ }
|
|
238
|
+
}
|
|
158
239
|
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
159
240
|
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
160
|
-
// dir with
|
|
241
|
+
// dir with its creator's env (buildWatchCheckEnv: an allowlist for staff)
|
|
242
|
+
// and a short-lived run is registered for the job's creator so reusable
|
|
243
|
+
// tools attribute correctly — output discarded (exit code is the contract),
|
|
161
244
|
// hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
|
|
162
245
|
const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
|
|
163
246
|
const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
|
|
247
|
+
const attest = require("./speaker-attest");
|
|
248
|
+
const checkRunId = uniqueId("watch-check");
|
|
249
|
+
const principal = watchCheckPrincipal(job);
|
|
250
|
+
let runAttest = "";
|
|
251
|
+
try { runAttest = attest.registerRun(checkRunId, principal); } catch (_) { runAttest = ""; }
|
|
252
|
+
let lb = null;
|
|
253
|
+
try { lb = require("./loopback").info(); } catch (_) { lb = null; }
|
|
254
|
+
const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
|
|
255
|
+
if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
|
|
256
|
+
const env = buildWatchCheckEnv(process.env, stamp, { owner: principal.owner === true });
|
|
164
257
|
let settled = false;
|
|
165
258
|
const finish = (code, timedOut) => {
|
|
166
259
|
if (settled) return;
|
|
167
260
|
settled = true;
|
|
168
261
|
try { clearTimer(timer); } catch (_) {}
|
|
262
|
+
try { attest.endRun(checkRunId); } catch (_) {}
|
|
169
263
|
resolve({ code, timedOut: !!timedOut });
|
|
170
264
|
};
|
|
171
265
|
let child;
|
|
172
266
|
try {
|
|
173
|
-
child = spawn("sh", ["-c", String(command)], { cwd, env
|
|
267
|
+
child = spawn("sh", ["-c", String(command)], { cwd, env, stdio: "ignore" });
|
|
174
268
|
} catch (error) {
|
|
269
|
+
try { attest.endRun(checkRunId); } catch (_) {}
|
|
175
270
|
return resolve({ code: 127, timedOut: false });
|
|
176
271
|
}
|
|
177
272
|
const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
|
|
@@ -624,6 +719,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
624
719
|
};
|
|
625
720
|
|
|
626
721
|
return runInChat(chat, async () => {
|
|
722
|
+
noteLegacyCreatorOnce(job);
|
|
627
723
|
const state = stateForUser(canonicalUserId);
|
|
628
724
|
if (state.runningProcess || state.preparingRun || state.isCompacting) {
|
|
629
725
|
try { return markBusy(job); }
|
|
@@ -974,6 +1070,8 @@ module.exports = {
|
|
|
974
1070
|
createSchedulerService,
|
|
975
1071
|
parseFallbacks,
|
|
976
1072
|
terminalRunSucceeded,
|
|
1073
|
+
buildWatchCheckEnv,
|
|
1074
|
+
watchCheckPrincipal,
|
|
977
1075
|
setAdapters,
|
|
978
1076
|
initScheduler,
|
|
979
1077
|
scheduleJob: service.scheduleJob,
|
package/core/speaker-attest.js
CHANGED
|
@@ -53,4 +53,85 @@ function verify(canonicalUserId, attest) {
|
|
|
53
53
|
try { return crypto.timingSafeEqual(a, b); } catch (e) { return false; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// ── Run attestation ─────────────────────────────────────────────────────────
|
|
57
|
+
// The id attest above says "the runner stamped this canonical id". It is not
|
|
58
|
+
// bound to a run: a copy lifted from another process's environment stays valid
|
|
59
|
+
// for the whole boot. And the CLIs that decided the speaker locally (tool,
|
|
60
|
+
// keyring, entity) read OC_CHANNEL_* from their own env, so a shell that
|
|
61
|
+
// simply unset or rewrote those variables was treated as the owner.
|
|
62
|
+
//
|
|
63
|
+
// A run attestation binds a LIVE run to the principal it acts for, decided in
|
|
64
|
+
// the bot process: the runner registers every agent run it spawns
|
|
65
|
+
// (runId -> principal) and stamps OC_RUN_ID + OC_RUN_ATTEST into the run's
|
|
66
|
+
// env; the loopback answers "who is this?" only for a registered, still-live
|
|
67
|
+
// run whose attest matches. Anything else (no attest, a wrong one, a run that
|
|
68
|
+
// has ended, a detached process that outlived its turn) is UNATTRIBUTED, and
|
|
69
|
+
// unattributed never carries owner powers.
|
|
70
|
+
const RUN_TTL_MS = 12 * 60 * 60 * 1000;
|
|
71
|
+
const runs = new Map();
|
|
72
|
+
|
|
73
|
+
function runSignature(runId) {
|
|
74
|
+
return crypto.createHmac("sha256", secret()).update(`run\0${runId}`).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function principalOf(principal = {}) {
|
|
78
|
+
// Bot-internal work with no speaker (compaction, owner jobs) acts for the
|
|
79
|
+
// owner; a run is guarded exactly when it acts for someone who is not.
|
|
80
|
+
const owner = principal.owner === true;
|
|
81
|
+
const text = (value) => (value == null || value === "" ? null : String(value));
|
|
82
|
+
return {
|
|
83
|
+
kind: text(principal.kind) || "turn",
|
|
84
|
+
owner,
|
|
85
|
+
guarded: !owner,
|
|
86
|
+
canonicalUserId: text(principal.canonicalUserId),
|
|
87
|
+
name: text(principal.name),
|
|
88
|
+
adapterId: text(principal.adapterId),
|
|
89
|
+
adapterType: text(principal.adapterType),
|
|
90
|
+
channelId: text(principal.channelId),
|
|
91
|
+
userId: text(principal.userId),
|
|
92
|
+
room: text(principal.room),
|
|
93
|
+
purpose: text(principal.purpose),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pruneRuns(now = Date.now()) {
|
|
98
|
+
for (const [id, record] of runs) {
|
|
99
|
+
if (now - record.registeredAt > RUN_TTL_MS) runs.delete(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Returns the attest to stamp as OC_RUN_ATTEST. Re-registering a run (a retry
|
|
104
|
+
// of the same runId) replaces its principal.
|
|
105
|
+
function registerRun(runId, principal = {}) {
|
|
106
|
+
const id = String(runId || "");
|
|
107
|
+
if (!id) return "";
|
|
108
|
+
pruneRuns();
|
|
109
|
+
runs.set(id, { ...principalOf(principal), runId: id, registeredAt: Date.now() });
|
|
110
|
+
return runSignature(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function endRun(runId) {
|
|
114
|
+
runs.delete(String(runId || ""));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The principal of a live run whose attest matches, or null (fail closed).
|
|
118
|
+
function verifyRun(runId, attest) {
|
|
119
|
+
const id = String(runId || "");
|
|
120
|
+
const sig = String(attest || "");
|
|
121
|
+
if (!id || !sig) return null;
|
|
122
|
+
const record = runs.get(id);
|
|
123
|
+
if (!record) return null;
|
|
124
|
+
if (Date.now() - record.registeredAt > RUN_TTL_MS) { runs.delete(id); return null; }
|
|
125
|
+
const a = Buffer.from(sig, "utf-8");
|
|
126
|
+
const b = Buffer.from(runSignature(id), "utf-8");
|
|
127
|
+
if (a.length !== b.length) return null;
|
|
128
|
+
try { if (!crypto.timingSafeEqual(a, b)) return null; } catch (e) { return null; }
|
|
129
|
+
return { ...record };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function liveRuns() {
|
|
133
|
+
pruneRuns();
|
|
134
|
+
return Array.from(runs.values(), (record) => ({ ...record }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { mint, verify, registerRun, endRun, verifyRun, liveRuns, principalOf };
|