@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.
package/test-enforcer.js CHANGED
@@ -210,6 +210,38 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
210
210
  assert.strictEqual(f2.decision, "allow", "the failure was NOT cached — it is re-judged");
211
211
  assert.strictEqual(utilityCalls, 2, "both attempts reached the model");
212
212
 
213
+ // ── TRANSIENT guard failure retries once, then succeeds: a cold-start
214
+ // timeout is transport, not a verdict, so the guard re-runs once and the
215
+ // permitted reply goes through instead of needlessly escalating. ──
216
+ utilityCalls = 0;
217
+ let sawTransient = false;
218
+ utilityResponder = async () => {
219
+ if (!sawTransient) { sawTransient = true; throw Object.assign(new Error("Utility agent timed out"), { code: "UTILITY_TIMEOUT" }); }
220
+ return { text: '{"decision":"allow","reason":"recovered after a transient timeout"}' };
221
+ };
222
+ const recovered = await enforcer.vetReply(extSpeaker, "transient-timeout-recovers-xyz");
223
+ assert.strictEqual(recovered.decision, "allow", "a transient guard timeout is retried, not failed closed");
224
+ assert.strictEqual(recovered.status, "judged", "the retried verdict is a real judgement");
225
+ assert.strictEqual(recovered.retried, true, "the verdict records that it took a retry");
226
+ assert.strictEqual(utilityCalls, 2, "exactly one retry");
227
+
228
+ // ── but the retry is bounded to ONE: a transient failure on BOTH attempts
229
+ // still fails closed — never open, never an unbounded loop. ──
230
+ utilityCalls = 0;
231
+ utilityResponder = async () => { throw Object.assign(new Error("Utility agent timed out"), { code: "UTILITY_TIMEOUT" }); };
232
+ const stillClosed = await enforcer.vetReply(extSpeaker, "transient-timeout-persists-xyz");
233
+ assert.strictEqual(stillClosed.decision, "escalate", "a persistent transient failure still fails closed");
234
+ assert.ok(/failing closed/.test(stillClosed.reason), "bounded retry still fails closed");
235
+ assert.strictEqual(utilityCalls, 2, "the retry is bounded to a single extra attempt");
236
+
237
+ // ── a NON-transient failure is NOT retried: a bad verdict is a decision,
238
+ // not a transport blip, so it fails closed on the first attempt. ──
239
+ utilityCalls = 0;
240
+ utilityResponder = async () => { throw Object.assign(new Error("provider auth failed"), { code: "PROVIDER_UNAUTHENTICATED" }); };
241
+ const noRetry = await enforcer.vetReply(extSpeaker, "non-transient-no-retry-xyz");
242
+ assert.strictEqual(noRetry.decision, "escalate", "a non-transient failure fails closed");
243
+ assert.strictEqual(utilityCalls, 1, "a non-transient failure is never retried");
244
+
213
245
  // ── unparseable + invalid decision both fail closed to escalate ──
214
246
  utilityResponder = async () => ({ text: "I'm sorry, I can't do that." });
215
247
  const up = await enforcer.vetReply(extSpeaker, "no-json-payload-1");
@@ -241,5 +273,110 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
241
273
  assert.strictEqual(fbTarget.person, null, "env-derived owner target carries no people record");
242
274
  assert.strictEqual(kazeeDmRequestedFor, "owner-kz-999", "the DM is opened for the configured owner user id");
243
275
 
276
+ // ── triage (up-front gate): the INBOUND request is judged before any work ──
277
+ assert.ok(/triage decision/i.test(enforcer.GUARD_SYSTEM_PROMPT), "guard prompt explains the triage kind");
278
+ assert.ok(/TURN GRANT/.test(enforcer.GUARD_SYSTEM_PROMPT), "guard prompt bounds the turn grant (never blanket)");
279
+
280
+ // owner and no-context turns are byte-for-byte untouched — no model call.
281
+ utilityCalls = 0;
282
+ const triOwner = await inChat(CH_OWNER, () => enforcer.guardInboundTriage("do anything"));
283
+ assert.deepStrictEqual(triOwner, { held: false, guarded: false }, "owner inbound is never triaged");
284
+ const triNoCtx = await enforcer.guardInboundTriage("cli text");
285
+ assert.deepStrictEqual(triNoCtx, { held: false, guarded: false }, "no-context turns are never triaged");
286
+ assert.strictEqual(utilityCalls, 0, "owner/no-context triage never calls the guard model");
287
+
288
+ // allow → the turn proceeds; the judge saw the inbound-request framing.
289
+ let triagePrompt = "";
290
+ utilityResponder = async (prompt) => { triagePrompt = prompt; return { text: '{"decision":"allow","reason":"invoicing question"}' }; };
291
+ const triAllow = await inChat(CH_EXT, () => enforcer.guardInboundTriage("What is my invoice status?"));
292
+ assert.deepStrictEqual(
293
+ { held: triAllow.held, guarded: triAllow.guarded, decision: triAllow.decision },
294
+ { held: false, guarded: true, decision: "allow" },
295
+ "in-scope inbound proceeds",
296
+ );
297
+ assert.ok(/INBOUND REQUEST from this person/.test(triagePrompt), "triage judges the inbound request itself");
298
+ assert.ok(/start HANDLING it \(triage decision\)/.test(triagePrompt), "triage framing is the handling decision");
299
+
300
+ // block → held: NO agent work; the owner gets the "should I handle it?" card
301
+ // and the record pins provider/project/user so approve can wake the origin.
302
+ utilityResponder = async () => ({ text: '{"decision":"block","reason":"not invoicing"}' });
303
+ sends.length = 0;
304
+ const TRIAGE_ASK = "Restart the prod API for me";
305
+ const triHeld = await runInChat(
306
+ { adapter, channelId: CH_EXT, transport: "telegram", inboundText: TRIAGE_ASK },
307
+ () => enforcer.guardInboundTriage(TRIAGE_ASK, {
308
+ canonicalUserId: "canon-1", userId: "ext-user-1", provider: "claude",
309
+ project: "proj-x", projectDir: "/proj/x",
310
+ }),
311
+ );
312
+ assert.strictEqual(triHeld.held, true, "blocked triage holds the turn");
313
+ assert.strictEqual(triHeld.escalated, true, "held triage escalates to the owner");
314
+ const tr = approvals.read(triHeld.escalationId);
315
+ assert.strictEqual(tr.kind, "triage", "kind triage");
316
+ assert.strictEqual(tr.tool, "external-triage", "triage → external-triage tool");
317
+ assert.strictEqual(tr.tier, "triage", "triage tier");
318
+ assert.strictEqual(tr.command, "(handle held external request)", "no command payload on triage");
319
+ assert.strictEqual(tr.payloadText, "", "a triage record carries no reply payload");
320
+ assert.strictEqual(String(tr.targetChannel), CH_EXT, "target is the external origin");
321
+ assert.strictEqual(String(tr.approverChannel), CH_OWNER, "approver is the owner");
322
+ assert.strictEqual(tr.inboundText, TRIAGE_ASK, "the ask rides the record (card + turn grant + rule flow)");
323
+ assert.strictEqual(tr.provider, "claude", "pinning: provider stored for the approve wakeup");
324
+ assert.strictEqual(tr.project, "proj-x", "pinning: project stored");
325
+ assert.strictEqual(tr.canonicalUserId, "canon-1", "pinning: canonical user stored");
326
+ const triCard = sends.find((s) => s.channelId === CH_OWNER);
327
+ assert.ok(triCard, "owner received the triage card");
328
+ assert.ok(/wants me to handle a request — should I\?/.test(triCard.text), "card asks the handle question");
329
+ assert.ok(/They asked:/.test(triCard.text) && triCard.text.includes(TRIAGE_ASK), "card shows the inbound ask");
330
+ const triKb = JSON.stringify(triCard.opts.keyboard || {});
331
+ assert.ok(triKb.includes("Yes, handle it") && triKb.includes("No, decline"), "triage card gets handle/decline buttons");
332
+ assert.ok(!sends.some((s) => s.channelId === CH_EXT && /prod API/.test(s.text)), "no generated content reaches the external");
333
+
334
+ // guard failure on a guarded turn fails CLOSED: the message is held.
335
+ utilityResponder = async () => { throw new Error("model down"); };
336
+ const triFail = await inChat(CH_EXT, () => enforcer.guardInboundTriage("unique-triage-fail-payload"));
337
+ assert.strictEqual(triFail.held, true, "triage guard failure holds the guarded turn");
338
+
339
+ // ── turn grant: the owner's triage approval clears the REPLY vet only ──
340
+ approvals.decide(triHeld.escalationId, "approved", "owner-test");
341
+ const grantCtx = { adapter, channelId: CH_EXT, transport: "telegram", triageApprovalId: triHeld.escalationId };
342
+ let grantPrompt = "";
343
+ utilityResponder = async (prompt) => { grantPrompt = prompt; return { text: '{"decision":"allow","reason":"serves the approved ask"}' }; };
344
+ await runInChat(grantCtx, () => enforcer.vetReply(extSpeaker, "grant-reply-payload-1"));
345
+ assert.ok(/TURN GRANT \(owner-approved, THIS turn only\):/.test(grantPrompt), "reply vet carries the turn grant");
346
+ assert.ok(grantPrompt.includes(TRIAGE_ASK), "the grant quotes the approved ask verbatim");
347
+
348
+ // actions NEVER inherit the grant — write/destructive runs keep their own approvals.
349
+ await runInChat(grantCtx, () => enforcer.vetAction(extSpeaker, { command: "open-claudia tool run kchat post", tier: "write" }));
350
+ assert.ok(!/TURN GRANT/.test(grantPrompt), "the grant never reaches action vets");
351
+
352
+ // the grant is part of the cache key: with/without-grant verdicts never conflate.
353
+ utilityCalls = 0;
354
+ utilityResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
355
+ const GK_P = "grant-cache-key-payload-1";
356
+ await runInChat(grantCtx, () => enforcer.vetReply(extSpeaker, GK_P));
357
+ await inChat(CH_EXT, () => enforcer.vetReply(extSpeaker, GK_P));
358
+ assert.strictEqual(utilityCalls, 2, "grant presence busts the verdict cache key");
359
+
360
+ // fail-closed grant validation: pending, wrong-channel, and wrong-kind ids grant nothing.
361
+ utilityResponder = async (prompt) => { grantPrompt = prompt; return { text: '{"decision":"allow","reason":"ok"}' }; };
362
+ const escPending = await runInChat(
363
+ { adapter, channelId: CH_EXT, transport: "telegram", inboundText: "another ask" },
364
+ () => enforcer.escalate({ speaker: extSpeaker, kind: "triage", payloadText: "", summary: "another ask", reason: "r" }),
365
+ );
366
+ await runInChat({ ...grantCtx, triageApprovalId: escPending.id }, () => enforcer.vetReply(extSpeaker, "pending-grant-payload"));
367
+ assert.ok(!/TURN GRANT/.test(grantPrompt), "a still-pending triage id grants nothing");
368
+ const wrongCh = approvals.create({
369
+ tool: "external-triage", verb: "", tier: "triage", command: "(handle held external request)",
370
+ channelId: "999", adapter: "telegram", kind: "triage",
371
+ targetAdapter: "telegram", targetChannel: "999", inboundText: "other channel ask",
372
+ approverAdapter: "telegram", approverChannel: CH_OWNER,
373
+ });
374
+ approvals.decide(wrongCh.id, "approved", "owner-test");
375
+ await runInChat({ ...grantCtx, triageApprovalId: wrongCh.id }, () => enforcer.vetReply(extSpeaker, "mismatch-grant-payload"));
376
+ assert.ok(!/TURN GRANT/.test(grantPrompt), "a grant for another channel never applies");
377
+ approvals.decide(escReply.id, "approved", "owner-test");
378
+ await runInChat({ ...grantCtx, triageApprovalId: escReply.id }, () => enforcer.vetReply(extSpeaker, "reply-kind-grant-payload"));
379
+ assert.ok(!/TURN GRANT/.test(grantPrompt), "only a triage-kind approval grants the turn");
380
+
244
381
  console.log("enforcer OK");
245
382
  })().catch((e) => { console.error(e); process.exit(1); });
@@ -103,6 +103,14 @@ const lucy = relationship.ensureContact(envLucy);
103
103
  assert.strictEqual(spk.slug, lucy.entitySlug);
104
104
  assert.strictEqual(relationship.speakerFor("kazee", "chatdoc1", "user-impostor").mandate, "", "impostor speaker has no mandate");
105
105
 
106
+ // Expired grant lines are filtered at the speakerFor choke point — the guard
107
+ // judge never sees a lapsed capability, even when the file still holds it.
108
+ entities.upsertEntity({ slug: lucy.entitySlug, name: "Lucy Osweta",
109
+ mandate: "- may ask about invoices\n- may pull payroll [until 2020-01-01]" });
110
+ const spkExp = relationship.speakerFor("kazee", "chatdoc1", "user-lucy");
111
+ assert.strictEqual(spkExp.mandate, "- may ask about invoices", "expired mandate line never reaches the judge");
112
+ assert.ok(/payroll/.test(entities.readEntity(lucy.entitySlug).sections.Mandate), "the stale line still sits in the file (read-side filter, not a write)");
113
+
106
114
  // Known people and malformed envelopes never create records.
107
115
  assert.strictEqual(relationship.ensureContact(envLucy), null);
108
116
  assert.strictEqual(relationship.ensureContact({ channelId: "x" }), null, "no adapter → no-op");
@@ -163,7 +163,7 @@ function runHookProbe() {
163
163
  && overrides.includes("features.unified_exec=false")
164
164
  && overrides.some((value) => /^projects=\{.+trust_level="untrusted".+\}$/.test(value))
165
165
  && hookOverrides.length === 1
166
- && /matcher\s*=\s*"\^Bash\$"/.test(hookOverrides[0]);
166
+ && /matcher\s*=\s*"\^\(Bash\|apply_patch\)\$"/.test(hookOverrides[0]);
167
167
 
168
168
  if (!generatedOnly && process.env.FAKE_AGENT_ARBITRARY_HOOK_MARKER) {
169
169
  fs.writeFileSync(process.env.FAKE_AGENT_ARBITRARY_HOOK_MARKER, "untrusted hook executed\n");
@@ -0,0 +1,147 @@
1
+ // Mandate hygiene (guardrail Always-allow writer). A mandate is owner-authored
2
+ // security text injected into the guard prompt, so the writer is slug-keyed
3
+ // (two same-named people never share one), deduped (an identical grant is a
4
+ // no-op), and capped (past MANDATE_MAX_CHARS nothing is written — security
5
+ // text is never silently truncated).
6
+ const assert = require("assert");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mandate-hygiene-"));
12
+ process.env.OPEN_CLAUDIA_TEST = "1";
13
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(tmp, "config");
14
+ process.env.WORKSPACE = path.join(tmp, "workspace");
15
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
16
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
17
+ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
18
+ process.env.TELEGRAM_CHAT_ID = "fixture-chat";
19
+ process.env.CHANNELS = "";
20
+ process.env.CLAUDE_PATH = process.execPath;
21
+ process.env.CODEX_PATH = process.execPath;
22
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
23
+ fs.mkdirSync(process.env.WORKSPACE, { recursive: true });
24
+
25
+ const entities = require("./core/entities");
26
+ const { appendMandate } = require("./core/actions");
27
+
28
+ // ── slug-keying: the grant lands on exactly the entity the record points at,
29
+ // never a same-named other. Two Sharons: a plain-slug one and a hashed
30
+ // auto-contact — the record carries the auto-contact slug.
31
+ entities.upsertEntity({ slug: "sharon", name: "Sharon", relationship: "family", mandate: "- May ask about dinner plans." });
32
+ entities.upsertEntity({ slug: "sharon-a1b2c3", name: "Sharon", relationship: "external" });
33
+ const r1 = appendMandate({ entitySlug: "sharon-a1b2c3", personName: "Sharon" }, "checking invoice status");
34
+ assert.strictEqual(r1.ok, true, "grant written");
35
+ const hashed = entities.readEntity("sharon-a1b2c3");
36
+ assert.ok(/Always-allowed by owner: checking invoice status/.test(hashed.sections.Mandate),
37
+ "grant lands on the slug-keyed entity");
38
+ assert.ok(!/invoice status/.test(entities.readEntity("sharon").sections.Mandate || ""),
39
+ "the same-named other Sharon is untouched");
40
+ assert.strictEqual(hashed.relationship, "external", "relationship preserved across the mandate write");
41
+
42
+ // ── dedup: an identical grant (any whitespace shape) is a no-op ──
43
+ const r2 = appendMandate({ entitySlug: "sharon-a1b2c3", personName: "Sharon" }, " checking invoice status ");
44
+ assert.deepStrictEqual(r2, { ok: true, duplicate: true }, "identical grant reports duplicate");
45
+ const once = entities.readEntity("sharon-a1b2c3").sections.Mandate;
46
+ assert.strictEqual(once.split("invoice status").length, 2, "the grant appears exactly once");
47
+
48
+ // distinct grants still append.
49
+ assert.strictEqual(appendMandate({ entitySlug: "sharon-a1b2c3", personName: "Sharon" }, "sending weekly report").ok, true);
50
+ assert.ok(/weekly report/.test(entities.readEntity("sharon-a1b2c3").sections.Mandate), "second grant appended");
51
+
52
+ // ── cap: past MANDATE_MAX_CHARS the write is refused, never truncated ──
53
+ assert.ok(entities.MANDATE_MAX_CHARS > 0, "cap exported");
54
+ const nearFull = "- filler line to occupy mandate budget xxxxxxxxxxxxxxxxxxxxxxxx\n"
55
+ .repeat(Math.ceil(entities.MANDATE_MAX_CHARS / 64)).slice(0, entities.MANDATE_MAX_CHARS - 10).trim();
56
+ entities.upsertEntity({ slug: "busy-contact", name: "Busy Contact", relationship: "external", mandate: nearFull });
57
+ const r3 = appendMandate({ entitySlug: "busy-contact", personName: "Busy Contact" }, "one more grant");
58
+ assert.strictEqual(r3.ok, false, "over-cap write refused");
59
+ assert.ok(/full/.test(r3.reason), "reason says the mandate is full");
60
+ assert.strictEqual(entities.readEntity("busy-contact").sections.Mandate, nearFull,
61
+ "refused write leaves the mandate byte-identical (no truncation)");
62
+
63
+ // ── no record: nothing to write to → refused with a reason ──
64
+ const r4 = appendMandate({ entitySlug: "", personName: "" }, "anything");
65
+ assert.strictEqual(r4.ok, false, "no contact record → refused");
66
+ assert.ok(/no contact record/.test(r4.reason));
67
+
68
+ // ── legacy fallback: a record with only a personName still lands (by name) ──
69
+ const r5 = appendMandate({ entitySlug: "", personName: "Legacy Larry" }, "asking build status");
70
+ assert.strictEqual(r5.ok, true, "name-only record still writes");
71
+ assert.ok(/build status/.test((entities.readEntity("legacy-larry") || { sections: {} }).sections.Mandate || ""),
72
+ "fallback upserts the slugified-name entity");
73
+
74
+ // ── P3: capability-scoped grants — expiry, refresh, ids, revoke ──
75
+ const mandates = require("./core/mandates");
76
+ const { appendMandateLine } = require("./core/actions");
77
+ const defaultExp = mandates.defaultGrantExpiry();
78
+
79
+ // A tap-grant is a guardrail, not a blanket: it carries the default TTL as a
80
+ // trailing marker, and the write returns the line's stable slug-scoped id.
81
+ assert.ok(mandates.GRANT_TTL_DAYS > 0, "tap-grants default to a bounded TTL");
82
+ assert.ok(new RegExp(`checking invoice status \\[until ${defaultExp}\\]`).test(
83
+ entities.readEntity("sharon-a1b2c3").sections.Mandate), "always-allow grant carries the default expiry marker");
84
+ assert.strictEqual(r1.id, mandates.lineId("sharon-a1b2c3", "Always-allowed by owner: checking invoice status"),
85
+ "write returns the line's stable id");
86
+ assert.strictEqual(r1.expiresAt, defaultExp);
87
+
88
+ // Re-granting the same capability refreshes its expiry — never stacks lines.
89
+ const rr = appendMandateLine({ entitySlug: "sharon-a1b2c3", personName: "Sharon" },
90
+ "Always-allowed by owner: checking invoice status", "refresh", { expiresAt: "2030-01-01" });
91
+ assert.strictEqual(rr.ok, true);
92
+ assert.strictEqual(rr.refreshed, true, "same capability, new expiry → refresh");
93
+ const afterRefresh = entities.readEntity("sharon-a1b2c3").sections.Mandate;
94
+ assert.strictEqual(afterRefresh.split("invoice status").length, 2, "refresh replaces, never stacks");
95
+ assert.ok(/invoice status \[until 2030-01-01\]/.test(afterRefresh), "expiry moved to the new date");
96
+
97
+ // A successful write prunes expired bullets; hand-written non-bullet text survives.
98
+ entities.upsertEntity({ slug: "temp-tina", name: "Temp Tina", relationship: "external",
99
+ mandate: "context line, not a bullet\n- may see old dashboards [until 2020-01-01]\n- may ask about lunch" });
100
+ const rp = appendMandateLine({ entitySlug: "temp-tina", personName: "Temp Tina" },
101
+ "may request exports", "test write", { expiresAt: null });
102
+ assert.strictEqual(rp.ok, true);
103
+ assert.strictEqual(rp.expiresAt, null, "owner-authored lines default to permanent");
104
+ const tina = entities.readEntity("temp-tina").sections.Mandate;
105
+ assert.ok(!/old dashboards/.test(tina), "expired bullet pruned on write");
106
+ assert.ok(/context line, not a bullet/.test(tina), "non-bullet text survives the rewrite");
107
+ assert.ok(/may ask about lunch/.test(tina) && /may request exports/.test(tina), "live grants kept");
108
+
109
+ // Read-side filter: the judge never sees expired bullets, even in a stale file.
110
+ assert.strictEqual(mandates.activeMandateText("- live grant\n- dead grant [until 2020-01-01]\nplain note"),
111
+ "- live grant\nplain note");
112
+ assert.strictEqual(mandates.isExpired(defaultExp), false, "today+TTL is live");
113
+ assert.strictEqual(mandates.isExpired("2020-01-01"), true);
114
+ assert.strictEqual(mandates.isExpired(null), false, "no marker never expires");
115
+
116
+ // Ids are slug-scoped (same capability on two people revokes independently)
117
+ // and ignore case, whitespace, and the expiry marker.
118
+ assert.notStrictEqual(mandates.lineId("sharon", "may ask about x"), mandates.lineId("sharon-a1b2c3", "may ask about x"));
119
+ assert.strictEqual(mandates.lineId("s", "May Ask about X "), mandates.lineId("s", "may ask about x"));
120
+ assert.strictEqual(mandates.lineId("s", "may ask about x [until 2030-01-01]"), mandates.lineId("s", "may ask about x"));
121
+
122
+ // Owner rule text may carry its own expiry phrase.
123
+ assert.deepStrictEqual(mandates.extractExpiry("may fetch invoices until 2026-08-30"),
124
+ { text: "may fetch invoices", expiresAt: "2026-08-30" });
125
+ const for14 = mandates.extractExpiry("may fetch invoices for 14d");
126
+ assert.strictEqual(for14.text, "may fetch invoices");
127
+ assert.ok(/^\d{4}-\d{2}-\d{2}$/.test(for14.expiresAt), "'for Nd' converts to a date");
128
+ assert.deepStrictEqual(mandates.extractExpiry("may fetch invoices"), { text: "may fetch invoices", expiresAt: null });
129
+
130
+ // Listing shows live grants only; revoke removes exactly one line from
131
+ // exactly one person.
132
+ const tinaListed = mandates.listPersonMandates().find((p) => p.slug === "temp-tina");
133
+ assert.ok(tinaListed && tinaListed.lines.length === 2, "listing shows the two live grants");
134
+ const lunchId = mandates.lineId("temp-tina", "may ask about lunch");
135
+ const rev = mandates.revokeLine(lunchId);
136
+ assert.ok(rev && rev.slug === "temp-tina" && /lunch/.test(rev.text), "revoke reports what it removed");
137
+ const tinaAfter = entities.readEntity("temp-tina").sections.Mandate;
138
+ assert.ok(!/lunch/.test(tinaAfter) && /may request exports/.test(tinaAfter), "exactly the one line removed");
139
+ assert.strictEqual(mandates.revokeLine("deadbeef"), null, "unknown id → null");
140
+
141
+ // Revoking the last line clears the section entirely (empty is a real state).
142
+ entities.upsertEntity({ slug: "solo-sam", name: "Solo Sam", relationship: "external", mandate: "- only grant" });
143
+ assert.ok(mandates.revokeLine(mandates.lineId("solo-sam", "only grant")));
144
+ assert.strictEqual(String(entities.readEntity("solo-sam").sections.Mandate || "").trim(), "",
145
+ "last-line revoke empties the section");
146
+
147
+ console.log("mandate hygiene OK");
@@ -1,8 +1,12 @@
1
1
  // Persona pipeline: the two write-side paths that POPULATE a persona pack —
2
2
  // the post-turn reviewer (pack-review.applyEntityAction) and the nightly dream
3
3
  // consolidation (dream.applyDream entity_notes). Both must thread the persona
4
- // fields (relationship/role/style/knows/mandate) into the entity, replacing
5
- // each section wholesale, without disturbing the base Notes/Log contract.
4
+ // fields (role/style/knows) into the entity, replacing each section wholesale,
5
+ // without disturbing the base Notes/Log contract — while relationship and
6
+ // mandate, the two POLICY fields the external guard consumes, are STRIPPED
7
+ // from model output no matter what the proposal carries (D1: both appliers run
8
+ // over mixed-provenance content, so honoring them would let a poisoned turn
9
+ // grant itself privileges).
6
10
 
7
11
  const assert = require("assert");
8
12
  const fs = require("fs");
@@ -17,7 +21,10 @@ const entities = require("./core/entities");
17
21
  const review = require("./core/pack-review");
18
22
  const dream = require("./core/dream");
19
23
 
20
- // ── reviewer: a persona-bearing entity action writes all persona sections ──
24
+ // ── reviewer: a persona-bearing action writes role/style/knows — and even
25
+ // when the proposal smuggles relationship + mandate, both policy fields are
26
+ // stripped (the pipeline-level D1 pin; the applier-level attack fixtures live
27
+ // in test-provider-pack-review.js / test-provider-dream.js) ──
21
28
  const res = review.applyEntityAction({
22
29
  name: "Rex Boss",
23
30
  type: "person",
@@ -31,11 +38,11 @@ const res = review.applyEntityAction({
31
38
  });
32
39
  assert.strictEqual(res.kind, "create", "reviewer creates the entity");
33
40
  let rex = entities.findEntity("Rex Boss");
34
- assert.strictEqual(rex.relationship, "trusted", "reviewer set relationship");
41
+ assert.strictEqual(rex.relationship, "", "proposed relationship is STRIPPED — owner surfaces only");
35
42
  assert.strictEqual(rex.sections.Role, "CTO — owns platform architecture.", "reviewer set Role");
36
43
  assert.strictEqual(rex.sections.Style, "Blunt and fast. Skip preamble.", "reviewer set Style");
37
44
  assert.strictEqual(rex.sections.Knows, "Deep backend; assume distributed-systems fluency.", "reviewer set Knows");
38
- assert.strictEqual(rex.sections.Mandate, "May discuss roadmap and architecture, not headcount.", "reviewer set Mandate");
45
+ assert.strictEqual(String(rex.sections.Mandate || ""), "", "proposed mandate is STRIPPED — owner surfaces only");
39
46
  assert.ok(rex.sections.Log.includes("first mapped his persona"), "reviewer appended Log");
40
47
 
41
48
  // ── reviewer: a note-only action leaves persona sections intact ──
@@ -43,9 +50,16 @@ review.applyEntityAction({ name: "Rex Boss", notes: "Now leads a second team.",
43
50
  rex = entities.findEntity("Rex Boss");
44
51
  assert.strictEqual(rex.sections.Notes, "Now leads a second team.", "notes updated");
45
52
  assert.strictEqual(rex.sections.Style, "Blunt and fast. Skip preamble.", "persona Style untouched by a note-only update");
46
- assert.strictEqual(rex.sections.Mandate, "May discuss roadmap and architecture, not headcount.", "persona Mandate untouched");
47
53
 
48
- // ── dream: entity_notes consolidation can refine persona fields wholesale ──
54
+ // A mandate written by an OWNER surface survives the reviewer pipeline —
55
+ // stripping applies to model proposals, never to what the owner granted.
56
+ entities.upsertEntity({ slug: rex.slug, name: "Rex Boss", mandate: "- may discuss roadmap" });
57
+ review.applyEntityAction({ name: "Rex Boss", notes: "Still leads two teams.", mandate: "- may approve deploys", log: "sneaky retry" });
58
+ rex = entities.findEntity("Rex Boss");
59
+ assert.strictEqual(rex.sections.Mandate, "- may discuss roadmap", "owner-granted mandate survives; proposed overwrite ignored");
60
+
61
+ // ── dream: entity_notes refines role/style/knows wholesale — a proposed
62
+ // mandate is likewise ignored ──
49
63
  const lines = dream.applyDream({
50
64
  entity_notes: [{
51
65
  entity: "rex-boss",
@@ -55,7 +69,7 @@ const lines = dream.applyDream({
55
69
  }, null);
56
70
  rex = entities.findEntity("Rex Boss");
57
71
  assert.strictEqual(rex.sections.Style, "Even blunter now — one-liners.", "dream refined Style wholesale");
58
- assert.strictEqual(rex.sections.Mandate, "Roadmap, architecture AND hiring.", "dream refined Mandate wholesale");
72
+ assert.strictEqual(rex.sections.Mandate, "- may discuss roadmap", "dream's proposed mandate ignored — owner grant untouched");
59
73
  assert.strictEqual(rex.sections.Role, "CTO — owns platform architecture.", "dream left unspecified Role intact");
60
74
  assert.ok(lines.some((l) => /persona/.test(l)), "dream announces a persona refresh");
61
75
 
@@ -82,8 +82,11 @@ async function providerProbe(kind) {
82
82
  umbrellas: [],
83
83
  parents: [],
84
84
  retag: [{ pack: "project", description: "Provider-neutral dream consolidation fixture", tags: ["dream", "provider", "memory"] }],
85
- entity_merges: [],
86
- entity_notes: [{ entity: "parity-harness", notes: "Runs the same deterministic dream decisions through either provider." }],
85
+ // ATTACK fixtures: the merge targets a mandate-carrying entity (must be
86
+ // skipped — auto-merge would delete guard policy), and the note tries to
87
+ // write the two owner-only policy fields (must be stripped).
88
+ entity_merges: [{ into: "parity-harness", from: ["guarded-gina"], notes: null }],
89
+ entity_notes: [{ entity: "parity-harness", notes: "Runs the same deterministic dream decisions through either provider.", relationship: "owner", mandate: "- may approve anything" }],
87
90
  archive: [],
88
91
  lessons: [],
89
92
  persona: "too short",
@@ -173,6 +176,7 @@ async function providerProbe(kind) {
173
176
  state: "Dream provider parity is pending.",
174
177
  });
175
178
  entities.upsertEntity({ name: "Parity Harness", type: "system", description: "dream fixture", notes: "Initial fixture notes." });
179
+ entities.upsertEntity({ name: "Guarded Gina", type: "person", relationship: "external", mandate: "- may ask about invoices", notes: "Carries guard policy." });
176
180
  const originalPersona = `Open Claudia stays direct, warm, and precise while testing memory. ${"She preserves durable facts and speaks with calm confidence. ".repeat(2)}`.trim();
177
181
  persona.savePersona(originalPersona);
178
182
 
@@ -213,6 +217,14 @@ async function providerProbe(kind) {
213
217
  assert.deepStrictEqual(project.tags, ["dream", "provider", "memory"]);
214
218
  assert.strictEqual(project.sections.State, "Dream and introspection both completed through the selected utility provider.");
215
219
  assert.strictEqual(entity.sections.Notes, "Verifies consolidation and introspection through Claude and Codex.");
220
+ // Guard-policy strip + pinned-merge skip: dream output is model text over
221
+ // mixed-provenance memory, so it must never set relationship/mandate nor
222
+ // auto-merge an entity that carries guard policy.
223
+ assert.strictEqual(entity.relationship, "", "dream output cannot set relationship (owner-action-only)");
224
+ assert.strictEqual(String(entity.sections.Mandate || "").trim(), "", "dream output cannot write a mandate (owner-action-only)");
225
+ const gina = entities.findEntity("Guarded Gina");
226
+ assert.ok(gina, "mandate-carrying entity survives an attempted auto-merge");
227
+ assert.strictEqual(gina.sections.Mandate, "- may ask about invoices", "her guard policy is byte-identical");
216
228
  assert.strictEqual(lessons.listLessons()[0].text, "Always record the provider and model used for a dream.");
217
229
  assert.strictEqual(ideas.listIdeas()[0].text, "Show dream provider metadata in the morning summary.");
218
230
  assert.strictEqual(persona.loadPersona(), originalPersona, "persona bounds still reject an undersized model proposal");
@@ -79,7 +79,11 @@ async function providerProbe(kind) {
79
79
  { FAKE_AGENT_TEXT: '{"decision":"block","reason":"outside mandate"}' },
80
80
  { FAKE_AGENT_TEXT: '{"decision":"escalate","reason":"owner must decide"}' },
81
81
  { FAKE_AGENT_TEXT: "not-json" },
82
+ // The guard-reliability fix retries ONCE on a transient timeout before
83
+ // failing closed: BOTH attempts hang, so the verdict still escalates —
84
+ // proving the retry is bounded and never fails open at the transport level.
82
85
  { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: timeoutSignal },
86
+ { FAKE_AGENT_SCENARIO: "forced-termination" },
83
87
  ];
84
88
  const entry = makeProvider(kind, root, controls);
85
89
  const model = `${kind}-enforcer-exact`;
@@ -140,7 +144,7 @@ async function providerProbe(kind) {
140
144
  assert.ok(!serializedAudit.includes(secret), "audit metadata never contains the judged prompt or payload");
141
145
  assert.ok(!serializedAudit.includes("mandate"), "audit metadata never contains the private mandate");
142
146
 
143
- assert.strictEqual(entry.captures.length, 5);
147
+ assert.strictEqual(entry.captures.length, 6, "5 verdicts + one bounded retry on the transient timeout");
144
148
  for (const capture of entry.captures) {
145
149
  const raw = fs.readFileSync(capture, "utf8");
146
150
  const record = JSON.parse(raw);
@@ -52,6 +52,15 @@ async function routerProbe() {
52
52
  installStub("./core/state", {
53
53
  currentState: () => state,
54
54
  autoLinkOwnerChannel: () => null,
55
+ getActiveProvider: (s) => (s || state).settings?.backend || null,
56
+ getProjectKey: (s) => (s || state).currentSession?.name || "",
57
+ });
58
+ // The up-front triage gate is pinned in test-enforcer.js; here it must only
59
+ // be CONSULTED with the routed text + provider/project pinning, then let the
60
+ // turn through — the probe's subject is admission/dispatch mechanics.
61
+ const triaged = [];
62
+ installStub("./core/enforcer", {
63
+ guardInboundTriage: async (text, pinning) => { triaged.push({ text, pinning }); return { held: false, guarded: false }; },
55
64
  });
56
65
  installStub("./core/runner", {
57
66
  admitRunContext(prompt, cwd, replyToMsgId, opts = {}) {
@@ -235,6 +244,11 @@ async function routerProbe() {
235
244
  await router.onMessage(envelope("text", { text: "plain text" }));
236
245
  assert.strictEqual(runs.at(-1).opts.runContext, admissions.at(-1));
237
246
  assert.strictEqual(runs.at(-1).prompt, "plain text");
247
+ const lastTriage = triaged.at(-1);
248
+ assert.strictEqual(lastTriage.text, "plain text", "text turns consult the up-front triage gate");
249
+ assert.strictEqual(lastTriage.pinning.provider, "claude", "triage pinning carries the active provider");
250
+ assert.strictEqual(lastTriage.pinning.project, "project-a", "triage pinning carries the project key");
251
+ assert.strictEqual(lastTriage.pinning.projectDir, projectA, "triage pinning carries the project dir");
238
252
  }
239
253
 
240
254
  assert.ok(sends.length > 0, "media acknowledgement delivery remains active");
@@ -282,6 +296,8 @@ async function schedulerProbe() {
282
296
  });
283
297
  installStub("./core/state", {
284
298
  getUserState: () => state,
299
+ getActiveProvider: (s) => (s || state).settings?.backend || null,
300
+ getProviderSettings: (s, provider) => ({ ...((s || state).providerSettings?.[provider] || {}) }),
285
301
  getProviderSession: (_state, provider, project) => (
286
302
  state.activeSessions?.[project]?.[provider] || null
287
303
  ),
@@ -302,7 +302,27 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
302
302
  // mode; recall query mirrors it and the CLI banner reports honest
303
303
  // hint-survival instead of raw expansion counts; catalog knob (dream-tunable,
304
304
  // RECALL_CATALOG pin) restores the legacy menu.
305
- assert.strictEqual(pkg.version, "3.1.24", "release version must remain unchanged without explicit approval");
305
+ // 3.1.25 approved by Sumeet 2026-07-24 ("ok then if you have evertyhing you
306
+ // need then you can proceed", plus the 360° instruction: "maybe do one pass
307
+ // with a check to see if there is something that is danlging with your new
308
+ // impleentatin ... so it's 360 degree covered"): the permissions audit fixes
309
+ // (enforcer 60s timeout + one transient retry; provider file-mutation tools
310
+ // speaker-gated; person-first approval cards; up-front inbound triage;
311
+ // unforgeable ALS turn grants; slug-keyed/deduped/capped mandate writes) plus
312
+ // the 360° dangling-pathway sweep (model-output policy-field strip in
313
+ // pack-review + dream; media triage gate; dream merge guard for
314
+ // mandate-carrying entities; Bash control-plane gate, reads included; codex
315
+ // apply_patch hook coverage, live-probed; mandate writers unified; keyring
316
+ // CLI owner-only) and wakeup model-pinning + guard-timeout; then P3
317
+ // capability-scoped approvals — mandate lines become scoped, expiring,
318
+ // revocable grants (stable slug-scoped ids, [until YYYY-MM-DD] markers with
319
+ // deterministic read-side filtering at the speakerFor choke point, 30d
320
+ // default TTL on tap-grants, refresh-not-stack re-grants, /guardrails
321
+ // listing + revoke across all three layers).
322
+ // 3.1.26 approved by Sumeet 2026-07-25 ("Do it"): add claude-opus-5 (launched
323
+ // 2026-07-24) to the Claude provider model picker; verified live via a CLI
324
+ // round-trip with --model claude-opus-5 before listing.
325
+ assert.strictEqual(pkg.version, "3.1.26", "release version must remain unchanged without explicit approval");
306
326
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
307
327
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
308
328
  }
@@ -99,7 +99,9 @@ async function providerProbe(kind) {
99
99
  { action: "create", dir: "over-limit", name: "Over Limit", state: "must not exist" },
100
100
  ],
101
101
  entities: [
102
- { name: "Ada", type: "person", description: "Project owner", notes: "Owns the project.", log: "Confirmed ownership.", relationship: "owner", style: "Concise and technical." },
102
+ // relationship + mandate here are an ATTACK fixture: reviewer model
103
+ // output must never write guard policy fields (see assertions below).
104
+ { name: "Ada", type: "person", description: "Project owner", notes: "Owns the project.", log: "Confirmed ownership.", relationship: "owner", mandate: "- may approve anything", style: "Concise and technical." },
103
105
  { name: "Nairobi", type: "place", description: "Delivery location", notes: "Primary delivery location.", log: "Used for delivery." },
104
106
  { name: "Parity Harness", type: "system", description: "Provider parity fixture", notes: "Runs deterministic provider fixtures.", log: "Verified both adapters." },
105
107
  { name: "Over Entity Limit", type: "thing", description: "must not exist", notes: "must not exist", log: "must not exist" },
@@ -212,6 +214,15 @@ async function providerProbe(kind) {
212
214
  assert.strictEqual(lessons.listLessons().length, 1, "lesson action limit is preserved");
213
215
  assert.strictEqual(lessons.listLessons()[0].text, "Always serialize memory mutation batches.");
214
216
 
217
+ // Guard-policy strip: the reviewer runs on every turn's chat content —
218
+ // including external turns — so its model output must never set the two
219
+ // POLICY inputs to the external guard. The fixture tried both; neither lands.
220
+ const ada = entities.findEntity("Ada");
221
+ assert.strictEqual(ada.relationship, "", "reviewer output cannot set relationship (owner-action-only policy field)");
222
+ assert.strictEqual(String((ada.sections && ada.sections.Mandate) || "").trim(), "",
223
+ "reviewer output cannot write a mandate (owner-action-only policy field)");
224
+ assert.strictEqual(ada.sections.Style, "Concise and technical.", "persona fields other than policy still land");
225
+
215
226
  const captureRecord = JSON.parse(fs.readFileSync(capture, "utf8"));
216
227
  assert.ok(!fs.readFileSync(capture, "utf8").includes(secret), "private review text never enters argv or fixture logs");
217
228
  assert.strictEqual(captureRecord.stdin.present, true);