@inetafrica/open-claudia 3.1.24 → 3.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/bin/cli.js +2 -2
- package/bin/keyring.js +19 -0
- package/bin/schedule.js +4 -3
- package/core/actions.js +140 -15
- package/core/context.js +22 -2
- package/core/dream.js +23 -3
- package/core/enforcer.js +184 -72
- package/core/entities.js +5 -1
- package/core/handlers.js +16 -6
- package/core/mandates.js +160 -0
- package/core/pack-review.js +8 -6
- package/core/providers/claude-hook.js +10 -10
- package/core/providers/codex-hook.js +5 -1
- package/core/relationship.js +4 -1
- package/core/router.js +51 -13
- package/core/scheduler.js +62 -2
- package/core/tool-guard.js +100 -1
- package/docs/permissions-audit-2026-07.md +324 -0
- package/package.json +4 -2
- package/test-approval-cards.js +184 -0
- package/test-enforcer.js +137 -0
- package/test-external-rule-scopes.js +8 -0
- package/test-fixtures/fake-agent-cli.js +1 -1
- package/test-mandate-hygiene.js +147 -0
- package/test-persona-pipeline.js +22 -8
- package/test-provider-dream.js +14 -2
- package/test-provider-enforcer.js +5 -1
- package/test-provider-gateway.js +16 -0
- package/test-provider-language.js +18 -1
- package/test-provider-pack-review.js +12 -1
- package/test-provider-scheduler.js +55 -22
- package/test-provider-tool-hooks.js +4 -0
- package/test-tool-guard.js +118 -0
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 ||
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
247
|
+
"Do the policy layers clearly permit this? Output ONLY the JSON verdict.",
|
|
196
248
|
].join("\n");
|
|
197
249
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
264
330
|
}
|
|
265
331
|
|
|
266
|
-
// Post the Approve / Deny /
|
|
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 || "
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
|
298
|
-
{ text:
|
|
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 === "
|
|
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-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1785
|
-
|
|
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
|
|
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
|
});
|
package/core/mandates.js
ADDED
|
@@ -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
|
+
};
|