@inetafrica/open-claudia 3.1.23 → 3.1.25

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/core/scheduler.js CHANGED
@@ -1,5 +1,10 @@
1
- // Provider-aware durable scheduler. Every occurrence executes the immutable
2
- // user/project/provider/settings/session tuple captured when it was created.
1
+ // Provider-aware durable scheduler. Each occurrence runs in the user/project/
2
+ // channel it was created for, but its PROVIDER and MODEL track the owner's
3
+ // CURRENT selection at fire time — a wakeup pinned to a since-changed (or
4
+ // quota-exhausted) model must not keep firing that model. The origin provider
5
+ // and session are still recorded and are resumed only when the current provider
6
+ // still matches the origin; switching providers can't resume the other
7
+ // provider's native session, so that case starts a fresh thread.
3
8
  // One-shots are removed only after a terminal successful RunResult and a
4
9
  // durable success marker; failures retain the same occurrence ID for bounded
5
10
  // retry and eventually become explicit dead letters.
@@ -194,7 +199,50 @@ function createSchedulerService(dependencies = {}) {
194
199
  }
195
200
  }
196
201
 
202
+ // Current provider settings (model/effort/…) without throwing or mutating
203
+ // caller state past what getProviderSettings already does; cloned so the
204
+ // target owns its copy.
205
+ function currentProviderSettings(state, provider) {
206
+ try { return structuredClone(getProviderSettings(state, provider) || {}); }
207
+ catch (_) { return {}; }
208
+ }
209
+
210
+ // Resolve which provider + model actually runs this occurrence. It tracks the
211
+ // owner's CURRENT selection rather than the tuple pinned at creation:
212
+ // 1. Current provider (if runnable): run under it with its current settings.
213
+ // Same provider as the job's origin → resume the recorded session (only
214
+ // the model may have changed). Different provider → fresh thread, since
215
+ // the origin provider's native session can't cross providers.
216
+ // 2. No runnable current selection → honour the job's origin provider if it
217
+ // is still runnable, resuming its session (prior behaviour preserved).
218
+ // 3. Otherwise an explicitly configured fallback provider, fresh.
197
219
  function executionTarget(job, state) {
220
+ const currentProvider = getActiveProvider(state);
221
+ if (currentProvider && providerIsRunnable(providerStatusSafe(currentProvider))) {
222
+ const settings = currentProviderSettings(state, currentProvider);
223
+ if (currentProvider === job.provider) {
224
+ const lineage = resolveCompactionLineage(state, job);
225
+ return {
226
+ provider: currentProvider,
227
+ providerSettings: settings,
228
+ sessionId: lineage.sessionId,
229
+ attachSession: lineage.attachSession,
230
+ fallbackFrom: null,
231
+ switchedFrom: null,
232
+ fresh: !lineage.sessionId,
233
+ };
234
+ }
235
+ return {
236
+ provider: currentProvider,
237
+ providerSettings: settings,
238
+ sessionId: null,
239
+ attachSession: false,
240
+ fallbackFrom: null,
241
+ switchedFrom: job.provider,
242
+ fresh: true,
243
+ };
244
+ }
245
+
198
246
  if (providerIsRunnable(providerStatusSafe(job.provider))) {
199
247
  const lineage = resolveCompactionLineage(state, job);
200
248
  return {
@@ -203,6 +251,7 @@ function createSchedulerService(dependencies = {}) {
203
251
  sessionId: lineage.sessionId,
204
252
  attachSession: lineage.attachSession,
205
253
  fallbackFrom: null,
254
+ switchedFrom: null,
206
255
  fresh: !lineage.sessionId,
207
256
  };
208
257
  }
@@ -215,6 +264,7 @@ function createSchedulerService(dependencies = {}) {
215
264
  sessionId: null,
216
265
  attachSession: false,
217
266
  fallbackFrom: job.provider,
267
+ switchedFrom: null,
218
268
  fresh: true,
219
269
  };
220
270
  }
@@ -231,6 +281,13 @@ function createSchedulerService(dependencies = {}) {
231
281
  job.prompt,
232
282
  ].join("\n");
233
283
  }
284
+ if (target.switchedFrom) {
285
+ return [
286
+ `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
287
+ `[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`,
288
+ job.prompt,
289
+ ].join("\n");
290
+ }
234
291
  return `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]\n${job.prompt}`;
235
292
  }
236
293
 
@@ -391,6 +448,9 @@ function createSchedulerService(dependencies = {}) {
391
448
  // shared session. Matches captured project.name (projectDescriptor(job).name)
392
449
  // so sameCapturedProject / getProjectKey resolve the right thread mid-run.
393
450
  conversationKey: projectDescriptor(job).name,
451
+ // Rides only on triage-approval wakeups; the enforcer's reply vet honours
452
+ // the owner's one-off "handle this request" grant through it.
453
+ triageApprovalId: job.triageApprovalId || "",
394
454
  };
395
455
 
396
456
  return runInChat(chat, async () => {
@@ -233,6 +233,82 @@ function readGuardEvents(sinceTs) {
233
233
  return out;
234
234
  }
235
235
 
236
+ // ── guarded-speaker file-write gate ────────────────────────────────────────
237
+ // Provider file-mutation tools (Write/Edit/NotebookEdit) are denied outright on
238
+ // guarded EXTERNAL turns. The guard system prompt already says externals get
239
+ // answers, not edits — this makes it fixed code behaviour instead of guidance.
240
+ // Channel identity comes from the OC_CHANNEL_* env the runner stamps on every
241
+ // provider spawn; without it (owner-local CLI, tests, cron) the gate is
242
+ // dormant, and the owner resolves unguarded via the same speakerFor call
243
+ // bin/tool.js uses — so owner paths stay byte-for-byte unchanged.
244
+ //
245
+ // Fail direction: UNLIKE the deny-gate's fail-open doctrine, a resolution
246
+ // error here stays guarded (deny). This branch only activates when channel
247
+ // context is present, and speakerFor itself already resolves internal failures
248
+ // as external — never widen access on failure.
249
+ // apply_patch is Codex's native file-edit tool (its shell tool arrives as
250
+ // "Bash"); it carries the patch body in tool_input.command rather than a
251
+ // file_path, and the codex transport matcher forwards it here.
252
+ const FILE_MUTATION_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch"]);
253
+
254
+ // Shared by the speaker-keyed gates: resolve the turn's speaker from the
255
+ // OC_CHANNEL_* env. null → no channel context (dormant) or an unguarded
256
+ // (owner) speaker; otherwise { name } for the guarded external. Resolution
257
+ // errors stay guarded (fail closed) — never widen access on failure.
258
+ function guardedSpeakerFromEnv() {
259
+ const adapter = process.env.OC_CHANNEL_ADAPTER;
260
+ const channelId = process.env.OC_CHANNEL_ID;
261
+ if (!adapter || !channelId) return null; // no channel context → ungated
262
+ let guarded = true;
263
+ let name = "";
264
+ try {
265
+ const relationship = require("./relationship");
266
+ const sp = relationship.speakerFor(adapter, channelId, process.env.OC_CHANNEL_USER_ID);
267
+ guarded = relationship.guardActive(sp);
268
+ name = (sp && (sp.name || sp.relationship)) || "";
269
+ } catch (e) { /* fail closed: stay guarded */ }
270
+ return guarded ? { name } : null;
271
+ }
272
+
273
+ function externalFileMutationDenial(toolName) {
274
+ const speaker = guardedSpeakerFromEnv();
275
+ if (!speaker) return null;
276
+ return (
277
+ `⛔ External-speaker guard: ${toolName} is blocked on this turn — the speaker${speaker.name ? ` (${speaker.name})` : ""} is not the owner.\n` +
278
+ `Do not create or edit files for an external request. Describe what you would change instead, or run the change through a write-tier tool (open-claudia tool run …) so it escalates to the owner for approval.`
279
+ );
280
+ }
281
+
282
+ // ── guarded-speaker control-plane gate ─────────────────────────────────────
283
+ // On guarded EXTERNAL turns the agent's shell must not touch the bot's own
284
+ // control plane at all — the config dir (packs, tools, entity mandates,
285
+ // people.json, crons, lessons, approvals) or the installed package source. A
286
+ // write there is privilege escalation that OUTLIVES the turn (a planted cron,
287
+ // tool source, or mandate line later runs in owner context), and the F2
288
+ // Write/Edit denial could otherwise be routed around with one Bash heredoc.
289
+ // Reads are denied too: no legitimate external request needs the shell inside
290
+ // owner memory, and read-then-quote leaks policy into the vetted surface.
291
+ // Same fail directions as the file gate; deliberately outside relaxed
292
+ // tooling-mode. The authorised channels (tool run / keyring exec) stay exempt
293
+ // — bin/tool.js speaker-gates external runs itself.
294
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
295
+
296
+ function controlPlaneAnchors() {
297
+ return [CONFIG_DIR, PACKAGE_ROOT, ".open-claudia"];
298
+ }
299
+
300
+ function externalControlPlaneDenial(command) {
301
+ const cmd = String(command || "");
302
+ if (!cmd.trim() || isAuthorizedChannelCommand(cmd)) return null;
303
+ if (!controlPlaneAnchors().some((a) => a && cmd.includes(a))) return null;
304
+ const speaker = guardedSpeakerFromEnv();
305
+ if (!speaker) return null;
306
+ return (
307
+ `⛔ External-speaker guard: this command touches Open Claudia's own control plane (config, memory, tools, schedules) and the speaker${speaker.name ? ` (${speaker.name})` : ""} is not the owner.\n` +
308
+ `Answer from the conversation instead, or go through open-claudia CLI verbs — write-tier ones escalate to the owner for approval.`
309
+ );
310
+ }
311
+
236
312
  // Hook entry, pure enough to test: takes the PreToolUse stdin payload (object
237
313
  // or JSON string), returns { exit, stderr }. exit 2 = deny (stderr is fed back
238
314
  // to the model); exit 0 = allow. Anything unexpected → allow (fail-open).
@@ -244,8 +320,30 @@ function runHook(payload) {
244
320
  return { exit: 0, stderr: "" };
245
321
  }
246
322
  try {
323
+ // File-mutation tools are gated by SPEAKER, not by command content — and
324
+ // deliberately outside the relaxed tooling-mode bypass below: tooling mode
325
+ // is an owner workflow preference, this is a security control.
326
+ if (data && FILE_MUTATION_TOOLS.has(data.tool_name)) {
327
+ const denial = externalFileMutationDenial(data.tool_name);
328
+ if (!denial) return { exit: 0, stderr: "" };
329
+ const input = data.tool_input || {};
330
+ logGuardEvent({
331
+ kind: "deny",
332
+ rule: "external-file-write",
333
+ tool: data.tool_name,
334
+ file: String(input.file_path || input.notebook_path || input.command || "").slice(0, 300),
335
+ });
336
+ return { exit: 2, stderr: denial };
337
+ }
247
338
  if (!data || data.tool_name !== "Bash") return { exit: 0, stderr: "" };
248
339
  const command = data.tool_input && data.tool_input.command;
340
+ // Control-plane references are speaker-gated ahead of the tooling-mode
341
+ // switch — a security control, not a workflow preference.
342
+ const cpDenial = externalControlPlaneDenial(command);
343
+ if (cpDenial) {
344
+ logGuardEvent({ kind: "deny", rule: "external-control-plane", command: String(command).slice(0, 300) });
345
+ return { exit: 2, stderr: cpDenial };
346
+ }
249
347
  // Relaxed tooling mode: the gate observes but never blocks. The event is
250
348
  // still logged (as relaxed-pass, not deny) so the dream KPI can report
251
349
  // what WOULD have been denied — visibility survives the mode switch.
@@ -275,7 +373,8 @@ function ensureHookSettings() {
275
373
  }
276
374
 
277
375
  module.exports = {
278
- RULES, RAW_OP_PATTERNS, GUARD_LOG, SETTINGS_FILE,
376
+ RULES, RAW_OP_PATTERNS, GUARD_LOG, SETTINGS_FILE, FILE_MUTATION_TOOLS,
279
377
  isAuthorizedChannelCommand, matchRule, denyMessage, evaluateCommandPolicy, logGuardEvent, runHook, hookSettings, ensureHookSettings,
378
+ externalFileMutationDenial, externalControlPlaneDenial, guardedSpeakerFromEnv,
280
379
  matchRawOp, noteRawOp, readGuardEvents,
281
380
  };
@@ -0,0 +1,324 @@
1
+ # Permissions system — production-readiness audit & redesign plan
2
+
3
+ Date: 2026-07-24 · Auditor: Open Claudia (session-reviewed, all fixes test-pinned)
4
+ Scope: the external-speaker guardrail stack — relationship classification, the
5
+ three policy layers, the enforcer (reply/action/triage vets), approvals +
6
+ owner cards, the tool deny-gate, mandates, and identity/people records.
7
+
8
+ ---
9
+
10
+ ## 1. Verdict
11
+
12
+ **Conditionally production-ready.** The core is sound: default-deny
13
+ classification, an independent model judge that fails closed, owner-only
14
+ approval, and a hardcoded security floor no note can override. This pass
15
+ closed the five gaps that were outright exploitable or operationally broken
16
+ (F1–F6 below). What remains open is not exploitable-by-message but is
17
+ *organizational* debt that will bite as more people use the bot: fragmented
18
+ identities, no group layer, and always-allow grants that are broader than the
19
+ owner probably intends. Those are designed (§6) but deliberately NOT built
20
+ until the plan is approved.
21
+
22
+ ---
23
+
24
+ ## 2. The system today (post-fix architecture)
25
+
26
+ Layers consulted for every guarded turn, narrowing in scope; union of grants,
27
+ any prohibition wins, default-deny:
28
+
29
+ 1. **Security floor** — hardcoded in `GUARD_SYSTEM_PROMPT` (enforcer.js).
30
+ Credentials/secrets/infra access can never be granted by any layer below.
31
+ 2. **Global guardrails** — owner-authored, applies to every external.
32
+ 3. **Channel rules** — per group chat / task thread.
33
+ 4. **Person mandate** — per-person grants on their entity pack (≤4000 chars,
34
+ deduped, slug-keyed; judge reads at most 6000 defensively).
35
+ 5. **Turn grant** — one-off "handle this request" approval; reply vets only.
36
+
37
+ Enforcement points on a guarded (non-owner) turn:
38
+
39
+ - **Inbound triage** (router → `guardInboundTriage`): the request itself is
40
+ judged BEFORE any agent work. Held → owner card ("should I handle it?"),
41
+ external gets the holding line, no generation happens at all.
42
+ - **Provider file tools** (PreToolUse hook → tool-guard): Write/Edit/
43
+ MultiEdit/NotebookEdit are denied outright for external speakers, with a
44
+ redirect to write-tier tools (which escalate to the owner).
45
+ - **Bash deny-gate** (unchanged): prod surfaces require the covering
46
+ open-claudia tool; write/destructive tool runs escalate with exact-command
47
+ approval, one-shot consume, byte-bound.
48
+ - **Outbound reply vet** (`guardOutboundReply`): the authoritative gate on
49
+ anything the external actually receives. Triage is advisory; this is final.
50
+
51
+ Identity: `speakerFor` fails closed to "external"; owner status comes only
52
+ from `people.isOwner` via canonical identity (a note can never confer it).
53
+ Unknown authed speakers get an auto-contact (fresh record, hashed entity slug,
54
+ never name-linked). Meet-greet lane covers UNVERIFIED first contacts.
55
+
56
+ Fail direction map (deliberate asymmetries):
57
+
58
+ | Gate | On internal error | Why |
59
+ |---|---|---|
60
+ | speakerFor | closed (external) | never widen on failure |
61
+ | Enforcer verdict | closed (escalate), never cached, 1 bounded retry on transient | transport blips ≠ verdicts |
62
+ | Triage (guarded speaker) | closed (held) | reply vet still downstream |
63
+ | Triage (unresolvable speaker) | open (proceed) | owner UX; output vet authoritative |
64
+ | File-mutation gate, no channel env | dormant (allow) | owner-local turns untouched (R9/R14) |
65
+ | File-mutation gate, env + resolve error | closed (deny) | env present ⇒ real channel turn |
66
+ | Bash deny-gate | open (allow) | availability guard, not identity guard; logged |
67
+ | Turn grant validation | closed (no grant) | any mismatch/expiry/read error |
68
+
69
+ ---
70
+
71
+ ## 3. Findings fixed in this pass
72
+
73
+ | ID | Sev | Finding | Fix (all uncommitted, test-pinned) |
74
+ |---|---|---|---|
75
+ | F1 | High (ops) | Guard model cold-starts overran the old timeout, failing closed on legitimate replies → spurious owner cards | 60s timeout (`ENFORCER_TIMEOUT_MS`) + exactly one retry on transient transport errors; non-transient never retried; persistent failure still closes |
76
+ | F2 | **Critical** | Only Bash was hooked: an external speaker could have the agent write/edit files directly via provider Write/Edit tools — bypassing the whole approval path | Second PreToolUse matcher `^(Write\|Edit\|MultiEdit\|NotebookEdit)$` → same deny-gate CLI → speaker-gated denial; deliberately outside relaxed tooling-mode (it's a security control, not a workflow preference); audited as `external-file-write` |
77
+ | F3 | Med (UX/authz clarity) | Approval cards led with the bot's output, not the person — the owner was approving text, unclear they were authorizing a PERSON's request | Cards reworded: "**X** asked me something — should I act on it?" / "wants me to handle a request — should I?" / "wants me to run a **write** action — should I?"; ask shown first |
78
+ | F4 | High | Work happened BEFORE any approval: the agent fully processed an external request, only the reply was vetted (cost, prompt-injection surface, side-effect risk) | Up-front triage gate in the router: inbound judged first; held turns generate nothing; approval re-runs enter via the scheduler so they can't re-triage into a loop |
79
+ | F5 | High (friction→risk) | Without a grant concept, every triage-approved request structurally produced a SECOND approval card for its reply — double-approval fatigue trains blanket-allow habits | Turn grant: the approval id rides the scheduler job → bot-process AsyncLocalStorage (agent subprocess can never forge it) → honored in reply vets ONLY, validated fail-closed (approved + triage-kind + adapter/channel match + <24h), quotes one request, never actions |
80
+ | F6 | High (latent bug) | `appendMandate` upserted by NAME: an always-allow for auto-contact `sharon-a1b2c3` could land on a different `sharon` entity — cross-person grant leakage; mandates also grew unbounded with duplicate lines | Slug-keyed upsert; whitespace-normalized dedup; 4000-char write cap (refused, never truncated — it's security text); 6000-char defensive read clip in the judge; same hygiene in the /guardrails person-rule path |
81
+
82
+ ## 4. Known gaps — documented, not yet closed
83
+
84
+ | ID | Sev | Gap | Position |
85
+ |---|---|---|---|
86
+ | G1 | Med | Codex `apply_patch` file writes: hook matcher for Codex remains Bash-only; whether Codex file-mutation surfaces are covered is UNVERIFIED | Left unchanged rather than guessing hook names; verify against a live Codex fleet bot before relying on F2 parity there |
87
+ | G2 | Med | Media turns (voice/photo/document) from externals bypass inbound triage (text-only hook) | Output vet still covers everything they receive; close alongside G1 in a follow-up |
88
+ | G3 | ~~Low~~ CLOSED (P3) | ~~The `apr:` button handler (actions.js) has no test harness~~ Closed with P3: test-approval-cards.js pins the handler end-to-end (real approvals + entities + mandates, stubbed transport) — reply Always-allow delivery + scoped grant + ack ids, double-tap idempotence, deny hold, owner-only gating, triage approve wake job + deny decline, make-a-rule scope buttons, expired-card ack | Was: pre-existing posture; grant validation, card content, and record shapes were already pinned in test-enforcer.js |
89
+ | G4 | High (org) | Identity fragmentation: auto-contacts key on (adapter, userId) — the same human on Telegram + Kazee + Spaces mints separate records with separate mandates. An owner grant lands on one-third of a person (mechanism verified in code; live counts to be confirmed on the deployed store during the repair pass) | Redesign P1 |
90
+ | G5 | Med | No group layer: nothing between "this channel" and "this person" — can't say "the dev team may deploy to staging" | Redesign P2 |
91
+ | G6 | High (authz creep) | "Always allow" writes a verbatim-ask mandate line, forever, with no scope or expiry — over time a person accretes broad standing permissions the owner never reviewed as a set | Cap+dedup (F6) bound the bleeding; real fix is scoped grants, Redesign P3 |
92
+
93
+ ## 5. Test coverage after this pass
94
+
95
+ All green locally (`npm test`; one known loaded-runner flake class in
96
+ test-utility-provider-policy re-verified 3× clean):
97
+
98
+ - **test-enforcer.js** (+137 lines): triage kind end-to-end (owner no-op,
99
+ allow, block→card with handle/decline buttons + pinned record, fail-closed),
100
+ turn grant (reply-only, quotes the ask, cache-key isolation, and rejection
101
+ of pending / wrong-channel / wrong-kind ids), retry semantics, fail-closed
102
+ + never-cache, audit hygiene (no payloads/mandates in logs).
103
+ - **test-tool-guard.js** (+60): file gate dormant without channel env, denies
104
+ unknown-external Write/Edit/NotebookEdit, reads+benign Bash stay free,
105
+ owner turns untouched, audited rule, settings carry both matchers on one
106
+ hook command.
107
+ - **test-mandate-hygiene.js** (new): slug-keying (two Sharons never share),
108
+ dedup, cap-refusal leaves bytes identical, name-fallback for legacy records.
109
+ - **test-provider-gateway.js**: router consults triage with the routed text +
110
+ provider/project/dir pinning on every text turn.
111
+ - Plus the previously-passing enforcer/scheduler/hook/approval suites.
112
+
113
+ ---
114
+
115
+ ## 6. Redesign plan (approval requested — nothing below is built)
116
+
117
+ ### P1 — One person, many surfaces (identity unification)
118
+
119
+ Anchor: **people.id stays the canonical key; Kazee userId becomes the
120
+ preferred anchor handle** (it is unique across all Kazee tasks, so every task
121
+ thread resolves to the same person — no per-task duplicates).
122
+
123
+ - **Agent-proposed, owner-confirmed merges.** When a new identity appears (or
124
+ at repair time), the agent gathers signals — same display name, same email
125
+ in profile, cross-references in conversation ("this is Sharon from
126
+ accounts") — and posts a card: *"Is `telegram: Sharon N. (…111)` the same
127
+ person as `kazee: sharon@… `? [Same person] [Different] [Not sure]"*.
128
+ NEVER auto-merge, and never merge on display name alone (names are
129
+ spoofable — that invariant already exists in ensureContact and stays).
130
+ - **Merge = handles fold into one people record**; entities merge Mandate
131
+ (union, deduped, capped — over-cap requires owner trim), Notes/Log append.
132
+ The losing record is tombstoned (`mergedInto: <id>`) so old approval
133
+ records/audit lines still resolve. Merges are reversible from the tombstone.
134
+ - **Repair pass** for existing stores: one-time sweep proposes merge cards for
135
+ probable duplicates (e.g. the fragmented Sharon records on the deployed
136
+ bot); owner confirms each. Nothing merges silently.
137
+ - Guard changes: `speakerFor` unchanged (already person-keyed); the win is
138
+ that one mandate follows the human across surfaces.
139
+
140
+ ### P2 — Groups as a policy layer
141
+
142
+ Precedence becomes: **floor > global > channel > group > person > turn-grant**
143
+ (same rule: grants union, any prohibition wins, floor absolute).
144
+
145
+ - A group is an owner-curated named set of person ids (`groups.json`), e.g.
146
+ `dev-team`, `family`. People can be in several.
147
+ - `/guardrails` gains group scope; the "Make a rule…" card offers *this
148
+ person / this channel / this group / everyone*.
149
+ - The enforcer prompt gains one GROUP RULES section (union of the speaker's
150
+ groups, clipped like mandates). Cache key includes it.
151
+ - No nesting, no inheritance between groups in v1 — flat sets only.
152
+
153
+ ### P3 — Capability-scoped approvals (kills the blanket)
154
+
155
+ Replaces free-text "Always-allowed by owner: <verbatim ask>" with a
156
+ structured grant the owner explicitly shapes at approval time:
157
+
158
+ ```
159
+ - grant: {capability} · scope: {this-exact-thing | this-kind-of-thing}
160
+ · for: {person | group} · expires: {7d | 30d | never} · granted: 2026-07-24
161
+ ```
162
+
163
+ - The card's "Make a rule…" flow asks two more taps: *what* ("just this" vs
164
+ "requests like this — <agent-drafted capability line the owner can edit>")
165
+ and *how long* (7d / 30d / no expiry). Default is the NARROW option.
166
+ - Stored in the same Mandate section (human-readable lines, same cap), so the
167
+ judge consumes them without a format break; expired lines are skipped at
168
+ read time and swept by the nightly dream pass with a chat notice.
169
+ - `/guardrails` lists a person's active grants with per-line revoke — the
170
+ owner finally sees the accumulated permission set in one place.
171
+ - Rationale: an approval should carry WHO + WHAT KIND + HOW LONG, not become
172
+ an eternal verbatim precedent (this is the "blanket approve" concern raised
173
+ on the always-allow flow, solved at the model layer the judge already reads).
174
+
175
+ ### P4 — Rollout order
176
+
177
+ 1. **P3** first (smallest blast radius, immediate authz-creep fix; pure
178
+ card-flow + mandate-format change, judge prompt already compatible).
179
+ 2. **P1** second (unification + repair pass; needs the merge card UI and the
180
+ tombstone plumbing; highest data-migration care — every step
181
+ owner-confirmed, reversible, backed up like dream merges).
182
+ 3. **P2** last (new layer; cleanest once identities are unified so group
183
+ membership means one person, not one fragment).
184
+ 4. Close G1/G2 (Codex file-tool verification, media-turn triage) alongside.
185
+ (G3, the `apr:` handler harness, shipped with P3 — see §4/§9.)
186
+
187
+ Risks: P1 merge cards interrupt the owner (batched, max a few per day);
188
+ P3 makes approvals two taps longer (only on "Make a rule", never on
189
+ approve-once); all three change nothing for the owner's own turns.
190
+
191
+ ---
192
+
193
+ ## 7. Pass 2 — 360° dangling-pathway sweep (2026-07-24, post-go)
194
+
195
+ After the go, one full pass enumerating every EXISTING entry point that
196
+ reaches the same concerns as the new gates (the /intros lesson: a new system
197
+ must reconcile every old pathway, not just the one it was built on). Findings,
198
+ all fixed and test-pinned this pass:
199
+
200
+ - **D1 · CRITICAL — model output could write guard policy.** The per-turn
201
+ pack reviewer and the nightly dream both passed `relationship` + `mandate`
202
+ from model JSON into `upsertEntity`. Both run over mixed-provenance content
203
+ (including external chatter), so a prompt-injected turn could have granted
204
+ itself a mandate. Writers now strip both fields (owner actions remain the
205
+ only writers), prompts/schemas updated, attack fixtures added
206
+ (test-provider-pack-review, test-provider-dream). Introspection's
207
+ entity_edits applier verified notes-only.
208
+ - **D2 — media turns bypassed inbound triage (closes G2).** Voice, audio,
209
+ photo, and document handlers ran the agent without `guardInboundTriage`;
210
+ only text was triaged. All five inbound shapes now enter through one
211
+ `admitGuardedTurn` helper; media asks carry saved file paths so an
212
+ owner-approved re-run can still find them, and the resolved ask is
213
+ back-filled into the turn scope (`setCurrentInboundText`) so owner cards
214
+ and F5 turn-grant validation see the real request, not an empty string.
215
+ - **D3 — dream auto-merges could delete guard policy.** An entity merge
216
+ could remove a mandate-carrying entity or orphan a people-record
217
+ `entitySlug` pointer (silent speaker-resolution break). Merges touching
218
+ either are now skipped (`guardPinned`) — owner-confirmed merges only
219
+ (arrives with P1).
220
+ - **D4 — Bash was an open side door around F2/F6.** On a guarded turn one
221
+ heredoc could edit config, packs, crons, tool sources, or the installed
222
+ package. New speaker-gated control-plane gate in the PreToolUse hook:
223
+ any command referencing the config dir, package root, or `.open-claudia`
224
+ is denied for externals (reads too — read-then-quote leaks policy), ahead
225
+ of the tooling-mode switch, audited as `external-control-plane`.
226
+ Authorised channels stay exempt (bin/tool.js gates externals itself).
227
+ - **D5 — Codex file edits were unhooked (closes G1, live-probed).** Probed
228
+ real codex 0.144.0 with a log-everything hook: shell arrives as tool_name
229
+ `Bash`, file edits as `apply_patch` — and normal runs bypass the sandbox,
230
+ so the `^Bash$` matcher left externals able to write ANY file under Codex.
231
+ Matcher widened to `^(Bash|apply_patch)$`, `apply_patch` added to
232
+ FILE_MUTATION_TOOLS, fixture + pins updated.
233
+ - **D6 — two mandate writers, drift risk.** The always-allow grant writer
234
+ (actions.appendMandate) and the rule-card writer (router) each implemented
235
+ dedup/cap/slug-keying. Unified on one `appendMandateLine` so hygiene can't
236
+ drift.
237
+ - **D7 — keyring CLI was speaker-blind.** `keyring exec` runs an arbitrary
238
+ command with the FULL keyring in env, `get` prints raw values — and the
239
+ control-plane gate exempts `keyring exec` as an authorised channel, so on
240
+ an external turn it was a creds-plus-config-write side door around D4. The
241
+ keyring CLI now refuses guarded external speakers outright (every verb,
242
+ audited as `external-keyring`), dormant owner-local, denied on resolution
243
+ failure.
244
+
245
+ Verified-OK pathways (no change needed): scheduler re-runs stamp
246
+ `OC_CHANNEL_USER_ID` (file gate stays live on approved external re-runs);
247
+ Spaces turns flow through the common router (triage covered); sub-agents
248
+ spawn read-only and inherit channel env; codex transport confinement
249
+ (hooks on, plugins/unified_exec off, untrusted trust) is mandatory-or-refuse.
250
+
251
+ G3 closed with P3 (test-approval-cards.js pins the `apr:` handler). `tool run`
252
+ remains the one authorised channel reachable on external turns — by design:
253
+ bin/tool.js vets write/destructive tiers per speaker (mandate-allow or
254
+ owner escalation; vet logic pinned in test-enforcer.js /
255
+ test-provider-enforcer.js), and read-only verbs answer without side effects.
256
+
257
+ ---
258
+
259
+ ## 8. Ship state
260
+
261
+ Go received 2026-07-24. All §3/§5 fixes plus the §7 sweep are committed and
262
+ pushed (v3.1.25, 3025f53); fleet roll/publish is confirmed separately
263
+ (restarting the bot kills the live session). §6 starts next: P3 → P1 → P2.
264
+
265
+ ---
266
+
267
+ ## 9. P3 — capability-scoped approvals (2026-07-24)
268
+
269
+ The owner's driving concern, verbatim: "the approval all is a blanket for all
270
+ users or how can we get it to be for the user plus maybe what we want them to
271
+ be able to do as a guardrail vs a clanket approve everything going forward for
272
+ all or event that person." A mandate line is now a scoped, expiring, revocable
273
+ GRANT — not a permanent blanket.
274
+
275
+ New module `core/mandates.js`; the three policy layers now share one
276
+ lifecycle (id → list → revoke):
277
+
278
+ - **Ids.** Every mandate bullet has a stable 8-char id =
279
+ sha1(slug + normalized text) — slug-scoped, so the same capability granted
280
+ to two people revokes independently. Ids ignore case, whitespace, and the
281
+ expiry marker.
282
+ - **Expiry.** A trailing ` [until YYYY-MM-DD]` marker (UTC, inclusive).
283
+ Tap-grants ("Always allow" on an approval card) default to a bounded TTL —
284
+ `MANDATE_GRANT_TTL_DAYS`, 30 by default, 0 = permanent. Owner-authored rule
285
+ cards stay permanent unless the text ends "for Nd" / "until YYYY-MM-DD"
286
+ (`extractExpiry`). Filtering is deterministic and read-side:
287
+ `relationship.speakerFor` (the single choke point every consumer of
288
+ `speaker.mandate` goes through — enforcer, external-mode prompt, tool vets)
289
+ drops expired bullets via `activeMandateText`, so the judge never sees a
290
+ lapsed capability and never does date math. The enforcer verdict cache keys
291
+ on the filtered text, so expiry busts it naturally.
292
+ - **Refresh, not stacking.** `appendMandateLine` dedups on the
293
+ marker-stripped text: re-granting the same capability replaces the line
294
+ with the new expiry (`refreshed: true`). Successful writes also prune
295
+ expired bullets and collapse legacy duplicates; refused/duplicate writes
296
+ mutate nothing (cap-refusal pin unchanged). Non-bullet hand-written text
297
+ survives rewrites.
298
+ - **Revoke.** `/guardrails` now lists per-person grants (`👤 name (slug)`,
299
+ ids + until-dates) alongside global + channel rules; `remove|rm|revoke <id>`
300
+ resolves across all three layers (global → channel → person). Revoking the
301
+ last line empties the section via direct `writeEntity` (upsertEntity treats
302
+ "" as no-op). Approval-card acks quote the id + expiry and the revoke
303
+ command.
304
+
305
+ Pathways reconciled (the §7 discipline applied to the new system):
306
+ `bin/entity.js persona --mandate` is a deliberate owner-only WHOLESALE editor
307
+ (same class as hand-editing the entity file) — the parser tolerates whatever
308
+ lands there and the read filter still governs what the judge sees. Dream and
309
+ pack-review remain stripped of mandate writes (D1). All other writers funnel
310
+ through `appendMandateLine` (D6).
311
+
312
+ Pinned in test-mandate-hygiene.js (expiry marker, refresh, prune-on-write,
313
+ id scoping, extractExpiry, list/revoke, last-line clear) and
314
+ test-external-rule-scopes.js (speakerFor drops expired lines while the file
315
+ still holds them). G3 closes here too: test-approval-cards.js exercises the
316
+ `apr:` button handler end-to-end with the real approvals + entities +
317
+ mandates modules and stubbed transport — Always-allow delivers AND writes the
318
+ scoped grant (TTL marker, ack quotes id + revoke command), double-tap can't
319
+ flip a decision or duplicate a grant, deny holds with no delivery and no
320
+ mandate write, non-owner taps bounce, triage approve wakes the agent in the
321
+ origin channel with the approval id as a one-off turn grant (request framed
322
+ strictly as data), triage deny sends the polite decline without waking the
323
+ agent, "Make a rule" delivers and offers person/channel/global scope buttons,
324
+ and stale cards get a clean ack.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.23",
3
+ "version": "3.1.25",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
13
  "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
14
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js"
14
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -69,7 +69,9 @@
69
69
  "test-recall-relationship-gate.js",
70
70
  "test-relationship.js",
71
71
  "test-enforcer.js",
72
+ "test-mandate-hygiene.js",
72
73
  "test-external-rule-scopes.js",
74
+ "test-approval-cards.js",
73
75
  "test-identity-prune.js",
74
76
  "test-kazee-message-id.js",
75
77
  "test-kazee-channel-health.js",