@tpsdev-ai/flair 0.51.2 → 0.52.0
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/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relay-ops.ts — Flair Relay S1 orchestration (send / inbox / consume / sweep /
|
|
3
|
+
* dead-letter). Pure of Harper: every external dependency (the Message table,
|
|
4
|
+
* the Agent table, org resolution) is INJECTED, so the full behavioural surface
|
|
5
|
+
* is unit-testable with plain mocks and real ed25519 keys — no Harper instance,
|
|
6
|
+
* and none of the bun-single-process superclass-capture hazard that mocking the
|
|
7
|
+
* `harper` module invites. resources/Message.ts wires the real accessors in.
|
|
8
|
+
*
|
|
9
|
+
* All authorization decisions are made here against an already-resolved auth
|
|
10
|
+
* verdict; the resource layer only resolves the verdict and forwards the call.
|
|
11
|
+
*/
|
|
12
|
+
import { capDecision, computeContentHash, isUnconsumed, LOCAL_ORG_SENTINEL, reconcileState, sweepDecision, verifyMessageSignature, } from "./relay-lib.js";
|
|
13
|
+
// ─── HTTP responses (Response is a web global, not a Harper import) ──────────
|
|
14
|
+
const json = (status, obj) => new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
|
|
15
|
+
const UNAUTH = () => json(401, { error: "authentication required" });
|
|
16
|
+
const FORBIDDEN = (error) => json(403, { error });
|
|
17
|
+
const NOT_FOUND = () => json(404, { error: "not found" });
|
|
18
|
+
const BAD_REQUEST = (error) => json(400, { error });
|
|
19
|
+
/** A per-(from, threadId) sequence conflict — the seq is not strictly ahead of
|
|
20
|
+
* the thread's last message (Sherlock P1 seq monotonicity). */
|
|
21
|
+
const CONFLICT = (error) => json(409, { error, reason: "seq_conflict" });
|
|
22
|
+
/** Over-cap: a synchronous 4xx that names the reason — backpressure that fails loud. */
|
|
23
|
+
const INBOX_FULL = (scope) => json(429, { error: "inbox full", reason: "inbox_full", scope });
|
|
24
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Materialize a table.search() result (async iterable | iterable | promise) to
|
|
27
|
+
* an array. `conditions` are passed to search() so a real Harper backend uses
|
|
28
|
+
* the @indexed columns (`to`/`from`/`threadId`/`state` in schemas/message.graphql)
|
|
29
|
+
* instead of a full-table scan — the Sherlock P0 fix: the cap-counter is the
|
|
30
|
+
* DoS-preventer, so it must not itself be O(table). The in-code filters at each
|
|
31
|
+
* call site remain the correctness guarantee (the unit-test table double ignores
|
|
32
|
+
* the query and returns every row); the conditions are the production index hint.
|
|
33
|
+
*/
|
|
34
|
+
async function queryRows(table, conditions = []) {
|
|
35
|
+
const query = conditions.length > 0
|
|
36
|
+
? { operator: "and", conditions: conditions.map((c) => ({ attribute: c.attribute, comparator: "equals", value: c.value })) }
|
|
37
|
+
: undefined;
|
|
38
|
+
const res = table.search(query);
|
|
39
|
+
const iter = res && typeof res.then === "function" ? await res : res;
|
|
40
|
+
const out = [];
|
|
41
|
+
for await (const row of iter)
|
|
42
|
+
out.push(row);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
// `from` is REQUIRED: it is inside the SIGNED body, so it must be present BEFORE
|
|
46
|
+
// signature verification and can never be server-mutated first (Sherlock P1 —
|
|
47
|
+
// a mutated `from` would be signed-without / verified-with). A non-admin agent's
|
|
48
|
+
// `from` is enforced-equal to its authenticated id (no overwrite), not defaulted.
|
|
49
|
+
const REQUIRED_SEND_FIELDS = ["id", "from", "to", "threadId", "kind", "body", "createdAt", "signature"];
|
|
50
|
+
/** The stable "accepted" projection — identical whether or not `to` exists. */
|
|
51
|
+
function acceptedView(row) {
|
|
52
|
+
return {
|
|
53
|
+
id: row.id,
|
|
54
|
+
orgScope: row.orgScope,
|
|
55
|
+
from: row.from,
|
|
56
|
+
to: row.to,
|
|
57
|
+
threadId: row.threadId,
|
|
58
|
+
seq: row.seq,
|
|
59
|
+
kind: row.kind,
|
|
60
|
+
createdAt: row.createdAt,
|
|
61
|
+
contentHash: row.contentHash,
|
|
62
|
+
state: row.state,
|
|
63
|
+
deliveredAt: row.deliveredAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// ─── send ───────────────────────────────────────────────────────────────────
|
|
67
|
+
/**
|
|
68
|
+
* post(msg). Verifies the sender signature, resolves orgScope server-side,
|
|
69
|
+
* enforces the no-forge `from`, dedups retries, enforces the inbox cap + the
|
|
70
|
+
* per-sender sub-cap, and delivers. Returns the accepted envelope — the SAME
|
|
71
|
+
* shape regardless of whether `to` exists (no existence oracle): the only
|
|
72
|
+
* principal ever looked up is the sender (needed for its public key), which is
|
|
73
|
+
* the authenticated caller and therefore always present.
|
|
74
|
+
*/
|
|
75
|
+
export async function relaySend(deps, auth, content) {
|
|
76
|
+
if (auth.kind === "anonymous")
|
|
77
|
+
return UNAUTH();
|
|
78
|
+
for (const f of REQUIRED_SEND_FIELDS) {
|
|
79
|
+
if (content[f] === undefined || content[f] === null || content[f] === "") {
|
|
80
|
+
return BAD_REQUEST(`missing required field: ${f}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (typeof content.seq !== "number" || !Number.isInteger(content.seq) || content.seq < 0) {
|
|
84
|
+
return BAD_REQUEST("seq must be a non-negative integer");
|
|
85
|
+
}
|
|
86
|
+
// Validate `deadline` as a parseable ISO-8601 timestamp at send time. Left
|
|
87
|
+
// unvalidated, a garbage string yields NaN in sweepDecision, `NaN > now` is
|
|
88
|
+
// false, and the row sweeps to failed/deadline IMMEDIATELY (Kern nit). Absent
|
|
89
|
+
// is fine (no deadline); present-but-unparseable is a 400.
|
|
90
|
+
if (content.deadline !== undefined && content.deadline !== null) {
|
|
91
|
+
if (typeof content.deadline !== "string" || Number.isNaN(new Date(content.deadline).getTime())) {
|
|
92
|
+
return BAD_REQUEST("deadline must be a valid ISO-8601 timestamp");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// No-forge `from`: a non-admin agent can only send AS itself. `from` is a
|
|
96
|
+
// REQUIRED, SIGNED field — we ENFORCE equality (never overwrite, which would
|
|
97
|
+
// invalidate the signature) and NEVER mutate it before verification (a
|
|
98
|
+
// server-set `from` would be signed-without / verified-with — Sherlock P1).
|
|
99
|
+
if (auth.kind === "agent" && !auth.isAdmin && content.from !== auth.agentId) {
|
|
100
|
+
return FORBIDDEN("forbidden: `from` must match the authenticated agent");
|
|
101
|
+
}
|
|
102
|
+
// orgScope is server-resolved from the auth/pairing context and NEVER trusted
|
|
103
|
+
// from the body. It is inside the signed body, so the caller must have signed
|
|
104
|
+
// the correct org; any other value is a forged/foreign scope and is rejected.
|
|
105
|
+
const resolvedOrg = (await deps.resolveOrg()) ?? LOCAL_ORG_SENTINEL;
|
|
106
|
+
if (content.orgScope !== resolvedOrg) {
|
|
107
|
+
return FORBIDDEN("forbidden: orgScope is server-resolved, not settable from the body");
|
|
108
|
+
}
|
|
109
|
+
// Verify the sender's signature against the sender's pinned public key.
|
|
110
|
+
// `from` is guaranteed present by REQUIRED_SEND_FIELDS above (String() only
|
|
111
|
+
// narrows the optional type — it can never be undefined here).
|
|
112
|
+
const sender = await Promise.resolve(deps.agents.get(String(content.from))).catch(() => null);
|
|
113
|
+
if (!sender?.publicKey)
|
|
114
|
+
return FORBIDDEN("forbidden: unknown sender principal");
|
|
115
|
+
const verdict = verifyMessageSignature(content, String(sender.publicKey));
|
|
116
|
+
if (!verdict.ok)
|
|
117
|
+
return BAD_REQUEST(`signature: ${verdict.reason}`);
|
|
118
|
+
// Store the server-recomputed canonical hash (verified equal above).
|
|
119
|
+
content.contentHash = computeContentHash(content);
|
|
120
|
+
// Retry-dedup: an identical resend — same primary id, or the same content
|
|
121
|
+
// hash from this sender to this recipient — returns the already-accepted
|
|
122
|
+
// envelope. No second row, and (critically) no second charge against the cap.
|
|
123
|
+
const existingById = await Promise.resolve(deps.messages.get(String(content.id))).catch(() => null);
|
|
124
|
+
if (existingById)
|
|
125
|
+
return acceptedView(existingById);
|
|
126
|
+
// The recipient's inbox — queried by the @indexed `to`, never the whole table
|
|
127
|
+
// (Sherlock P0). Serves both dedup (same content from this sender) and the cap.
|
|
128
|
+
const recipientRows = await queryRows(deps.messages, [{ attribute: "to", value: content.to }]);
|
|
129
|
+
const dup = recipientRows.find((r) => r.from === content.from && r.to === content.to && r.contentHash === content.contentHash);
|
|
130
|
+
if (dup)
|
|
131
|
+
return acceptedView(dup);
|
|
132
|
+
// Seq monotonicity per (from, threadId): a distinct new message must be
|
|
133
|
+
// STRICTLY ahead of the thread's last seq (Sherlock P1). Checked AFTER dedup so
|
|
134
|
+
// a legitimate retry (same content, caught above) is never rejected. Queried by
|
|
135
|
+
// the @indexed `threadId`, then filtered to this sender in-code.
|
|
136
|
+
const threadRows = await queryRows(deps.messages, [{ attribute: "threadId", value: content.threadId }]);
|
|
137
|
+
let prevMaxSeq = -1;
|
|
138
|
+
for (const r of threadRows) {
|
|
139
|
+
if (r.from !== content.from || r.threadId !== content.threadId)
|
|
140
|
+
continue;
|
|
141
|
+
if (typeof r.seq === "number" && r.seq > prevMaxSeq)
|
|
142
|
+
prevMaxSeq = r.seq;
|
|
143
|
+
}
|
|
144
|
+
if (content.seq <= prevMaxSeq) {
|
|
145
|
+
return CONFLICT(`seq ${content.seq} is not ahead of the last seq (${prevMaxSeq}) for this (from, threadId)`);
|
|
146
|
+
}
|
|
147
|
+
// Inbox cap + per-sender sub-cap — AFTER dedup, so a retry is never rejected.
|
|
148
|
+
const counts = { recipientUnconsumed: 0, senderUnconsumed: 0 };
|
|
149
|
+
for (const r of recipientRows) {
|
|
150
|
+
if (r.to !== content.to || !isUnconsumed(r.state))
|
|
151
|
+
continue;
|
|
152
|
+
counts.recipientUnconsumed++;
|
|
153
|
+
if (r.from === content.from)
|
|
154
|
+
counts.senderUnconsumed++;
|
|
155
|
+
}
|
|
156
|
+
const cap = capDecision(counts);
|
|
157
|
+
if (!cap.ok)
|
|
158
|
+
return INBOX_FULL(cap.scope);
|
|
159
|
+
// Strip client-supplied lifecycle fields from the stored row: they are NOT
|
|
160
|
+
// under the signature (SIGNED_BODY_FIELDS excludes them), so a caller could
|
|
161
|
+
// pre-set `state`/`consumedAt`/`failureReason` and have them persist. `state`
|
|
162
|
+
// and `deliveredAt` are overwritten below regardless; consumedAt/failureReason
|
|
163
|
+
// are dropped here (Kern nit — hygiene, no injection today but no drift later).
|
|
164
|
+
const { state: _s, consumedAt: _c, failureReason: _f, deliveredAt: _d, ...clean } = content;
|
|
165
|
+
const now = (deps.now ?? (() => new Date()))().toISOString();
|
|
166
|
+
const row = {
|
|
167
|
+
...clean,
|
|
168
|
+
kind: content.kind ?? "message",
|
|
169
|
+
state: "delivered",
|
|
170
|
+
deliveredAt: now,
|
|
171
|
+
};
|
|
172
|
+
await deps.messages.put(row);
|
|
173
|
+
return acceptedView(row);
|
|
174
|
+
}
|
|
175
|
+
// ─── inbox ──────────────────────────────────────────────────────────────────
|
|
176
|
+
/**
|
|
177
|
+
* inbox(to). Unconsumed messages addressed to a principal, sorted by `seq`
|
|
178
|
+
* (per §12 P1-5 — createdAt collides at ms). A non-admin agent may only read
|
|
179
|
+
* its OWN inbox; admin/internal may read any.
|
|
180
|
+
*/
|
|
181
|
+
export async function relayInbox(deps, auth, requestedTo) {
|
|
182
|
+
if (auth.kind === "anonymous")
|
|
183
|
+
return UNAUTH();
|
|
184
|
+
const to = resolveSelfScope(auth, requestedTo);
|
|
185
|
+
if (to instanceof Response)
|
|
186
|
+
return to;
|
|
187
|
+
// Queried by the @indexed `to` — bounded by this inbox, not the table (Sherlock P0).
|
|
188
|
+
const rows = await queryRows(deps.messages, [{ attribute: "to", value: to }]);
|
|
189
|
+
return rows
|
|
190
|
+
.filter((r) => r.to === to && isUnconsumed(r.state))
|
|
191
|
+
.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
192
|
+
}
|
|
193
|
+
// ─── consume / ack ────────────────────────────────────────────────────────
|
|
194
|
+
/**
|
|
195
|
+
* consume(id). Marks a message consumed. Only the RECIPIENT may consume its own
|
|
196
|
+
* message; a missing id or another principal's message is an indistinguishable
|
|
197
|
+
* 404 (no enumeration). `consumed` is absorbing — consuming an already-consumed
|
|
198
|
+
* message is an idempotent no-op that returns the row, never a regression.
|
|
199
|
+
*/
|
|
200
|
+
export async function relayConsume(deps, auth, id) {
|
|
201
|
+
if (auth.kind === "anonymous")
|
|
202
|
+
return UNAUTH();
|
|
203
|
+
if (!id)
|
|
204
|
+
return BAD_REQUEST("missing message id");
|
|
205
|
+
const row = await Promise.resolve(deps.messages.get(id)).catch(() => null);
|
|
206
|
+
if (!row)
|
|
207
|
+
return NOT_FOUND();
|
|
208
|
+
const isRecipient = auth.kind === "agent" && !auth.isAdmin ? row.to === auth.agentId : true;
|
|
209
|
+
if (!isRecipient)
|
|
210
|
+
return NOT_FOUND();
|
|
211
|
+
if (row.state === "consumed")
|
|
212
|
+
return row; // absorbing — idempotent
|
|
213
|
+
const now = (deps.now ?? (() => new Date()))().toISOString();
|
|
214
|
+
const updated = { ...row, state: reconcileState(row.state, "consumed"), consumedAt: now };
|
|
215
|
+
await deps.messages.put(updated);
|
|
216
|
+
return updated;
|
|
217
|
+
}
|
|
218
|
+
// ─── dead-letter ────────────────────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* The visible dead-letter: `failed` messages queryable by the SENDER (from ===
|
|
221
|
+
* caller), each carrying its failureReason. A non-admin sees only its own
|
|
222
|
+
* failures; admin/internal may query any sender.
|
|
223
|
+
*/
|
|
224
|
+
export async function relayDeadLetters(deps, auth, requestedFrom) {
|
|
225
|
+
if (auth.kind === "anonymous")
|
|
226
|
+
return UNAUTH();
|
|
227
|
+
const from = resolveSelfScope(auth, requestedFrom);
|
|
228
|
+
if (from instanceof Response)
|
|
229
|
+
return from;
|
|
230
|
+
// Queried by the @indexed `from` + `state` — the sender's failed rows only,
|
|
231
|
+
// never a full-table scan (Sherlock P0).
|
|
232
|
+
const rows = await queryRows(deps.messages, [
|
|
233
|
+
{ attribute: "from", value: from },
|
|
234
|
+
{ attribute: "state", value: "failed" },
|
|
235
|
+
]);
|
|
236
|
+
return rows
|
|
237
|
+
.filter((r) => r.from === from && r.state === "failed")
|
|
238
|
+
.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
239
|
+
}
|
|
240
|
+
// ─── deadline sweep ─────────────────────────────────────────────────────────
|
|
241
|
+
/**
|
|
242
|
+
* Deadline sweep. Unconsumed past-deadline messages transition to a VISIBLE
|
|
243
|
+
* `failed`/`deadline` state (the inverse of OrgEvent's silent expiry). Consumed
|
|
244
|
+
* rows are NEVER regressed (absorbing). Admin/internal only — it is a
|
|
245
|
+
* maintenance operation over the whole table.
|
|
246
|
+
*/
|
|
247
|
+
export async function relaySweepDeadlines(deps, auth) {
|
|
248
|
+
if (auth.kind === "anonymous")
|
|
249
|
+
return UNAUTH();
|
|
250
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
251
|
+
return FORBIDDEN("forbidden: deadline sweep is an administrative operation");
|
|
252
|
+
}
|
|
253
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
254
|
+
// Only UNCONSUMED rows can sweep to failed — query the two unconsumed states
|
|
255
|
+
// by the @indexed `state` and union by id, instead of scanning the whole table
|
|
256
|
+
// (Sherlock P0). The id-dedup also makes this correct if a backend (or the
|
|
257
|
+
// test double) returns overlapping rows across the two state queries.
|
|
258
|
+
const byId = new Map();
|
|
259
|
+
for (const st of ["submitted", "delivered"]) {
|
|
260
|
+
for (const r of await queryRows(deps.messages, [{ attribute: "state", value: st }])) {
|
|
261
|
+
if (r?.id != null)
|
|
262
|
+
byId.set(String(r.id), r);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const rows = [...byId.values()];
|
|
266
|
+
let failed = 0;
|
|
267
|
+
for (const row of rows) {
|
|
268
|
+
const outcome = sweepDecision(row, now);
|
|
269
|
+
if (!outcome.fail)
|
|
270
|
+
continue;
|
|
271
|
+
// reconcileState guards the absorbing rule a second time: even if `row`
|
|
272
|
+
// were consumed, "failed" could never win — belt and suspenders.
|
|
273
|
+
const next = reconcileState(row.state, "failed");
|
|
274
|
+
if (next !== "failed")
|
|
275
|
+
continue;
|
|
276
|
+
await deps.messages.put({ ...row, state: "failed", failureReason: outcome.failureReason });
|
|
277
|
+
failed++;
|
|
278
|
+
}
|
|
279
|
+
return { failed };
|
|
280
|
+
}
|
|
281
|
+
// ─── shared scoping ─────────────────────────────────────────────────────────
|
|
282
|
+
/** A non-admin is pinned to its own id; admin/internal may target any id. */
|
|
283
|
+
function resolveSelfScope(auth, requested) {
|
|
284
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
285
|
+
if (requested && requested !== auth.agentId) {
|
|
286
|
+
return FORBIDDEN("forbidden: can only act on your own messages");
|
|
287
|
+
}
|
|
288
|
+
return auth.agentId;
|
|
289
|
+
}
|
|
290
|
+
// admin or internal
|
|
291
|
+
if (!requested)
|
|
292
|
+
return FORBIDDEN("forbidden: a target principal id is required");
|
|
293
|
+
return requested;
|
|
294
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-write.ts — the WRITE-side skill policy (flair#1542 components 1-3).
|
|
3
|
+
*
|
|
4
|
+
* A skill is a Memory tagged "skill" (reuse the substrate — no new table).
|
|
5
|
+
* This module centralizes the three write-side rules that make a skill-tagged
|
|
6
|
+
* Memory behave like a skill:
|
|
7
|
+
*
|
|
8
|
+
* 1. Embedding source: a skill-tagged row embeds from `trigger` (the
|
|
9
|
+
* "when to use" text), NOT from `content` (the full procedure). The
|
|
10
|
+
* recall signal is "when does this skill apply", so the vector must
|
|
11
|
+
* represent the trigger. Non-skill rows are untouched — `skillEmbedText`
|
|
12
|
+
* returns `content` for them, byte-identical to the pre-slice behavior.
|
|
13
|
+
*
|
|
14
|
+
* 2. SkillScan gate: every skill-tagged write is statically scanned
|
|
15
|
+
* (resources/scan/skill-scanner.ts) BEFORE the embedding is computed.
|
|
16
|
+
* Fail-closed on high/critical risk (a rejected write pays no embed);
|
|
17
|
+
* allow-with-flag on medium (the findings are recorded on the row's
|
|
18
|
+
* `_safetyFlags` so the write is auditable, but it is not blocked).
|
|
19
|
+
*
|
|
20
|
+
* 3. Forced durability: a skill MUST be durability=persistent. The 30-day
|
|
21
|
+
* reaper would archive a default-written ("standard") skill, and an
|
|
22
|
+
* ephemeral/session skill is a contradiction in terms — ephemeral/session
|
|
23
|
+
* are rejected outright, and every other value is forced to "persistent".
|
|
24
|
+
*
|
|
25
|
+
* Deliberately ZERO Harper imports — pure functions + constants, so the
|
|
26
|
+
* coverage-gate test and unit tests can import this module without the
|
|
27
|
+
* runtime (same load-bearing reason as memory-durability.ts /
|
|
28
|
+
* memory-visibility.ts).
|
|
29
|
+
*/
|
|
30
|
+
import { scanSkillContent } from "./scan/skill-scanner.js";
|
|
31
|
+
/** The tag that marks a Memory as a skill. */
|
|
32
|
+
export const SKILL_TAG = "skill";
|
|
33
|
+
/** Is this write a skill-tagged Memory? */
|
|
34
|
+
export function isSkillWrite(content) {
|
|
35
|
+
return Array.isArray(content?.tags) && content.tags.includes(SKILL_TAG);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The text a row embeds from. Skill-tagged rows embed from `trigger` (the
|
|
39
|
+
* recall signal); every other row embeds from `content`. Returns `undefined`
|
|
40
|
+
* only when there is no usable text at all.
|
|
41
|
+
*/
|
|
42
|
+
export function skillEmbedText(content) {
|
|
43
|
+
if (isSkillWrite(content) && typeof content.trigger === "string" && content.trigger.length > 0) {
|
|
44
|
+
return content.trigger;
|
|
45
|
+
}
|
|
46
|
+
return content.content;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Force a skill-tagged write to durability=persistent. Rejects an explicit
|
|
50
|
+
* ephemeral/session durability (a skill that expires is a contradiction);
|
|
51
|
+
* every other value — including the "standard" default and "permanent" — is
|
|
52
|
+
* forced to "persistent" so the 30-day reaper never archives a skill.
|
|
53
|
+
*
|
|
54
|
+
* Returns a 400 Response to short-circuit the write, or null to proceed.
|
|
55
|
+
* `content.durability` is mutated to "persistent" on the proceed path.
|
|
56
|
+
*/
|
|
57
|
+
export function enforceSkillDurability(content) {
|
|
58
|
+
if (!isSkillWrite(content))
|
|
59
|
+
return null;
|
|
60
|
+
const d = content.durability;
|
|
61
|
+
if (d === "ephemeral" || d === "session") {
|
|
62
|
+
return new Response(JSON.stringify({
|
|
63
|
+
error: "skill_durability",
|
|
64
|
+
message: "skill memories must be durability=persistent; ephemeral/session rejected",
|
|
65
|
+
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
66
|
+
}
|
|
67
|
+
content.durability = "persistent";
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reject a skill-tagged write on a path that does NOT run the SkillScan gate
|
|
72
|
+
* or forced durability (patch, seed, etc.). Skills are written ONLY via
|
|
73
|
+
* skill_store (→ Memory.post) or Memory.put — every other verb rejects a
|
|
74
|
+
* skill-tagged write rather than land it unscanned (the #1537 raw-writer
|
|
75
|
+
* lesson: gate EVERY verb, not just post/put).
|
|
76
|
+
*
|
|
77
|
+
* Returns a 400 Response to short-circuit the write, or null to proceed.
|
|
78
|
+
*/
|
|
79
|
+
export function rejectSkillWritePath(content) {
|
|
80
|
+
if (!isSkillWrite(content))
|
|
81
|
+
return null;
|
|
82
|
+
return new Response(JSON.stringify({
|
|
83
|
+
error: "skill_write_path",
|
|
84
|
+
message: "skill memories must be written via skill_store (or Memory post/put); this path does not gate skill writes",
|
|
85
|
+
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* SkillScan gate — run BEFORE the embedding is computed. Scans the combined
|
|
89
|
+
* `trigger` + `content` text (a dangerous shell/network payload in EITHER is
|
|
90
|
+
* a rejection). Fail-closed on high/critical risk; allow-with-flag on medium
|
|
91
|
+
* (findings appended to `_safetyFlags`); a clean scan is a no-op.
|
|
92
|
+
*
|
|
93
|
+
* Returns a 400 Response to short-circuit the write, or null to proceed.
|
|
94
|
+
*/
|
|
95
|
+
export function skillScanGate(content) {
|
|
96
|
+
if (!isSkillWrite(content))
|
|
97
|
+
return null;
|
|
98
|
+
const parts = [content.trigger, content.content].filter((s) => typeof s === "string" && s.length > 0);
|
|
99
|
+
if (parts.length === 0)
|
|
100
|
+
return null;
|
|
101
|
+
const result = scanSkillContent(parts.join("\n\n"));
|
|
102
|
+
if (result.riskLevel === "high" || result.riskLevel === "critical") {
|
|
103
|
+
return new Response(JSON.stringify({
|
|
104
|
+
error: "skill_scan_rejected",
|
|
105
|
+
riskLevel: result.riskLevel,
|
|
106
|
+
violations: result.violations,
|
|
107
|
+
message: "skill content failed SkillScan (fail-closed on high/critical risk)",
|
|
108
|
+
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
109
|
+
}
|
|
110
|
+
if (result.riskLevel === "medium") {
|
|
111
|
+
// allow-with-flag: record the findings so the write is auditable, but do
|
|
112
|
+
// not block it. `_safetyFlags` is the existing content-safety flag column;
|
|
113
|
+
// skill findings are namespaced `skill:<type>` so they never collide with
|
|
114
|
+
// the content-safety flags that may already be present.
|
|
115
|
+
const flags = result.violations.map((v) => `skill:${v.type}`);
|
|
116
|
+
const existing = Array.isArray(content._safetyFlags) ? content._safetyFlags : [];
|
|
117
|
+
content._safetyFlags = [...existing, ...flags];
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ADK_SCOPE_TAG_PREFIX } from "../src/rem/promote-policy.js";
|
|
2
|
+
/** Soul is agentId-scoped and cannot carry a per-user `adk:` tag. Writing an
|
|
3
|
+
* ADK-sourced claim there leaks that user's distilled text to every other
|
|
4
|
+
* user of the shared agentId. CLI refusal is not enough — a scripted
|
|
5
|
+
* PUT /Soul must hit the same door.
|
|
6
|
+
*
|
|
7
|
+
* This vendor-string match is a dated rollout bridge only. Soul authorization
|
|
8
|
+
* is the deny-by-default operator/internal allowlist in soul-write-policy.ts;
|
|
9
|
+
* runtime denial does not depend on `adk:`. Remove this module after
|
|
10
|
+
* ADK_SOUL_REFUSE_KILL_DATE once classified writers are deployed (#1540). */
|
|
11
|
+
export const ADK_SOUL_REFUSAL = "adk_sourced_claim_cannot_be_written_to_soul";
|
|
12
|
+
/** Calendar date (UTC) after which this `adk:` bridge must be removed. */
|
|
13
|
+
export const ADK_SOUL_REFUSE_KILL_DATE = "2026-10-31";
|
|
14
|
+
export function tagLooksAdk(tag) {
|
|
15
|
+
return typeof tag === "string" && tag.toLowerCase().startsWith(ADK_SCOPE_TAG_PREFIX);
|
|
16
|
+
}
|
|
17
|
+
/** Request body carries an ADK scope tag (promotion leftover or forged). */
|
|
18
|
+
export function bodyCarriesAdkScope(content) {
|
|
19
|
+
if (tagLooksAdk(content?.scopeTag))
|
|
20
|
+
return true;
|
|
21
|
+
const tags = content?.tags;
|
|
22
|
+
return Array.isArray(tags) && tags.some(tagLooksAdk);
|
|
23
|
+
}
|
|
24
|
+
export function rowLooksAdkSourced(row) {
|
|
25
|
+
if (!row)
|
|
26
|
+
return false;
|
|
27
|
+
if (tagLooksAdk(row.scopeTag))
|
|
28
|
+
return true;
|
|
29
|
+
return Array.isArray(row.tags) && row.tags.some(tagLooksAdk);
|
|
30
|
+
}
|
|
31
|
+
function adkForbidden() {
|
|
32
|
+
return new Response(JSON.stringify({ error: ADK_SOUL_REFUSAL }), {
|
|
33
|
+
status: 403,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function* searchByAgentId(tableName, agentId) {
|
|
38
|
+
const { databases } = await import("harper");
|
|
39
|
+
const table = databases.flair?.[tableName];
|
|
40
|
+
if (!table?.search)
|
|
41
|
+
return;
|
|
42
|
+
const query = { conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }] };
|
|
43
|
+
yield* table.search(query);
|
|
44
|
+
}
|
|
45
|
+
/** Refuse a Soul write whose value is an ADK-sourced claim. Body tags are
|
|
46
|
+
* sufficient; otherwise match stored MemoryCandidate.claim / Memory.content
|
|
47
|
+
* that already carries an `adk:` scope tag. */
|
|
48
|
+
export async function refuseAdkSourcedSoulWrite(content, lookup = {}) {
|
|
49
|
+
if (bodyCarriesAdkScope(content))
|
|
50
|
+
return adkForbidden();
|
|
51
|
+
const value = typeof content?.value === "string" ? content.value : "";
|
|
52
|
+
const agentId = typeof content?.agentId === "string" ? content.agentId : "";
|
|
53
|
+
if (!value || !agentId)
|
|
54
|
+
return null;
|
|
55
|
+
const candidates = lookup.searchCandidates
|
|
56
|
+
?? ((id) => searchByAgentId("MemoryCandidate", id));
|
|
57
|
+
for await (const row of candidates(agentId)) {
|
|
58
|
+
if (row?.claim === value && rowLooksAdkSourced(row))
|
|
59
|
+
return adkForbidden();
|
|
60
|
+
}
|
|
61
|
+
const memories = lookup.searchMemories
|
|
62
|
+
?? ((id) => searchByAgentId("Memory", id));
|
|
63
|
+
for await (const row of memories(agentId)) {
|
|
64
|
+
if (row?.content === value && rowLooksAdkSourced(row))
|
|
65
|
+
return adkForbidden();
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { FORBIDDEN, UNAUTH } from "./record-type-kit.js";
|
|
3
|
+
import { buildProvenance } from "./provenance.js";
|
|
4
|
+
import { databases } from "harper";
|
|
5
|
+
import { refuseAdkSourcedSoulWrite } from "./soul-adk-guard.js";
|
|
6
|
+
// Role is not source: an admin agent key or delegated OAuth identity is still
|
|
7
|
+
// a runtime credential. Only verified Basic admin auth enters the operator path.
|
|
8
|
+
// Callers cannot choose this class via body fields or connector-supplied labels.
|
|
9
|
+
export function soulWriteSource(context, auth) {
|
|
10
|
+
const request = context?.request ?? context;
|
|
11
|
+
// Internal path needs a deliberate __flairInternal marker; a contextless call is refused.
|
|
12
|
+
if (auth.kind === "internal" && context?.__flairInternal === true && !request?.headers)
|
|
13
|
+
return "internal";
|
|
14
|
+
const header = request?.headers?.get?.("authorization") ?? request?.headers?.asObject?.authorization ?? "";
|
|
15
|
+
if (auth.kind === "agent" && auth.isAdmin && /^Basic\s/i.test(header))
|
|
16
|
+
return "operator";
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export async function authorizeSoulWrite(context) {
|
|
20
|
+
const auth = await resolveAgentAuth(context);
|
|
21
|
+
const source = soulWriteSource(context, auth);
|
|
22
|
+
const denied = auth.kind === "anonymous" ? UNAUTH()
|
|
23
|
+
: source ? null : FORBIDDEN("soul_write_requires_operator: use authenticated operator credentials");
|
|
24
|
+
return { auth, source, denied };
|
|
25
|
+
}
|
|
26
|
+
export function soulProvenance(auth, source, now) {
|
|
27
|
+
const provenance = JSON.parse(buildProvenance(auth, now, {}));
|
|
28
|
+
provenance.verified.sourceClass = source;
|
|
29
|
+
return JSON.stringify(provenance);
|
|
30
|
+
}
|
|
31
|
+
export async function refuseLearnedSoulWrite(content) {
|
|
32
|
+
// Generic content-provenance backstop. Stored artifacts need no vendor tag —
|
|
33
|
+
// an exact owner-scoped Memory / MemoryCandidate match is enough.
|
|
34
|
+
if (typeof content?.value !== "string" || !content.value)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof content.agentId !== "string" || !content.agentId)
|
|
37
|
+
return FORBIDDEN("soul_owner_required");
|
|
38
|
+
for (const [name, field] of [["MemoryCandidate", "claim"], ["Memory", "content"]]) {
|
|
39
|
+
const table = databases.flair[name];
|
|
40
|
+
// Missing tables or failed reads throw before any write, rather than
|
|
41
|
+
// treating unavailable provenance as proof of operator-authored content.
|
|
42
|
+
for await (const row of table.search({
|
|
43
|
+
conditions: [
|
|
44
|
+
{ attribute: "agentId", comparator: "equals", value: content.agentId },
|
|
45
|
+
{ attribute: field, comparator: "equals", value: content.value },
|
|
46
|
+
],
|
|
47
|
+
select: ["agentId", field],
|
|
48
|
+
limit: 1,
|
|
49
|
+
})) {
|
|
50
|
+
if (row.agentId === content.agentId && row[field] === content.value)
|
|
51
|
+
return FORBIDDEN("soul_value_is_learned_content");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Content guards after source authorization: dated vendor-tag bridge, then the
|
|
57
|
+
* generic learned-content backstop. New connectors need no Soul-side branch. */
|
|
58
|
+
export async function refuseSoulWriteContent(content) {
|
|
59
|
+
const adk = await refuseAdkSourcedSoulWrite(content);
|
|
60
|
+
if (adk)
|
|
61
|
+
return adk;
|
|
62
|
+
return refuseLearnedSoulWrite(content);
|
|
63
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Harper's `put()` is FULL RECORD REPLACEMENT. If you pass a partial
|
|
5
5
|
* object, all missing fields (including embeddings!) are permanently
|
|
6
6
|
* deleted. This helper ensures you always read the full record first.
|
|
7
|
+
* It does not promise atomic counters — search hit-tracking uses
|
|
8
|
+
* `resources/hit-tracking.ts` (MemoryHitStat) instead of this helper.
|
|
7
9
|
*
|
|
8
10
|
* Usage:
|
|
9
11
|
* import { patchRecord } from "./table-helpers.js";
|
|
@@ -75,10 +75,10 @@ export const MAX_USAGE_IDS_PER_CALL = 20;
|
|
|
75
75
|
* patchRecord() helper, which would combine both into one un-safe wrap.
|
|
76
76
|
*
|
|
77
77
|
* The final get-then-put for the increment is a best-effort (non-atomic)
|
|
78
|
-
* read-modify-write
|
|
79
|
-
* this codebase for count fields (e.g. retrievalCount's bump in
|
|
80
|
-
* SemanticSearch.ts) — a concurrent contribution from a DIFFERENT agent
|
|
78
|
+
* read-modify-write — a concurrent contribution from a DIFFERENT agent
|
|
81
79
|
* landing between this call's read and write could lose one increment.
|
|
80
|
+
* Search hit-tracking is no longer this class of race (flair#1528;
|
|
81
|
+
* resources/hit-tracking.ts coalesces MemoryHitStat increments).
|
|
82
82
|
* Re-fetching immediately before the write (rather than reusing the
|
|
83
83
|
* earlier existence-check read) narrows, without eliminating, that
|
|
84
84
|
* window. Not solved here: bounded, low-severity (an undercount, never an
|