@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/core/enforcer.js CHANGED
@@ -21,7 +21,18 @@ const crypto = require("crypto");
21
21
  const { spawnUtilityAgent } = require("./utility-agent");
22
22
  const { redactSensitive } = require("./redact");
23
23
 
24
- const ENFORCER_TIMEOUT_MS = Number(process.env.ENFORCER_TIMEOUT_MS || 30000);
24
+ const ENFORCER_TIMEOUT_MS = Number(process.env.ENFORCER_TIMEOUT_MS || 60000);
25
+ // A guard failure that is transport, not verdict: the guard spawns a fresh
26
+ // provider CLI per call, so a cold-start timeout, a momentary concurrency
27
+ // spike, or a spawn miss is worth one retry before failing closed. A bad or
28
+ // unparseable verdict, a provider/auth error, etc. are NOT retried — they fail
29
+ // closed on the first occurrence (never open).
30
+ const TRANSIENT_GUARD_CODES = new Set(["UTILITY_TIMEOUT", "UTILITY_CONCURRENCY_LIMIT", "UTILITY_SPAWN_FAILED"]);
31
+
32
+ function transientGuardFailure(error) {
33
+ const code = error?.code || error?.result?.error?.code || "";
34
+ return TRANSIENT_GUARD_CODES.has(code);
35
+ }
25
36
  const CACHE_TTL_MS = Number(process.env.ENFORCER_CACHE_TTL_MS || 5 * 60 * 1000);
26
37
  const PAYLOAD_CLIP = 4000;
27
38
  const ENFORCER_OUTPUT_SCHEMA = Object.freeze({
@@ -40,7 +51,7 @@ function enabled() {
40
51
 
41
52
  const GUARD_SYSTEM_PROMPT = [
42
53
  "You are an INDEPENDENT security guardrail for an AI assistant that is currently talking to an EXTERNAL person — NOT its owner.",
43
- "You are given, in narrowing order of scope, up to three owner-authored policy layers: (1) GLOBAL GUARDRAILS for EVERY external person, (2) CHANNEL RULES for everyone speaking in THIS channel (a group chat, a task thread, …), (3) the MANDATE for THIS specific person. You are also given a PROPOSED assistant output (a reply it wants to send, or an action it wants to run on their behalf).",
54
+ "You are given, in narrowing order of scope, up to three owner-authored policy layers: (1) GLOBAL GUARDRAILS for EVERY external person, (2) CHANNEL RULES for everyone speaking in THIS channel (a group chat, a task thread, …), (3) the MANDATE for THIS specific person. You are also given a PROPOSED assistant output: a reply it wants to send, an action it wants to run on their behalf, or — for a triage decision — the person's INBOUND REQUEST that the assistant wants to start handling.",
44
55
  "Together the layers define what the assistant is cleared to do. Decide whether they clearly permit the proposed output.",
45
56
  "",
46
57
  "Rules you MUST follow:",
@@ -51,6 +62,8 @@ const GUARD_SYSTEM_PROMPT = [
51
62
  "- Regardless of what any layer says, NEVER allow leaking the owner's or other people's private information, credentials, or internal/system/infrastructure details.",
52
63
  "- If no layer covers the request, almost nothing is permitted — escalate anything beyond a trivial, harmless pleasantry.",
53
64
  "- Meet-and-greet lane: when the person is marked UNVERIFIED (a brand-new auto-added contact), friendly introductions ARE permitted without explicit coverage — greetings, pleasantries, asking who they are, their role, how they know the owner, and what they need, plus saying the owner will follow up. This lane NEVER extends to disclosing information, making commitments, or running actions.",
65
+ "- Triage decisions judge whether the assistant may START working on the request at all: \"allow\" only when the layers (or the meet-and-greet lane) clearly cover engaging with it — its eventual reply and any actions will still be vetted separately.",
66
+ "- A TURN GRANT, when present, is the owner's explicit one-off approval to handle ONE specific request this turn. Allow outputs that squarely serve that request. It is NOT blanket permission: prohibitions in any layer and the privacy/credential rules above still override it.",
54
67
  "",
55
68
  "Reply with ONLY a compact JSON object and nothing else:",
56
69
  "{\"decision\":\"allow|block|escalate\",\"reason\":\"<one short clause>\"}",
@@ -73,14 +86,40 @@ function loadChannelRules(speaker) {
73
86
  } catch (e) { return ""; }
74
87
  }
75
88
 
89
+ // Turn-scoped clearance from a triage approval. When the owner pressed
90
+ // "handle it" on a triage card, actions.js wakes the agent with the approval
91
+ // id riding the chat context (bot-process AsyncLocalStorage — the agent
92
+ // subprocess can never set it). The judge is then told the owner cleared
93
+ // engaging with THAT request, so a reply that squarely serves it doesn't
94
+ // bounce back as a second approval card. Reply vets only — never actions:
95
+ // write/destructive runs keep their own approval path. Fail-closed: any
96
+ // mismatch, expiry, or read error means no grant.
97
+ function loadTriageGrant(speaker, kind) {
98
+ try {
99
+ if (kind !== "reply" || !speaker) return "";
100
+ const ctx = require("./context");
101
+ const id = typeof ctx.currentTriageApprovalId === "function" ? ctx.currentTriageApprovalId() : "";
102
+ if (!id) return "";
103
+ const approvals = require("./approvals");
104
+ const rec = approvals.read(String(id));
105
+ if (!rec || rec.status !== "approved" || rec.kind !== "triage") return "";
106
+ if (String(rec.targetAdapter) !== String(speaker.adapterType)) return "";
107
+ if (String(rec.targetChannel) !== String(speaker.channelId)) return "";
108
+ if (Date.now() - Date.parse(rec.requestedAt) > 24 * 60 * 60 * 1000) return "";
109
+ const ask = String(rec.inboundText || "").replace(/\s+/g, " ").trim().slice(0, 300);
110
+ if (!ask) return "";
111
+ return `The owner explicitly approved the assistant handling this specific request from this person, for this turn only: "${ask}"`;
112
+ } catch (e) { return ""; }
113
+ }
114
+
76
115
  // verdict cache: identical (kind, relationship, layers, payload) → same answer
77
116
  // within a short TTL, so a chatty external turn that re-checks the same reply
78
117
  // doesn't re-bill the model (R9). Every policy layer (and the unverified flag)
79
118
  // is part of the key so an edit to any of them busts stale verdicts.
80
119
  const cache = new Map();
81
- function cacheKey({ kind, relationship, mandate, guardrails, channelRules, unverified, payload, tier }) {
120
+ function cacheKey({ kind, relationship, mandate, guardrails, channelRules, turnGrant, unverified, payload, tier }) {
82
121
  return crypto.createHash("sha1")
83
- .update([kind, relationship || "", tier || "", guardrails || "", channelRules || "", mandate || "", unverified ? "u" : "", payload || ""].join("\0"))
122
+ .update([kind, relationship || "", tier || "", guardrails || "", channelRules || "", mandate || "", turnGrant || "", unverified ? "u" : "", payload || ""].join("\0"))
84
123
  .digest("hex");
85
124
  }
86
125
  function cacheGet(key) {
@@ -132,6 +171,7 @@ function logVerdict(audit, context, result) {
132
171
  fallbackUsed: !!result.fallbackUsed,
133
172
  status: result.status,
134
173
  cached: !!result.cached,
174
+ ...(result.retried ? { retried: true } : {}),
135
175
  ...(result.code ? { code: result.code } : {}),
136
176
  });
137
177
  }
@@ -146,9 +186,12 @@ function createEnforcerService(dependencies = {}) {
146
186
  // Disabled is the only allow-without-judgement path; every model/provider
147
187
  // error, timeout, or invalid schema fails closed to escalation.
148
188
  async function verdict({ kind, speaker, payload, tier, provider = null }) {
149
- const mandate = (speaker && speaker.mandate) || "";
189
+ // Defensive clip: the mandate is owner-authored entity text with a write
190
+ // cap, but a hand-edited file must still never balloon the judge prompt.
191
+ const mandate = String((speaker && speaker.mandate) || "").slice(0, 6000);
150
192
  const guardrails = loadGlobalGuardrails();
151
193
  const channelRules = loadChannelRules(speaker);
194
+ const turnGrant = loadTriageGrant(speaker, kind);
152
195
  const relationship = (speaker && speaker.relationship) || "external";
153
196
  const unverified = !!(speaker && speaker.person && speaker.person.autoCreated);
154
197
  const clipped = String(payload || "").slice(0, PAYLOAD_CLIP);
@@ -158,7 +201,7 @@ function createEnforcerService(dependencies = {}) {
158
201
  logVerdict(audit, auditContext, disabled);
159
202
  return disabled;
160
203
  }
161
- const key = `${namespace}:${cacheKey({ kind, relationship, mandate, guardrails, channelRules, unverified, payload: clipped, tier })}`;
204
+ const key = `${namespace}:${cacheKey({ kind, relationship, mandate, guardrails, channelRules, turnGrant, unverified, payload: clipped, tier })}`;
162
205
  const hit = cacheGet(key);
163
206
  if (hit) {
164
207
  const cached = { ...hit, status: "cached", cached: true };
@@ -182,57 +225,80 @@ function createEnforcerService(dependencies = {}) {
182
225
  mandate || "(none — the owner has authored no mandate for this person)",
183
226
  "MANDATE>>>",
184
227
  "",
228
+ ...(turnGrant ? [
229
+ "TURN GRANT (owner-approved, THIS turn only):",
230
+ "<<<TURNGRANT",
231
+ turnGrant,
232
+ "TURNGRANT>>>",
233
+ "",
234
+ ] : []),
185
235
  `Relationship: ${relationship}`,
186
236
  `Person: ${(speaker && speaker.name) || "unknown external contact"}${unverified ? " (UNVERIFIED — brand-new auto-added contact)" : ""}`,
187
237
  "",
188
238
  kind === "reply"
189
239
  ? "PROPOSED REPLY the assistant wants to SEND to this person:"
190
- : `PROPOSED ACTION (tier: ${tier || "write"}) the assistant wants to RUN on this person's behalf:`,
240
+ : kind === "triage"
241
+ ? "INBOUND REQUEST from this person — the assistant wants to start HANDLING it (triage decision):"
242
+ : `PROPOSED ACTION (tier: ${tier || "write"}) the assistant wants to RUN on this person's behalf:`,
191
243
  "<<<PROPOSED",
192
244
  clipped,
193
245
  "PROPOSED>>>",
194
246
  "",
195
- "Do the global guardrails or this person's mandate clearly permit this? Output ONLY the JSON verdict.",
247
+ "Do the policy layers clearly permit this? Output ONLY the JSON verdict.",
196
248
  ].join("\n");
197
249
 
198
- let utilityResult = null;
199
- try {
200
- utilityResult = await runUtility(userPrompt, {
201
- purpose: "enforcer",
202
- provider,
203
- tier: "medium",
204
- modelEnvPrefix: "ENFORCER_MODEL",
205
- legacyClaudeModelEnv: "ENFORCER_MODEL",
206
- readOnly: true,
207
- outputSchema: ENFORCER_OUTPUT_SCHEMA,
208
- systemPrompt: GUARD_SYSTEM_PROMPT,
209
- timeoutMs,
210
- cwd: process.cwd(),
211
- });
212
- let obj = utilityResult.json;
213
- if (!obj) {
214
- const match = String(utilityResult.text || "").match(/\{[\s\S]*?\}/);
215
- if (!match) throw Object.assign(new Error("unparseable enforcer verdict"), { code: "ENFORCER_OUTPUT_INVALID" });
216
- obj = JSON.parse(match[0]);
217
- }
218
- if (!obj || !VALID.has(obj.decision) || typeof obj.reason !== "string") {
219
- throw Object.assign(new Error("invalid enforcer verdict"), { code: "ENFORCER_OUTPUT_INVALID" });
250
+ // Judge with one retry on a TRANSIENT guard failure before failing closed.
251
+ // The guard already fails closed on any error; the retry only shrinks the
252
+ // window where a transport blip (cold-start timeout, concurrency spike,
253
+ // spawn miss) needlessly escalates a reply the rules actually permit. It
254
+ // never fails open and never retries a real verdict.
255
+ const maxAttempts = 2;
256
+ let lastError = null;
257
+ let lastResult = null;
258
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
259
+ let utilityResult = null;
260
+ try {
261
+ utilityResult = await runUtility(userPrompt, {
262
+ purpose: "enforcer",
263
+ provider,
264
+ tier: "medium",
265
+ modelEnvPrefix: "ENFORCER_MODEL",
266
+ legacyClaudeModelEnv: "ENFORCER_MODEL",
267
+ readOnly: true,
268
+ outputSchema: ENFORCER_OUTPUT_SCHEMA,
269
+ systemPrompt: GUARD_SYSTEM_PROMPT,
270
+ timeoutMs,
271
+ cwd: process.cwd(),
272
+ });
273
+ let obj = utilityResult.json;
274
+ if (!obj) {
275
+ const match = String(utilityResult.text || "").match(/\{[\s\S]*?\}/);
276
+ if (!match) throw Object.assign(new Error("unparseable enforcer verdict"), { code: "ENFORCER_OUTPUT_INVALID" });
277
+ obj = JSON.parse(match[0]);
278
+ }
279
+ if (!obj || !VALID.has(obj.decision) || typeof obj.reason !== "string") {
280
+ throw Object.assign(new Error("invalid enforcer verdict"), { code: "ENFORCER_OUTPUT_INVALID" });
281
+ }
282
+ const out = {
283
+ decision: obj.decision,
284
+ reason: String(obj.reason).slice(0, 200),
285
+ ...providerMetadata(utilityResult),
286
+ status: "judged",
287
+ ...(attempt > 1 ? { retried: true } : {}),
288
+ };
289
+ cacheSet(key, out);
290
+ logVerdict(audit, auditContext, out);
291
+ return out;
292
+ } catch (error) {
293
+ lastError = error;
294
+ lastResult = utilityResult || error.result || null;
295
+ if (attempt < maxAttempts && transientGuardFailure(error)) continue;
296
+ break;
220
297
  }
221
- const out = {
222
- decision: obj.decision,
223
- reason: String(obj.reason).slice(0, 200),
224
- ...providerMetadata(utilityResult),
225
- status: "judged",
226
- };
227
- cacheSet(key, out);
228
- logVerdict(audit, auditContext, out);
229
- return out;
230
- } catch (error) {
231
- const result = utilityResult || error.result || null;
232
- const out = failClosed(error.message || "enforcer error", result, error.code || result?.error?.code);
233
- logVerdict(audit, auditContext, out);
234
- return out;
235
298
  }
299
+ const out = failClosed(lastError?.message || "enforcer error", lastResult, lastError?.code || lastResult?.error?.code);
300
+ logVerdict(audit, auditContext, out);
301
+ return out;
236
302
  }
237
303
 
238
304
  return {
@@ -263,39 +329,44 @@ function esc(s) {
263
329
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
264
330
  }
265
331
 
266
- // Post the Approve / Deny / Always-allow keyboard to the OWNER's channel. The
332
+ // Post the Approve / Deny / Make-a-rule keyboard to the OWNER's channel. The
267
333
  // apr: button handler (actions.js) is owner-gated and idempotent, so only the
268
334
  // owner can decide and a double-tap can't flip it.
335
+ //
336
+ // Framing: the owner is deciding whether the bot should ACT on a request from
337
+ // this person — the card leads with WHO is asking WHAT, then what the bot
338
+ // proposes to do about it, never with the bot's output as the headline.
269
339
  async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
270
340
  const adapter = adapterFor(owner.adapter);
271
341
  if (!adapter) return false;
272
- const who = rec.personName || "an external contact";
342
+ const who = rec.personName || "An external contact";
273
343
  const where = rec.channelLabel ? ` in ${esc(rec.channelLabel)}` : "";
274
344
  const ask = String(rec.inboundText || "").slice(0, 500);
275
- const askBlock = ask
276
- ? [`<b>From ${esc(who)}${where}:</b>`, `<pre>${esc(ask)}</pre>`, ``]
277
- : [`<b>From ${esc(who)}${where}</b>`, ``];
278
- const lines = kind === "reply"
279
- ? [
280
- `🛡️ <b>External guardrail</b> — a reply needs your OK:`,
281
- ``,
282
- ...askBlock,
283
- `<b>Proposed reply:</b>`,
284
- `<pre>${esc(String(summary || rec.payloadText || "").slice(0, 700))}</pre>`,
285
- reason ? `\nWhy flagged: ${esc(reason)}` : ``,
286
- ]
287
- : [
288
- `🛡️ <b>External guardrail</b> — ${esc(who)} wants a ${esc(rec.tier)} action:`,
289
- ``,
290
- ...askBlock,
291
- `<b>Proposed action:</b>`,
292
- `<pre>${esc(String(rec.command || ""))}</pre>`,
293
- reason ? `\nWhy flagged: ${esc(reason)}` : ``,
294
- ];
345
+ const askBlock = ask ? [`<b>They asked:</b>`, `<pre>${esc(ask)}</pre>`, ``] : [];
346
+ let head, body;
347
+ if (kind === "reply") {
348
+ head = `🛡️ <b>${esc(who)}</b>${where} asked me something — should I act on it?`;
349
+ body = [
350
+ `<b>I'd reply:</b>`,
351
+ `<pre>${esc(String(summary || rec.payloadText || "").slice(0, 700))}</pre>`,
352
+ ];
353
+ } else if (kind === "triage") {
354
+ head = `🛡️ <b>${esc(who)}</b>${where} wants me to handle a request — should I?`;
355
+ body = ask ? [] : [`<i>(no request text captured)</i>`];
356
+ } else {
357
+ head = `🛡️ <b>${esc(who)}</b>${where} wants me to run a <b>${esc(rec.tier)}</b> action — should I?`;
358
+ body = [
359
+ `<b>The exact command:</b>`,
360
+ `<pre>${esc(String(rec.command || ""))}</pre>`,
361
+ ];
362
+ }
363
+ const lines = [head, ``, ...askBlock, ...body, reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
364
+ const yes = kind === "triage" ? "✅ Yes, handle it" : "✅ Approve once";
365
+ const no = kind === "triage" ? "⛔ No, decline" : "⛔ Deny";
295
366
  const keyboard = { inline_keyboard: [
296
367
  [
297
- { text: "✅ Approve once", callback_data: `apr:${rec.id}:ok` },
298
- { text: "⛔ Deny", callback_data: `apr:${rec.id}:no` },
368
+ { text: yes, callback_data: `apr:${rec.id}:ok` },
369
+ { text: no, callback_data: `apr:${rec.id}:no` },
299
370
  ],
300
371
  [
301
372
  { text: "📝 Make a rule…", callback_data: `apr:${rec.id}:rule` },
@@ -359,10 +430,12 @@ async function escalate({
359
430
  }
360
431
  } catch (e) {}
361
432
  const rec = approvals.create({
362
- tool: kind === "reply" ? "external-reply" : "external-action",
433
+ tool: kind === "reply" ? "external-reply" : kind === "triage" ? "external-triage" : "external-action",
363
434
  verb: "",
364
- tier: kind === "reply" ? "reply" : (tier || "write"),
365
- command: kind === "reply" ? "(deliver vetted reply)" : String(command || ""),
435
+ tier: kind === "reply" ? "reply" : kind === "triage" ? "triage" : (tier || "write"),
436
+ command: kind === "action" ? String(command || "")
437
+ : kind === "triage" ? "(handle held external request)"
438
+ : "(deliver vetted reply)",
366
439
  channelId: String(speaker.channelId), // origin = the external speaker
367
440
  adapter: String(speaker.adapterType),
368
441
  canonicalUserId,
@@ -417,7 +490,46 @@ async function guardOutboundReply(text) {
417
490
  return { allow: false, guarded: true, decision: v.decision, reason: v.reason, escalated: e.escalated, escalationId: e.id };
418
491
  }
419
492
 
493
+ // The router-facing up-front gate: on a guarded external turn the INBOUND
494
+ // request itself is judged BEFORE any agent work happens. allow → the turn
495
+ // proceeds (its reply and actions are still vetted separately — triage is an
496
+ // early advisory gate, the output vets stay authoritative). Anything else →
497
+ // no generation at all: the owner gets a "should I handle this?" card and the
498
+ // external gets the holding line. Approval re-runs enter via the scheduler,
499
+ // never the router, so an approved turn cannot re-triage itself into a loop.
500
+ //
501
+ // `pinning` carries the provider/project/user context the approval record
502
+ // needs so the approve-button wakeup can resume in the ORIGIN channel.
503
+ // Contract: never throws; an internal error on a guarded turn holds the
504
+ // message (fail closed), and owner/no-context turns are always untouched.
505
+ async function guardInboundTriage(text, pinning = {}) {
506
+ let speaker = null;
507
+ try {
508
+ const relationship = require("./relationship");
509
+ speaker = relationship.currentSpeaker();
510
+ if (!relationship.guardActive(speaker)) return { held: false, guarded: false };
511
+ const inbound = String(text || "").trim();
512
+ if (!inbound) return { held: false, guarded: true };
513
+ const v = await verdict({ kind: "triage", speaker, payload: inbound });
514
+ if (v.decision === "allow") return { held: false, guarded: true, decision: "allow" };
515
+ const e = await escalate({
516
+ speaker, kind: "triage", payloadText: "", summary: inbound, reason: v.reason,
517
+ canonicalUserId: String(pinning.canonicalUserId || ""),
518
+ userId: String(pinning.userId || ""),
519
+ provider: String(pinning.provider || ""),
520
+ project: String(pinning.project || ""),
521
+ projectDir: String(pinning.projectDir || ""),
522
+ });
523
+ return { held: true, guarded: true, decision: v.decision, reason: v.reason, escalated: e.escalated, escalationId: e.id };
524
+ } catch (err) {
525
+ if (!speaker || speaker.isOwner) return { held: false, guarded: false };
526
+ const audit = safeAudit();
527
+ if (audit) audit.log("enforcer.triage.failed", { reason: String(err?.message || err).slice(0, 160) });
528
+ return { held: true, guarded: true, decision: "escalate", reason: "triage failed — holding the request" };
529
+ }
530
+ }
531
+
420
532
  module.exports = {
421
533
  createEnforcerService, enabled, verdict, vetReply, vetAction, escalate, guardOutboundReply,
422
- postOwnerButtons, ENFORCER_OUTPUT_SCHEMA, GUARD_SYSTEM_PROMPT,
534
+ guardInboundTriage, postOwnerButtons, ENFORCER_OUTPUT_SCHEMA, GUARD_SYSTEM_PROMPT,
423
535
  };
package/core/entities.js CHANGED
@@ -28,6 +28,10 @@ const ENTITIES_DIR = process.env.ENTITIES_DIR ? path.resolve(process.env.ENTITIE
28
28
 
29
29
  const TYPES = ["person", "place", "project", "org", "system", "thing"];
30
30
  const MAX_LOG_ENTRIES = 40;
31
+ // Write cap for a person's Mandate section. Mandates are security-relevant
32
+ // text injected into the guard prompt, so growth is bounded at the writers
33
+ // (approval always-allow, rule capture) — never silently truncated on read.
34
+ const MANDATE_MAX_CHARS = 4000;
31
35
  // Persona-pack sections layered on top of the base Notes/Log. Additive: only
32
36
  // serialized when populated, so ordinary entities are untouched (R7).
33
37
  const PERSONA_SECTIONS = ["Role", "Style", "Knows", "Mandate"];
@@ -429,7 +433,7 @@ function matchEntities(text, { limit = 4, threshold = null } = {}) {
429
433
  }
430
434
 
431
435
  module.exports = {
432
- ENTITIES_DIR, TYPES, slugify,
436
+ ENTITIES_DIR, TYPES, slugify, MANDATE_MAX_CHARS,
433
437
  PERSONA_SECTIONS, RELATIONSHIPS, normalizeRelationship,
434
438
  listEntities, findEntity, readEntity, writeEntity, upsertEntity, removeEntity,
435
439
  touchSeen, entityNameFromPath, matchEntities, reindex, markIndexDirty,
package/core/handlers.js CHANGED
@@ -1752,7 +1752,7 @@ register({
1752
1752
  });
1753
1753
 
1754
1754
  register({
1755
- name: "guardrails", description: "View/manage rules for external people (global + per-channel)", args: "[add <rule> | remove <id>]", ownerOnly: true,
1755
+ name: "guardrails", description: "View/manage rules for external people (global + channel + per-person)", args: "[add <rule> | remove <id> | revoke <id>]", ownerOnly: true,
1756
1756
  handler: async (env, { tail }) => {
1757
1757
  if (!ownerEnv(env)) return send("Owner only — global guardrails are restricted.");
1758
1758
  const gr = require("./external-guardrails");
@@ -1770,19 +1770,25 @@ register({
1770
1770
  }
1771
1771
 
1772
1772
  const cr = require("./channel-rules");
1773
+ const md = require("./mandates");
1773
1774
 
1774
- if (subCmd === "remove" || subCmd === "rm") {
1775
+ // remove and revoke are the same operation across all three layers —
1776
+ // "revoke" reads naturally for per-person grants, "remove" for rules.
1777
+ if (subCmd === "remove" || subCmd === "rm" || subCmd === "revoke") {
1775
1778
  const key = restArr.join(" ").trim();
1776
- if (!key) return send("Usage: /guardrails remove <id>");
1779
+ if (!key) return send(`Usage: /guardrails ${subCmd} <id>`);
1777
1780
  if (gr.removeGuardrail(key)) return send(`Removed ${key}.`);
1778
1781
  if (cr.removeRule(key)) return send(`Removed channel rule ${key}.`);
1782
+ const hit = md.revokeLine(key);
1783
+ if (hit) return send(`Revoked [${hit.id}] from ${hit.name}'s mandate: ${hit.text}`);
1779
1784
  return send(`No matching guardrail: ${key}`);
1780
1785
  }
1781
1786
 
1782
1787
  const all = gr.listGuardrails();
1783
1788
  const channels = cr.listChannels().filter((c) => c.rules.length > 0);
1784
- if (all.length === 0 && channels.length === 0) {
1785
- return send(`No guardrails yet. These are rules I apply to external people — globally, or per channel (make one from an approval card). Add a global one: /guardrails add <rule>\n\ne.g. /guardrails add never discuss pricing or staff details\n\nFile: ${gr.GUARDRAILS_FILE}`);
1789
+ const persons = md.listPersonMandates();
1790
+ if (all.length === 0 && channels.length === 0 && persons.length === 0) {
1791
+ return send(`No guardrails yet. These are rules I apply to external people — globally, per channel, or per person (make one from an approval card). Add a global one: /guardrails add <rule>\n\ne.g. /guardrails add never discuss pricing or staff details\n\nFile: ${gr.GUARDRAILS_FILE}`);
1786
1792
  }
1787
1793
  const parts = [];
1788
1794
  if (all.length > 0) {
@@ -1795,8 +1801,12 @@ register({
1795
1801
  const lines = c.rules.map((r) => `👥 [${r.id}] ${r.text}`);
1796
1802
  parts.push(`Channel rules — ${c.label || c.key}:\n${lines.join("\n")}`);
1797
1803
  }
1804
+ for (const p of persons) {
1805
+ const lines = p.lines.map((l) => `[${l.id}] ${l.text}${l.expiresAt ? ` · until ${l.expiresAt}` : ""}`);
1806
+ parts.push(`👤 ${p.name} (${p.slug}):\n${lines.join("\n")}`);
1807
+ }
1798
1808
  let msg = parts.join("\n\n");
1799
- msg += `\n\n/guardrails add <rule> · /guardrails remove <id>\nChannel rules are made from approval cards (“Make a rule → This channel”).\nPer-person rules live in each person's note (Mandate).\nFiles: ${gr.GUARDRAILS_FILE} · ${cr.CHANNEL_RULES_FILE}`;
1809
+ msg += `\n\n/guardrails add <rule> · /guardrails revoke <id>\nChannel + person rules are made from approval cards; Always-allow grants expire after ${md.GRANT_TTL_DAYS || "∞"} days (re-approve to renew).\nFiles: ${gr.GUARDRAILS_FILE} · ${cr.CHANNEL_RULES_FILE}`;
1800
1810
  return send(msg);
1801
1811
  },
1802
1812
  });
@@ -0,0 +1,160 @@
1
+ // Per-person mandate lines as first-class, revocable grants — parsing, stable
2
+ // ids, and expiry for the Mandate section of a person's entity note.
3
+ //
4
+ // The mandate is the third policy layer the enforcer judges (floor > global
5
+ // guardrails > channel rules > MANDATE > turn grant). Global and channel rules
6
+ // already have hash ids and /guardrails remove; this module gives mandate
7
+ // lines the same lifecycle so an approval-card grant is a scoped capability,
8
+ // not a permanent blanket:
9
+ // - id: sha1(slug + normalized line text) — slug-scoped, so the same
10
+ // capability granted to two people revokes independently.
11
+ // - expiry: a trailing " [until YYYY-MM-DD]" marker (UTC date, inclusive).
12
+ // Filtering is DETERMINISTIC at read time (activeMandateText) — the guard
13
+ // judge never does date math, an expired line simply never reaches it.
14
+ // - revoke: /guardrails revoke <id> removes exactly one line from exactly
15
+ // one person.
16
+ //
17
+ // Dedup and ids ignore the expiry marker: re-granting the same capability
18
+ // refreshes its expiry instead of stacking duplicate lines.
19
+
20
+ const crypto = require("crypto");
21
+
22
+ // Trailing expiry marker, always the last thing on a line.
23
+ const EXPIRY_RE = /\s*\[until (\d{4}-\d{2}-\d{2})\]\s*$/;
24
+
25
+ // Tap-grants ("Always allow" on an approval card) default to a bounded TTL —
26
+ // a guardrail, not a blanket. 0 = permanent. Owner-authored rules pass their
27
+ // own expiry (usually none).
28
+ const GRANT_TTL_DAYS = Math.max(0, Number(process.env.MANDATE_GRANT_TTL_DAYS || 30));
29
+
30
+ function utcDate(ms) {
31
+ return new Date(ms).toISOString().slice(0, 10);
32
+ }
33
+
34
+ function normalize(text) {
35
+ return String(text || "").toLowerCase().replace(/\s+/g, " ").trim();
36
+ }
37
+
38
+ // Split a line into its capability text and optional expiry date.
39
+ function splitExpiry(line) {
40
+ const s = String(line || "").trim();
41
+ const m = s.match(EXPIRY_RE);
42
+ if (!m) return { text: s, expiresAt: null };
43
+ return { text: s.replace(EXPIRY_RE, "").trim(), expiresAt: m[1] };
44
+ }
45
+
46
+ function formatLine(text, expiresAt) {
47
+ const core = String(text || "").replace(/\s+/g, " ").trim();
48
+ return expiresAt ? `${core} [until ${expiresAt}]` : core;
49
+ }
50
+
51
+ function isExpired(expiresAt, now) {
52
+ if (!expiresAt) return false;
53
+ return utcDate(now === undefined ? Date.now() : now) > expiresAt; // inclusive of the named day
54
+ }
55
+
56
+ function lineId(slug, lineText) {
57
+ const { text } = splitExpiry(lineText);
58
+ return crypto.createHash("sha1")
59
+ .update(`${normalize(slug)}\0${normalize(text)}`)
60
+ .digest("hex").slice(0, 8);
61
+ }
62
+
63
+ // Parse a Mandate section into entries. Only "- "/"* " bullets are grants;
64
+ // anything else survives hand-edits but carries no id.
65
+ function parseMandate(slug, sectionText, now) {
66
+ const out = [];
67
+ for (const line of String(sectionText || "").split("\n")) {
68
+ const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
69
+ if (!m) continue;
70
+ const { text, expiresAt } = splitExpiry(m[1]);
71
+ if (!text) continue;
72
+ out.push({ id: lineId(slug, text), text, expiresAt, expired: isExpired(expiresAt, now), raw: line });
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Read-side filter for the guard judge: drop expired bullets, keep everything
78
+ // else byte-for-byte. This is the single choke point (relationship.speakerFor)
79
+ // so every consumer of speaker.mandate sees only live grants.
80
+ function activeMandateText(sectionText, now) {
81
+ return String(sectionText || "")
82
+ .split("\n")
83
+ .filter((line) => {
84
+ const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
85
+ if (!m) return true;
86
+ return !isExpired(splitExpiry(m[1]).expiresAt, now);
87
+ })
88
+ .join("\n");
89
+ }
90
+
91
+ function defaultGrantExpiry(now) {
92
+ if (!GRANT_TTL_DAYS) return null;
93
+ return utcDate((now === undefined ? Date.now() : now) + GRANT_TTL_DAYS * 24 * 60 * 60 * 1000);
94
+ }
95
+
96
+ // Owner rule text may carry its own expiry phrase: "... until 2026-08-30" or
97
+ // "... for 14d". Extracted here so the stored line uses the one marker format.
98
+ function extractExpiry(text, now) {
99
+ const s = String(text || "").trim();
100
+ let m = s.match(/\s+until\s+(\d{4}-\d{2}-\d{2})$/i);
101
+ if (m) return { text: s.slice(0, m.index).trim(), expiresAt: m[1] };
102
+ m = s.match(/\s+for\s+(\d{1,4})\s*d(?:ays?)?$/i);
103
+ if (m) {
104
+ const days = Math.max(1, Number(m[1]));
105
+ return { text: s.slice(0, m.index).trim(), expiresAt: utcDate((now === undefined ? Date.now() : now) + days * 24 * 60 * 60 * 1000) };
106
+ }
107
+ return { text: s, expiresAt: null };
108
+ }
109
+
110
+ // Every person-shaped entity carrying live grants, for /guardrails listing.
111
+ function listPersonMandates(now) {
112
+ const entities = require("./entities");
113
+ const out = [];
114
+ for (const ent of entities.listEntities()) {
115
+ const section = String((ent.sections && ent.sections.Mandate) || "").trim();
116
+ if (!section) continue;
117
+ const lines = parseMandate(ent.slug, section, now).filter((l) => !l.expired);
118
+ if (lines.length === 0) continue;
119
+ out.push({ slug: ent.slug, name: ent.name, lines });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // Remove exactly one grant by id across all entities. Returns what was
125
+ // revoked, or null when no line matches. Clearing the LAST line writes the
126
+ // section empty directly — upsertEntity treats "" as "leave unchanged".
127
+ function revokeLine(id) {
128
+ const key = String(id || "").trim().toLowerCase();
129
+ if (!key) return null;
130
+ const entities = require("./entities");
131
+ for (const ent of entities.listEntities()) {
132
+ const section = String((ent.sections && ent.sections.Mandate) || "");
133
+ if (!section.trim()) continue;
134
+ const entries = parseMandate(ent.slug, section);
135
+ const hit = entries.find((l) => l.id === key);
136
+ if (!hit) continue;
137
+ const remaining = section
138
+ .split("\n")
139
+ .filter((line) => line !== hit.raw)
140
+ .join("\n")
141
+ .trim();
142
+ entities.upsertEntity({
143
+ slug: ent.slug, name: ent.name,
144
+ log: `owner revoked mandate line [${hit.id}]: ${hit.text.slice(0, 80)}`,
145
+ });
146
+ const fresh = entities.readEntity(ent.slug);
147
+ fresh.sections.Mandate = remaining;
148
+ fresh.updated = new Date().toISOString();
149
+ entities.writeEntity(fresh);
150
+ return { slug: ent.slug, name: ent.name, id: hit.id, text: hit.text, expiresAt: hit.expiresAt };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ module.exports = {
156
+ GRANT_TTL_DAYS,
157
+ normalize, splitExpiry, formatLine, isExpired, lineId,
158
+ parseMandate, activeMandateText, defaultGrantExpiry, extractExpiry,
159
+ listPersonMandates, revokeLine,
160
+ };