@tpsdev-ai/flair 0.20.0 → 0.21.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 +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +482 -32
- package/dist/deploy.js +280 -18
- package/dist/doctor-client.js +312 -0
- package/dist/install/clients.js +18 -0
- package/dist/rem/restore.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/Federation.js +98 -2
- package/dist/resources/IngestEvents.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/Memory.js +123 -17
- package/dist/resources/MemoryBootstrap.js +46 -36
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/OAuth.js +61 -4
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +55 -3
- package/dist/resources/Relationship.js +10 -1
- package/dist/resources/SemanticSearch.js +14 -14
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +24 -5
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +1 -1
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-provider.js +1 -1
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/version-check.js +167 -0
- package/package.json +2 -2
- package/schemas/agent.graphql +7 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +16 -0
|
@@ -14,17 +14,19 @@ import { resolveReadScope } from "./memory-read-scope.js";
|
|
|
14
14
|
* 3. Recent memories (adaptive window)
|
|
15
15
|
* 4. Task-relevant memories (semantic search if currentTask provided)
|
|
16
16
|
* 4b. Teammate findings relevant to your task (flair#550 — the SAME scored
|
|
17
|
-
* task-relevant set as #4, split by origin:
|
|
18
|
-
*
|
|
19
|
-
* #4, attributed via "[via <agentId>]"
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* task-relevant set as #4, split by origin: any other in-org agent's
|
|
18
|
+
* NON-PRIVATE memory that scores against currentTask lands here instead
|
|
19
|
+
* of #4, attributed via "[via <agentId>]" — no MemoryGrant required
|
|
20
|
+
* (open-within-org read, per #578). Presentation only — what's readable
|
|
21
|
+
* is entirely resolveReadScope()'s job; this only changes how an
|
|
22
|
+
* already-read cross-agent record is formatted/sectioned)
|
|
22
23
|
* 5. Relationship context (active relationships for mentioned entities)
|
|
23
24
|
* 6. Predicted context (based on channel/surface/subject hints)
|
|
24
25
|
* 7. Team roster (other active agents in this office + a search-first nudge —
|
|
25
|
-
* bootstrap loads the caller's own memories plus
|
|
26
|
-
*
|
|
27
|
-
* section nudges toward memory_search for
|
|
26
|
+
* bootstrap loads the caller's own memories plus every other in-org
|
|
27
|
+
* agent's non-private memories (open-within-org read, never anyone's
|
|
28
|
+
* private ones), so this section nudges toward memory_search for
|
|
29
|
+
* anything beyond that window)
|
|
28
30
|
*
|
|
29
31
|
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
30
32
|
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
@@ -199,9 +201,10 @@ export class BootstrapMemories extends Resource {
|
|
|
199
201
|
}
|
|
200
202
|
// --- 1c. Team roster + cross-agent search nudge ---
|
|
201
203
|
// Soul is still caller-own-only (unaffected here). Memory loading below
|
|
202
|
-
// (step 2) now also includes
|
|
203
|
-
//
|
|
204
|
-
//
|
|
204
|
+
// (step 2) now also includes every other in-org agent's non-private
|
|
205
|
+
// memories (open-within-org read, no MemoryGrant needed — #578) — but
|
|
206
|
+
// this section stays: memory_search/SemanticSearch remains the
|
|
207
|
+
// deliberate, query-driven way to find a teammate's finding, vs.
|
|
205
208
|
// bootstrap's fixed recent/permanent window. This section is fixed-cost
|
|
206
209
|
// (no query text to format per agent) so it's cheap enough to always
|
|
207
210
|
// include, not budgeted.
|
|
@@ -225,14 +228,18 @@ export class BootstrapMemories extends Resource {
|
|
|
225
228
|
// Agent table may not exist in older / standalone deployments
|
|
226
229
|
}
|
|
227
230
|
// --- 2. Permanent memories (always included, highest priority) ---
|
|
228
|
-
// Read-scope: own (any visibility) +
|
|
229
|
-
//
|
|
230
|
-
// in resolveReadScope(): the condition is
|
|
231
|
-
// (so the table itself never returns an
|
|
232
|
-
// `scope.isAllowed` re-checks in-process as
|
|
233
|
-
// belt-and-suspenders discipline as
|
|
234
|
-
// filter) — this is the #550 foundation:
|
|
235
|
-
// beyond own-only without a parallel
|
|
231
|
+
// Read-scope: own (any visibility) + every OTHER in-org agent's
|
|
232
|
+
// non-private memory — open-within-org read (#578), no MemoryGrant
|
|
233
|
+
// consulted at all. Centralized in resolveReadScope(): the condition is
|
|
234
|
+
// pushed into the Harper query (so the table itself never returns an
|
|
235
|
+
// out-of-scope row), and `scope.isAllowed` re-checks in-process as
|
|
236
|
+
// defense-in-depth (same belt-and-suspenders discipline as
|
|
237
|
+
// SemanticSearch's BM25 pre-fusion filter) — this is the #550 foundation:
|
|
238
|
+
// bootstrap can now safely expand beyond own-only without a parallel
|
|
239
|
+
// scoping rule, and that rule tracks resolveReadScope()'s model
|
|
240
|
+
// automatically (grant-gated when #568 first built this, open-within-org
|
|
241
|
+
// now that #578 has landed — this file never re-implements the rule, so
|
|
242
|
+
// it never has to change when the rule does).
|
|
236
243
|
const scope = await resolveReadScope(agentId);
|
|
237
244
|
const allMemories = [];
|
|
238
245
|
for await (const record of databases.flair.Memory.search({ conditions: [scope.condition] })) {
|
|
@@ -240,18 +247,19 @@ export class BootstrapMemories extends Resource {
|
|
|
240
247
|
continue;
|
|
241
248
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
242
249
|
continue;
|
|
243
|
-
//
|
|
250
|
+
// A past validTo ALWAYS means the record has been closed out
|
|
244
251
|
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
245
252
|
// validTo without necessarily setting `archived`), same root cause and
|
|
246
|
-
// fix as
|
|
253
|
+
// fix as SemanticSearch.ts's unconditional past-validTo/bm25-filter
|
|
254
|
+
// exclusion. Unconditional
|
|
247
255
|
// so a server-superseded record can't resurface in bootstrap just
|
|
248
256
|
// because its successor isn't co-present in this result set (the
|
|
249
257
|
// supersededIds filter further down only catches co-presence). A
|
|
250
258
|
// record with no validTo, or a future validTo, is unaffected.
|
|
251
259
|
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
252
260
|
continue;
|
|
253
|
-
// Attribution for cross-agent (
|
|
254
|
-
// SemanticSearch.ts already uses: formatMemory() below only USES this
|
|
261
|
+
// Attribution for cross-agent (any other in-org agent's) records — same
|
|
262
|
+
// convention SemanticSearch.ts already uses: formatMemory() below only USES this
|
|
255
263
|
// when the record also carries _safetyFlags (labels the untrusted-data
|
|
256
264
|
// wrapper with whose memory it is), it never forces wrapping on its own.
|
|
257
265
|
// Real Harper's search() results are non-extensible objects — mutating
|
|
@@ -268,11 +276,12 @@ export class BootstrapMemories extends Resource {
|
|
|
268
276
|
}
|
|
269
277
|
const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
|
|
270
278
|
// #550 design boundary: the permanent / recent / predicted sections are the
|
|
271
|
-
// agent's OWN working context — own-only, always.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
279
|
+
// agent's OWN working context — own-only, always. `activeMemories` also
|
|
280
|
+
// carries every other in-org agent's non-private records (`_source` set,
|
|
281
|
+
// open-within-org read — no grant involved), but that cross-agent
|
|
282
|
+
// visibility exists to feed the task-relevant "Teammate findings"
|
|
283
|
+
// surfacing (#550) below, NOT to blend a teammate's memories into the
|
|
284
|
+
// reader's recent/permanent/predicted view. So these three sections
|
|
276
285
|
// filter to own (`!m._source`); team knowledge surfaces only when
|
|
277
286
|
// task-relevant (the teammate section) or via an explicit memory_search.
|
|
278
287
|
const ownMemories = activeMemories.filter((m) => !m._source);
|
|
@@ -419,13 +428,14 @@ export class BootstrapMemories extends Resource {
|
|
|
419
428
|
.filter((s) => s.score > 0.3)
|
|
420
429
|
.sort((a, b) => b.score - a.score);
|
|
421
430
|
// #550: split the scored, task-relevant set by origin. Own findings
|
|
422
|
-
// go to `relevant` as before;
|
|
423
|
-
// read-scoped by
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
431
|
+
// go to `relevant` as before; any other in-org agent's non-private
|
|
432
|
+
// record — already read-scoped by resolveReadScope(), no grant
|
|
433
|
+
// required (`m._source` is only ever set for a cross-agent record,
|
|
434
|
+
// see the allMemories loop above) — goes to the new `teammate`
|
|
435
|
+
// section so the agent can tell it apart at a glance. Both draw from
|
|
436
|
+
// the SAME `tokenBudget` in one score-ordered pass — highest-relevance
|
|
437
|
+
// memories win the remaining budget regardless of which section they
|
|
438
|
+
// land in, so neither section double-spends.
|
|
429
439
|
for (const { memory: m } of scored) {
|
|
430
440
|
const line = formatMemory(m, agentId);
|
|
431
441
|
const cost = estimateTokens(line);
|
|
@@ -499,7 +509,7 @@ export class BootstrapMemories extends Resource {
|
|
|
499
509
|
}
|
|
500
510
|
// #550: teammate findings relevant to the current task — right after the
|
|
501
511
|
// agent's own task-relevant knowledge. Empty section renders nothing
|
|
502
|
-
// (no header) so a bootstrap with no
|
|
512
|
+
// (no header) so a bootstrap with no task-relevant teammate findings for
|
|
503
513
|
// this task looks exactly as it did before this feature.
|
|
504
514
|
if (sections.teammate.length > 0) {
|
|
505
515
|
parts.push("## Teammate findings relevant to your task\n" + sections.teammate.join("\n"));
|
|
@@ -19,7 +19,7 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
22
|
-
* read-gate family fix
|
|
22
|
+
* read-gate family fix — same pattern as Memory.ts/Soul.ts/
|
|
23
23
|
* WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
|
|
24
24
|
* leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
|
|
25
25
|
* describe (`GET /MemoryGrant`) outside search(), so neither was gated
|
package/dist/resources/OAuth.js
CHANGED
|
@@ -35,6 +35,27 @@ function nowISO() {
|
|
|
35
35
|
function futureISO(ms) {
|
|
36
36
|
return new Date(Date.now() + ms).toISOString();
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 302 redirect — deliberately NOT `Response.redirect()`.
|
|
40
|
+
*
|
|
41
|
+
* FOUND while writing the real-Harper integration test for #604 (this exact
|
|
42
|
+
* gap is why the coverage doc above calls OAuthAuthorize.post() untestable
|
|
43
|
+
* without a running Harper instance — no test had ever hit this path over
|
|
44
|
+
* real HTTP before). Pre-existing on main, unrelated to the #604 auth fix:
|
|
45
|
+
* per the Fetch spec, `Response.redirect(url, status)` constructs a Response
|
|
46
|
+
* whose Headers guard is set to "immutable". Harper's own HTTP response
|
|
47
|
+
* pipeline mutates the handler's returned Response's headers afterward (e.g.
|
|
48
|
+
* CORS/instrumentation headers) — against an immutable-guarded Headers this
|
|
49
|
+
* throws `TypeError: immutable`, turning EVERY real POST /OAuthAuthorize
|
|
50
|
+
* (both "deny" and "approve") into a 500, verified via curl against a built
|
|
51
|
+
* origin/main with no other changes. A manually-constructed Response with
|
|
52
|
+
* the same status/Location header has the default (mutable) "response"
|
|
53
|
+
* guard, so Harper's later mutation succeeds — identical bytes on the wire,
|
|
54
|
+
* no behavior change, just a mutable Headers object underneath.
|
|
55
|
+
*/
|
|
56
|
+
function redirectTo(url, status = 302) {
|
|
57
|
+
return new Response(null, { status, headers: { Location: url } });
|
|
58
|
+
}
|
|
38
59
|
// ─── Discovery metadata ──────────────────────────────────────────────────────
|
|
39
60
|
export class OAuthMetadata extends Resource {
|
|
40
61
|
// OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
|
|
@@ -123,7 +144,7 @@ export class OAuthAuthorize extends Resource {
|
|
|
123
144
|
// In 1.0, this returns a simple HTML consent page.
|
|
124
145
|
// The user (Nathan) approves or denies, which POSTs back.
|
|
125
146
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
126
|
-
// getContext() is the only reliable path (
|
|
147
|
+
// getContext() is the only reliable path (the previous
|
|
127
148
|
// `(this as any).request` read was always undefined, so query params were
|
|
128
149
|
// always empty).
|
|
129
150
|
const ctx = this.getContext?.();
|
|
@@ -197,13 +218,13 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
197
218
|
}
|
|
198
219
|
if (action === "deny") {
|
|
199
220
|
const params = new URLSearchParams({ error: "access_denied", state });
|
|
200
|
-
return
|
|
221
|
+
return redirectTo(`${redirectUri}?${params}`);
|
|
201
222
|
}
|
|
202
223
|
// Determine authenticated principal. Harper v5 does not populate
|
|
203
224
|
// this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
|
|
204
225
|
// was always undefined and this ALWAYS fell back to the hardcoded "admin" —
|
|
205
226
|
// every approved consent grant was minted for the admin principal
|
|
206
|
-
// regardless of who actually approved it (
|
|
227
|
+
// regardless of who actually approved it (identity spoof).
|
|
207
228
|
// resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
|
|
208
229
|
// { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
|
|
209
230
|
// We do NOT silently fall back to "admin" on an unresolved principal — if
|
|
@@ -215,6 +236,42 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
215
236
|
// admin, or should any verified (non-admin) agent be able to approve its
|
|
216
237
|
// own consent grant? The previous code always resolved to "admin" for
|
|
217
238
|
// every caller, so this is a genuine policy decision, not just a bug fix.
|
|
239
|
+
//
|
|
240
|
+
// SECURITY FIX (#604 — authorizeLocal escalation): resolveAgentAuth's
|
|
241
|
+
// `context.user` fallback (agent-auth.ts, the super_user / username
|
|
242
|
+
// branches) ALSO matches Harper's `authorizeLocal` (config true) ambient
|
|
243
|
+
// super_user injection for ANY credential-less LOOPBACK request.
|
|
244
|
+
// /OAuthAuthorize sits on auth-middleware.ts's public early-return
|
|
245
|
+
// passthrough (any method), so a bare local POST never gets OUR
|
|
246
|
+
// middleware's tpsAgent/tpsAnonymous annotation either — resolveAgentAuth
|
|
247
|
+
// falls straight through to Harper's raw `context.user`, which
|
|
248
|
+
// authorizeLocal can forge with NO signature and NO password. A
|
|
249
|
+
// credential-less loopback POST therefore resolved to
|
|
250
|
+
// { kind: "agent", agentId: "admin", isAdmin: true } and minted a REAL
|
|
251
|
+
// admin authorization code, exchangeable at OAuthToken for a Bearer
|
|
252
|
+
// token — full local privilege escalation with zero credentials.
|
|
253
|
+
// authorizeLocal injects request.user=super_user ONLY when there is NO
|
|
254
|
+
// Authorization header present (see Presence.ts's get() doc for the
|
|
255
|
+
// fuller writeup of this exact mechanism) — a genuinely
|
|
256
|
+
// password-verified Basic admin/super_user, or a genuinely
|
|
257
|
+
// Ed25519-signed agent request, both carry a real header. Requiring one
|
|
258
|
+
// be present before trusting resolveAgentAuth's verdict closes the
|
|
259
|
+
// forged-approver path without touching resolveAgentAuth itself (the
|
|
260
|
+
// root-cause fix is a separate, larger follow-up — see #604): a
|
|
261
|
+
// credential-less local caller now gets 401 and no code is minted; a
|
|
262
|
+
// genuinely-authenticated approver (real Basic password, or a real
|
|
263
|
+
// TPS-Ed25519 signature verified by resolveAgentAuth's own fallback) is
|
|
264
|
+
// unaffected.
|
|
265
|
+
const ctx = this.getContext?.();
|
|
266
|
+
const request = ctx?.request ?? ctx;
|
|
267
|
+
const authHeader = request?.headers?.get?.("authorization") ??
|
|
268
|
+
request?.headers?.asObject?.authorization ??
|
|
269
|
+
"";
|
|
270
|
+
if (!authHeader) {
|
|
271
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
272
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
218
275
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
219
276
|
if (auth.kind !== "agent") {
|
|
220
277
|
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
@@ -238,7 +295,7 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
238
295
|
createdAt: now,
|
|
239
296
|
});
|
|
240
297
|
const params = new URLSearchParams({ code, state });
|
|
241
|
-
return
|
|
298
|
+
return redirectTo(`${redirectUri}?${params}`);
|
|
242
299
|
}
|
|
243
300
|
}
|
|
244
301
|
// ─── Token endpoint ──────────────────────────────────────────────────────────
|
|
@@ -23,7 +23,7 @@ export class OrgEventCatchup extends Resource {
|
|
|
23
23
|
async get(pathInfo) {
|
|
24
24
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
25
25
|
// getContext() is the only reliable path to the gate's tpsAgent/
|
|
26
|
-
// tpsAgentIsAdmin annotations (
|
|
26
|
+
// tpsAgentIsAdmin annotations (the previous `(this as
|
|
27
27
|
// any).request` read was always undefined, so the ownership check below
|
|
28
28
|
// never ran — fail-open cross-agent read).
|
|
29
29
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Auth:
|
|
10
10
|
* GET — public (returns only allowlisted fields; safe for public renderer).
|
|
11
|
+
* currentTask is additionally content-gated to verified agents only
|
|
12
|
+
* (#592) — anonymous callers get the roster with currentTask=null.
|
|
11
13
|
* POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
|
|
12
14
|
* own record; cross-agent writes are rejected (403).
|
|
13
15
|
*
|
|
@@ -15,9 +17,17 @@
|
|
|
15
17
|
* - Write: per-agent Ed25519 auth. Cross-agent → 403.
|
|
16
18
|
* - Read: field-allowlisted to public-safe set. No secrets, no admin data.
|
|
17
19
|
* - currentTask is agent-authored free text → cap length, escape on render.
|
|
20
|
+
* - currentTask CONTENT gate (#592): the roster (id/displayName/role/
|
|
21
|
+
* runtime/activity/presenceStatus/lastHeartbeatAt) is genuinely
|
|
22
|
+
* public-safe and stays world-readable, but currentTask is free text that
|
|
23
|
+
* the coordination convention (`presence set --task "investigating
|
|
24
|
+
* <host>: <symptom>"`) has put customer names and preprod hostnames in.
|
|
25
|
+
* sanitizeCurrentTask() only trims/caps length — it does not redact
|
|
26
|
+
* content. get() additionally gates currentTask itself to verified
|
|
27
|
+
* in-org agents only; see get()'s inline comment.
|
|
18
28
|
*/
|
|
19
29
|
import { databases } from "@harperfast/harper";
|
|
20
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
30
|
+
import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
|
|
21
31
|
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
22
32
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
23
33
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
@@ -32,7 +42,7 @@ function offlineThresholdMs() {
|
|
|
32
42
|
}
|
|
33
43
|
// ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
|
|
34
44
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
35
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
45
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
36
46
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
37
47
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
38
48
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -98,8 +108,47 @@ export class Presence extends databases.flair.Presence {
|
|
|
98
108
|
*
|
|
99
109
|
* Joins Presence records with Agent metadata and derives presenceStatus.
|
|
100
110
|
* Only allowlisted fields are returned (no secrets, no admin data).
|
|
111
|
+
*
|
|
112
|
+
* currentTask CONTENT gate (#592): Harper routes every GET — collection
|
|
113
|
+
* (`GET /Presence`) AND single-record (`GET /Presence/<id>`) — through this
|
|
114
|
+
* SAME method (REST.js: `resource.get(target, request)`, one call site for
|
|
115
|
+
* both; this override ignores `target` and always returns the full roster
|
|
116
|
+
* array), so gating once here, before the loop, covers every read path with
|
|
117
|
+
* no separate return site to miss.
|
|
118
|
+
*
|
|
119
|
+
* The gate keys off a valid TPS-Ed25519 SIGNATURE (verifyAgentRequest),
|
|
120
|
+
* NOT resolveAgentAuth()/allowVerified — and that distinction is the whole
|
|
121
|
+
* fix. /Presence is a public-passthrough in auth-middleware.ts: the
|
|
122
|
+
* middleware early-returns WITHOUT annotating tpsAgent/tpsAnonymous, so a
|
|
123
|
+
* resource-level resolver never sees a gate annotation for this path. Worse,
|
|
124
|
+
* Harper's `authorizeLocal` (config default true) auto-authorizes any
|
|
125
|
+
* *credential-less* loopback request as super_user — it injects request.user
|
|
126
|
+
* ONLY "when there is no Authorization header" (node .../server/http.js). So a
|
|
127
|
+
* bare, unauthenticated `GET /Presence` from loopback (exactly the anonymous
|
|
128
|
+
* caller the issue is about, and what the integration test exercises against
|
|
129
|
+
* a real spawned Harper) arrives with request.user = super_user and NO
|
|
130
|
+
* signature. resolveAgentAuth() would classify that as `kind:"agent"` (its
|
|
131
|
+
* super_user branch) and leak currentTask — which it did, against real
|
|
132
|
+
* Harper, even though mocked unit tests using tpsAgent annotations passed.
|
|
133
|
+
* A TPS-Ed25519 signature, by contrast, cannot be manufactured by
|
|
134
|
+
* authorizeLocal (it requires the Authorization header, which suppresses the
|
|
135
|
+
* super_user injection), so verifyAgentRequest() cleanly separates a real
|
|
136
|
+
* in-org agent from an anonymous/loopback/Basic-admin caller. Only a valid
|
|
137
|
+
* agent signature gets currentTask; everything else (anonymous, loopback
|
|
138
|
+
* super_user, Basic-admin, internal in-process) gets currentTask=null.
|
|
139
|
+
* allowRead() is UNCHANGED (still `true`) — the roster itself stays public;
|
|
140
|
+
* only the free-text field is gated, per the issue's field-level option.
|
|
101
141
|
*/
|
|
102
142
|
async get() {
|
|
143
|
+
// Extract the raw request the same way post() does (getContext().request
|
|
144
|
+
// is populated for GET; fall back to the context itself). verifyAgentRequest
|
|
145
|
+
// returns the agent for a valid TPS-Ed25519 signature, else null — see the
|
|
146
|
+
// gate rationale above. Memoized per-request, so this is a no-op if any
|
|
147
|
+
// other path already verified the same request.
|
|
148
|
+
const ctx = this.getContext?.();
|
|
149
|
+
const request = ctx?.request ?? ctx;
|
|
150
|
+
const agentAuth = request ? await verifyAgentRequest(request) : null;
|
|
151
|
+
const includeCurrentTask = agentAuth !== null;
|
|
103
152
|
const now = Date.now();
|
|
104
153
|
const idleThreshold = idleThresholdMs();
|
|
105
154
|
const offlineThreshold = offlineThresholdMs();
|
|
@@ -124,7 +173,10 @@ export class Presence extends databases.flair.Presence {
|
|
|
124
173
|
presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
|
|
125
174
|
? row.lastHeartbeatAt
|
|
126
175
|
: Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
|
|
127
|
-
|
|
176
|
+
// Anonymous/unverified readers get `null` here (key stays present,
|
|
177
|
+
// schema-stable) instead of the sanitized task text — see the
|
|
178
|
+
// currentTask CONTENT gate doc above get().
|
|
179
|
+
currentTask: includeCurrentTask ? sanitizeCurrentTask(row?.currentTask) : null,
|
|
128
180
|
lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
|
|
129
181
|
? row.lastHeartbeatAt
|
|
130
182
|
: Number(row?.lastHeartbeatAt ?? 0),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
4
5
|
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
6
7
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
@@ -16,7 +17,7 @@ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { s
|
|
|
16
17
|
export class Relationship extends databases.flair.Relationship {
|
|
17
18
|
/**
|
|
18
19
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
19
|
-
* read-gate family fix
|
|
20
|
+
* read-gate family fix — same pattern as Memory.ts/Soul.ts/
|
|
20
21
|
* WorkspaceState.ts). Closes the same P0 leak: Harper routes
|
|
21
22
|
* `GET /Relationship/<id>` to get() and the collection describe
|
|
22
23
|
* (`GET /Relationship`) outside search(), so neither was gated before this
|
|
@@ -124,6 +125,14 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
124
125
|
content.validFrom = content.validFrom || now;
|
|
125
126
|
// validTo left as null/undefined for active relationships
|
|
126
127
|
content.confidence = content.confidence ?? 1.0;
|
|
128
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
129
|
+
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
130
|
+
// full contract. No-op if already set (never fires for a genuine local
|
|
131
|
+
// write; a federation-synced record never reaches this method — the
|
|
132
|
+
// merge path writes via the raw table object, bypassing this class).
|
|
133
|
+
if (content.originatorInstanceId == null) {
|
|
134
|
+
content.originatorInstanceId = await localInstanceId();
|
|
135
|
+
}
|
|
127
136
|
return super.put(content);
|
|
128
137
|
}
|
|
129
138
|
async delete(_) {
|
|
@@ -11,7 +11,7 @@ import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidat
|
|
|
11
11
|
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
12
12
|
// unit-tested directly (see test/unit/temporal-scoring.test.ts).
|
|
13
13
|
import { compositeScore } from "./scoring.js";
|
|
14
|
-
// BM25 + union-RRF hybrid retrieval (
|
|
14
|
+
// BM25 + union-RRF hybrid retrieval (FLAIR-BM25-HYBRID-RETRIEVAL).
|
|
15
15
|
// Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
|
|
16
16
|
// SECURITY conditions-filter are unit-tested against the shipped code.
|
|
17
17
|
import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
|
|
@@ -81,10 +81,10 @@ export class SemanticSearch extends Resource {
|
|
|
81
81
|
? authenticatedAgent
|
|
82
82
|
: bodyAgentId;
|
|
83
83
|
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
84
|
-
// (
|
|
84
|
+
// (Layer 1). Centralized in resolveReadScope() — this used to be
|
|
85
85
|
// an inline grant-resolution loop here PLUS a `visibility === "office"`
|
|
86
86
|
// global OR-clause below that leaked ANY authenticated agent's read of
|
|
87
|
-
// ANY other agent's office-visible memories
|
|
87
|
+
// ANY other agent's office-visible memories. Both are gone;
|
|
88
88
|
// this is the ONE scoping resolution for this endpoint now.
|
|
89
89
|
const scope = agentId ? await resolveReadScope(agentId) : null;
|
|
90
90
|
// Generate query embedding
|
|
@@ -162,13 +162,13 @@ export class SemanticSearch extends Resource {
|
|
|
162
162
|
// from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
|
|
163
163
|
// headroom. Scoped to the legacy (non-hybrid) vector path below — the
|
|
164
164
|
// hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
|
|
165
|
-
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union
|
|
165
|
+
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
|
|
166
166
|
// reranker still applies to its output further down regardless of which
|
|
167
167
|
// path produced `filteredResults`.
|
|
168
168
|
const rerankOn = isRerankEnabled();
|
|
169
169
|
const rerankTopN = getRerankTopN();
|
|
170
170
|
if (hybrid) {
|
|
171
|
-
// ─── BM25 + union-RRF hybrid path
|
|
171
|
+
// ─── BM25 + union-RRF hybrid path ────────────────────────────────────
|
|
172
172
|
// 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
|
|
173
173
|
// over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
|
|
174
174
|
// by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
|
|
@@ -206,7 +206,7 @@ export class SemanticSearch extends Resource {
|
|
|
206
206
|
continue;
|
|
207
207
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
208
208
|
continue;
|
|
209
|
-
//
|
|
209
|
+
// Unconditional past-validTo exclusion (see legacy HNSW
|
|
210
210
|
// loop below for the full rationale) — applies regardless of asOf.
|
|
211
211
|
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
212
212
|
continue;
|
|
@@ -315,7 +315,7 @@ export class SemanticSearch extends Resource {
|
|
|
315
315
|
continue;
|
|
316
316
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
317
317
|
continue;
|
|
318
|
-
//
|
|
318
|
+
// A past validTo ALWAYS means the record has been closed out
|
|
319
319
|
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
320
320
|
// validTo without necessarily setting `archived`). Unconditional, not
|
|
321
321
|
// gated on `asOf`, so a server-superseded record can't resurface in
|
|
@@ -330,9 +330,9 @@ export class SemanticSearch extends Resource {
|
|
|
330
330
|
semanticScore = distanceToSimilarity(record.$distance);
|
|
331
331
|
}
|
|
332
332
|
else {
|
|
333
|
-
// ───
|
|
333
|
+
// ─── Harper's cosine-sort query omits $distance for a
|
|
334
334
|
// SINGLETON post-filter result set — the SAME quirk root-caused and
|
|
335
|
-
// fixed for the dedup path
|
|
335
|
+
// fixed for the dedup path (resources/Memory.ts
|
|
336
336
|
// findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
|
|
337
337
|
// Sort ORDER is still correct; only the numeric `$distance`
|
|
338
338
|
// annotation is missing on that one record, regardless of the
|
|
@@ -340,7 +340,7 @@ export class SemanticSearch extends Resource {
|
|
|
340
340
|
// just limit=1 — the trigger is the post-filter MATCH COUNT, not the
|
|
341
341
|
// requested limit).
|
|
342
342
|
//
|
|
343
|
-
//
|
|
343
|
+
// Layer 1 made this common: the no-grants agent scope used
|
|
344
344
|
// to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
|
|
345
345
|
// {visibility=="office"}]}` condition; resolveReadScope() now emits
|
|
346
346
|
// a PLAIN single `{agentId==X}` condition for the common (no-grants)
|
|
@@ -354,8 +354,8 @@ export class SemanticSearch extends Resource {
|
|
|
354
354
|
//
|
|
355
355
|
// Fix: point-lookup the record by id (a plain get(), unaffected by
|
|
356
356
|
// the sort-query quirk — selecting "embedding" directly on the SAME
|
|
357
|
-
// sort-by-embedding query comes back as a bare scalar per
|
|
358
|
-
//
|
|
357
|
+
// sort-by-embedding query comes back as a bare scalar, per the same
|
|
358
|
+
// investigation above) and compute cosine similarity ourselves in JS from its
|
|
359
359
|
// real stored `embedding` vector against this query's `qEmb`, via
|
|
360
360
|
// the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
|
|
361
361
|
// Only done on this (rare) undefined-$distance branch — never adds
|
|
@@ -405,7 +405,7 @@ export class SemanticSearch extends Resource {
|
|
|
405
405
|
continue;
|
|
406
406
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
407
407
|
continue;
|
|
408
|
-
//
|
|
408
|
+
// Unconditional past-validTo exclusion (see legacy HNSW
|
|
409
409
|
// loop above for the full rationale) — applies regardless of asOf.
|
|
410
410
|
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
411
411
|
continue;
|
|
@@ -449,7 +449,7 @@ export class SemanticSearch extends Resource {
|
|
|
449
449
|
// Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
|
|
450
450
|
// can't do) and reorders before the final slice. Reorders whatever
|
|
451
451
|
// `filteredResults` the retrieval stage above produced — legacy HNSW-only
|
|
452
|
-
// OR the BM25+union-RRF hybrid path
|
|
452
|
+
// OR the BM25+union-RRF hybrid path — since both converge into
|
|
453
453
|
// the same `results`/`filteredResults` shape before this point; hybrid
|
|
454
454
|
// retrieves+fuses, the reranker only reorders the fused set. Still gated
|
|
455
455
|
// on `qEmb` (an embedding was actually generated); the pure keyword-only
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
3
4
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
@@ -37,6 +38,14 @@ export class Soul extends databases.flair.Soul {
|
|
|
37
38
|
content.durability ||= "permanent";
|
|
38
39
|
content.createdAt = new Date().toISOString();
|
|
39
40
|
content.updatedAt = content.createdAt;
|
|
41
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
42
|
+
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
43
|
+
// full contract. No-op if already set (never fires for a genuine local
|
|
44
|
+
// write; a federation-synced record never reaches this method — the
|
|
45
|
+
// merge path writes via the raw table object, bypassing this class).
|
|
46
|
+
if (content.originatorInstanceId == null) {
|
|
47
|
+
content.originatorInstanceId = await localInstanceId();
|
|
48
|
+
}
|
|
40
49
|
return super.post(content, context);
|
|
41
50
|
}
|
|
42
51
|
async put(content, context) {
|
|
@@ -44,6 +53,11 @@ export class Soul extends databases.flair.Soul {
|
|
|
44
53
|
if (denied)
|
|
45
54
|
return denied;
|
|
46
55
|
content.updatedAt = new Date().toISOString();
|
|
56
|
+
// Write-time originatorInstanceId stamp — see post() above / Memory.ts's
|
|
57
|
+
// stampOriginatorInstanceId doc. No-op if already set.
|
|
58
|
+
if (content.originatorInstanceId == null) {
|
|
59
|
+
content.originatorInstanceId = await localInstanceId();
|
|
60
|
+
}
|
|
47
61
|
return super.put(content, context);
|
|
48
62
|
}
|
|
49
63
|
}
|
|
@@ -16,7 +16,7 @@ export class WorkspaceLatest extends Resource {
|
|
|
16
16
|
async get(pathInfo) {
|
|
17
17
|
// Harper v5 does not populate this.context / this.request on Resource
|
|
18
18
|
// subclasses — getContext() is the only reliable path to the gate's
|
|
19
|
-
// tpsAgent/tpsAgentIsAdmin annotations (
|
|
19
|
+
// tpsAgent/tpsAgentIsAdmin annotations (the previous
|
|
20
20
|
// `(this as any).context?.request ?? (this as any).request` read was always
|
|
21
21
|
// undefined, so the ownership check below never ran — fail-open cross-agent
|
|
22
22
|
// read).
|
|
@@ -20,7 +20,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
23
|
-
* read-gate family fix
|
|
23
|
+
* read-gate family fix — applying the Memory.ts/Soul.ts pattern
|
|
24
24
|
* to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
|
|
25
25
|
* P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
|
|
26
26
|
* collection describe (`GET /WorkspaceState`) to a path outside search(),
|
|
@@ -26,7 +26,7 @@ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuff
|
|
|
26
26
|
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
27
27
|
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
28
28
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
29
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
29
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
30
30
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
31
31
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
32
32
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Kept free of any @harperfast/harper import so the real logic is unit-testable
|
|
11
11
|
// without spinning up Harper (avoids the simulator-pattern that let the
|
|
12
|
-
// description-fallback leak ship untested
|
|
12
|
+
// description-fallback leak ship untested).
|
|
13
13
|
export function readSoulKind(entry) {
|
|
14
14
|
return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
|
|
15
15
|
}
|
|
@@ -20,7 +20,7 @@ export function readSoulContent(entry) {
|
|
|
20
20
|
* The public description is ONLY an explicit `kind="description"` soul.
|
|
21
21
|
*
|
|
22
22
|
* There is deliberately no fallback to "the first soul with any content": that
|
|
23
|
-
* was the
|
|
23
|
+
* was the description-fallback leak — an agent with no description soul would publish an
|
|
24
24
|
* arbitrary private soul on the unauthenticated card. Absent an explicit
|
|
25
25
|
* description, publish nothing.
|
|
26
26
|
*/
|