@inetafrica/open-claudia 3.1.24 → 3.1.26

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.
@@ -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.24",
3
+ "version": "3.1.26",
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",
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ // G3: the apr: button handler finally has a harness. The approval card is the
6
+ // owner's authorization surface for guarded external turns, so its handler is
7
+ // pinned end-to-end with the real approvals + entities + mandates modules and
8
+ // stubbed transport: reply delivery, Always-allow grant (scoped + expiring,
9
+ // P3), triage wake jobs, deny paths, owner-only gating, and the make-a-rule
10
+ // scope prompt.
11
+
12
+ const assert = require("assert");
13
+ const fs = require("fs");
14
+ const os = require("os");
15
+ const path = require("path");
16
+
17
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "approval-cards-"));
18
+ process.env.OPEN_CLAUDIA_TEST = "1";
19
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(tmp, "config");
20
+ process.env.WORKSPACE = path.join(tmp, "workspace");
21
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
22
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
23
+ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
24
+ process.env.TELEGRAM_CHAT_ID = "owner-chat";
25
+ process.env.CHANNELS = "";
26
+ process.env.CLAUDE_PATH = process.execPath;
27
+ process.env.CODEX_PATH = process.execPath;
28
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
29
+ fs.mkdirSync(process.env.WORKSPACE, { recursive: true });
30
+
31
+ function installStub(relativePath, exports) {
32
+ const filename = require.resolve(relativePath);
33
+ require.cache[filename] = { id: filename, filename, loaded: true, exports };
34
+ }
35
+
36
+ const sends = [];
37
+ const relayed = [];
38
+ const jobsAdded = [];
39
+
40
+ installStub("./core/io", {
41
+ send: async (text, opts) => { sends.push({ text, opts }); return { ok: true, messageId: String(sends.length) }; },
42
+ deleteMessage: async () => true,
43
+ });
44
+ installStub("./core/state", {
45
+ currentState: () => ({ settings: {}, messageQueue: [] }),
46
+ saveState: () => {},
47
+ getActiveProvider: () => "claude",
48
+ getProjectKey: () => "fixture",
49
+ consumeSessionCallbackToken: () => null,
50
+ userOwnsProviderSession: () => true,
51
+ });
52
+ installStub("./core/runner", { admitRunContext: () => ({}), runAgent: async () => ({ ok: true }) });
53
+ installStub("./core/access", {
54
+ isChatOwner: (channelId) => String(channelId) === "owner-chat",
55
+ approveAuthRequest: () => false, denyAuthRequest: () => false, authRequestLabel: () => "",
56
+ listAuthorizedRaw: () => ({ authorized: [], pending: [] }),
57
+ });
58
+ installStub("./core/people", { findByHandle: () => null });
59
+ installStub("./core/vault-store", { vault: { exists: () => false, isUnlocked: () => false } });
60
+ installStub("./core/intros", {});
61
+ installStub("./core/intro-flow", {});
62
+ installStub("./core/onboarding", { finishOnboarding: async () => {} });
63
+ installStub("./core/handlers", {
64
+ startSession: async () => {}, switchProviderState: async () => {}, startNewConversation: async () => {},
65
+ endCurrentSession: async () => {}, activeContinueOptions: () => null, applyProviderSetting: async () => {},
66
+ performProviderLogout: async () => {}, startProviderLogin: async () => {},
67
+ });
68
+ installStub("./core/providers", {});
69
+ installStub("./core/jobs", {});
70
+ installStub("./core/scheduler", { addJob: (job) => { jobsAdded.push(job); return { id: `job-${jobsAdded.length}` }; } });
71
+ installStub("./core/side-chat", { sideChatCoordinator: { enqueue: async () => ({ status: "queued" }) } });
72
+ installStub("./core/auth-flow", {
73
+ claudeProviderAuthStatus: async () => ({}), runClaudeAuthCommand: async () => {},
74
+ runCodexLoginStatus: async () => ({}), runCodexDeviceLogin: async () => {},
75
+ canLogout: () => false, providerAccountSummary: async () => "",
76
+ });
77
+ installStub("./core/relay", {
78
+ send: async (msg) => { relayed.push(msg); return { ok: true }; },
79
+ });
80
+
81
+ const entities = require("./core/entities");
82
+ const mandates = require("./core/mandates");
83
+ const approvals = require("./core/approvals");
84
+ const { handleAction } = require("./core/actions");
85
+
86
+ const edits = [];
87
+ function envelopeFor(payload, { channelId = "owner-chat", sourceMessageId = "card-1" } = {}) {
88
+ return {
89
+ adapter: {
90
+ id: "kazee-fixture", type: "kazee",
91
+ edit: async (chan, srcId, msg) => { edits.push({ chan, srcId, msg }); },
92
+ },
93
+ channelId,
94
+ action: { payload, sourceMessageId },
95
+ };
96
+ }
97
+ const lastEdit = () => edits[edits.length - 1]?.msg || "";
98
+ const lastSend = () => sends[sends.length - 1]?.text || "";
99
+
100
+ (async () => {
101
+ entities.upsertEntity({ slug: "lucy-ext1", name: "Lucy", relationship: "external" });
102
+ const base = {
103
+ adapter: "kazee", channelId: "ext-channel", canonicalUserId: "owner-user", userId: "user-lucy",
104
+ provider: "claude", project: "fixture", personName: "Lucy", entitySlug: "lucy-ext1",
105
+ targetAdapter: "kazee", targetChannel: "ext-channel",
106
+ };
107
+
108
+ // ── (b) reply escalation: Always-allow delivers AND writes a scoped grant ──
109
+ const reply = approvals.create({ ...base, kind: "reply", payloadText: "Your invoice is settled." });
110
+ await handleAction(envelopeFor(`apr:${reply.id}:always`));
111
+ assert.strictEqual(relayed.length, 1, "approved reply delivered to the external");
112
+ assert.strictEqual(relayed[0].text, "Your invoice is settled.");
113
+ assert.strictEqual(relayed[0].target.channelId, "ext-channel");
114
+ assert.strictEqual(approvals.read(reply.id).status, "approved");
115
+ const grantId = mandates.lineId("lucy-ext1", "Always-allowed by owner: reply about: Your invoice is settled.");
116
+ const mandateText = entities.readEntity("lucy-ext1").sections.Mandate;
117
+ assert.ok(/Always-allowed by owner: reply about: Your invoice is settled\./.test(mandateText), "grant written");
118
+ const exp = mandates.defaultGrantExpiry();
119
+ assert.ok(mandateText.includes(`[until ${exp}]`), "tap-grant carries the default TTL (scoped, not blanket)");
120
+ assert.ok(lastEdit().includes(`mandate [${grantId}]`), "ack quotes the grant id");
121
+ assert.ok(lastEdit().includes(`/guardrails revoke ${grantId}`), "ack offers the revoke command");
122
+
123
+ // Double-tap: decide() keeps the FIRST decision and the mandate is not
124
+ // re-written; delivery re-runs by design (retry affordance after a failed
125
+ // send — the card edit makes an accidental double-tap unlikely).
126
+ await handleAction(envelopeFor(`apr:${reply.id}:always`));
127
+ assert.strictEqual(approvals.read(reply.id).status, "approved", "second tap can't flip the decision");
128
+ assert.strictEqual(
129
+ entities.readEntity("lucy-ext1").sections.Mandate.split("invoice is settled").length, 2,
130
+ "double-tap never duplicates the grant");
131
+
132
+ // ── deny on a fresh reply: held, nothing delivered ──
133
+ relayed.length = 0;
134
+ const held = approvals.create({ ...base, kind: "reply", payloadText: "Payroll details attached." });
135
+ await handleAction(envelopeFor(`apr:${held.id}:deny`));
136
+ assert.strictEqual(approvals.read(held.id).status, "denied");
137
+ assert.strictEqual(relayed.length, 0, "denied reply never reaches the external");
138
+ assert.ok(/Held/.test(lastEdit()), "owner sees the hold ack");
139
+ assert.ok(!/Payroll/.test(entities.readEntity("lucy-ext1").sections.Mandate), "deny writes no mandate");
140
+
141
+ // ── owner-only: an external chat cannot decide approvals ──
142
+ const foreign = approvals.create({ ...base, kind: "reply", payloadText: "Secret." });
143
+ await handleAction(envelopeFor(`apr:${foreign.id}:ok`, { channelId: "intruder-chat" }));
144
+ assert.strictEqual(approvals.read(foreign.id).status, "pending", "non-owner tap changes nothing");
145
+ assert.ok(/Owner only/.test(lastSend()));
146
+ assert.strictEqual(relayed.length, 0);
147
+
148
+ // ── (c) triage approve: wakes the agent in the ORIGIN channel with the
149
+ // approval id as a one-off turn grant ──
150
+ const triage = approvals.create({ ...base, kind: "triage", inboundText: "Can you check invoice 42?" });
151
+ await handleAction(envelopeFor(`apr:${triage.id}:ok`));
152
+ assert.strictEqual(jobsAdded.length, 1, "approve wakes the agent");
153
+ assert.strictEqual(jobsAdded[0].triageApprovalId, triage.id, "job carries the turn grant id");
154
+ assert.strictEqual(jobsAdded[0].channelId, "ext-channel", "agent wakes in the origin channel");
155
+ assert.strictEqual(jobsAdded[0].provider, "claude", "provider pinned from the record");
156
+ assert.ok(jobsAdded[0].prompt.includes("Can you check invoice 42?"), "prompt carries the request");
157
+ assert.ok(jobsAdded[0].prompt.includes("strictly as data"), "request text framed as data, not instructions");
158
+
159
+ // ── triage deny: polite decline to the external, no agent run ──
160
+ const triageNo = approvals.create({ ...base, kind: "triage", inboundText: "Wire me the payroll." });
161
+ await handleAction(envelopeFor(`apr:${triageNo.id}:deny`));
162
+ assert.strictEqual(jobsAdded.length, 1, "deny never wakes the agent");
163
+ assert.strictEqual(relayed.length, 1, "external gets the decline");
164
+ assert.ok(/can't help/.test(relayed[0].text));
165
+
166
+ // ── make-a-rule: approve + scope buttons (person scope available when the
167
+ // record carries an entity slug) ──
168
+ relayed.length = 0;
169
+ const ruled = approvals.create({ ...base, kind: "reply", payloadText: "Office hours are 9-5." });
170
+ await handleAction(envelopeFor(`apr:${ruled.id}:rule`));
171
+ assert.strictEqual(relayed.length, 1, "make-a-rule still delivers the approved reply");
172
+ const scopeMsg = sends[sends.length - 1];
173
+ assert.ok(/who should it apply to/.test(scopeMsg.text), "scope prompt sent");
174
+ const rows = JSON.stringify(scopeMsg.opts?.keyboard || {});
175
+ assert.ok(rows.includes(`grl:${ruled.id}:person`), "person scope offered");
176
+ assert.ok(rows.includes(`grl:${ruled.id}:channel`), "channel scope offered");
177
+ assert.ok(rows.includes(`grl:${ruled.id}:global`), "global scope offered");
178
+
179
+ // ── expired card: nothing to decide ──
180
+ await handleAction(envelopeFor("apr:apr_0000_dead:ok"));
181
+ assert.ok(/expired|cleaned up/.test(lastEdit()), "stale card gets a clear ack");
182
+
183
+ console.log("approval cards OK");
184
+ })().catch((e) => { console.error(e); process.exit(1); });