@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.
Files changed (49) hide show
  1. package/README.md +29 -4
  2. package/SECURITY.md +28 -18
  3. package/dist/cli.js +482 -32
  4. package/dist/deploy.js +280 -18
  5. package/dist/doctor-client.js +312 -0
  6. package/dist/install/clients.js +18 -0
  7. package/dist/rem/restore.js +1 -1
  8. package/dist/resources/Admin.js +1 -1
  9. package/dist/resources/AdminConnectors.js +1 -1
  10. package/dist/resources/AdminDashboard.js +1 -1
  11. package/dist/resources/AdminIdp.js +1 -1
  12. package/dist/resources/AdminInstance.js +1 -1
  13. package/dist/resources/AdminMemory.js +2 -2
  14. package/dist/resources/AdminPrincipals.js +1 -1
  15. package/dist/resources/Agent.js +14 -0
  16. package/dist/resources/AgentCard.js +1 -1
  17. package/dist/resources/AgentSeed.js +1 -1
  18. package/dist/resources/Federation.js +98 -2
  19. package/dist/resources/IngestEvents.js +1 -1
  20. package/dist/resources/Integration.js +1 -1
  21. package/dist/resources/Memory.js +123 -17
  22. package/dist/resources/MemoryBootstrap.js +46 -36
  23. package/dist/resources/MemoryGrant.js +1 -1
  24. package/dist/resources/OAuth.js +61 -4
  25. package/dist/resources/OrgEventCatchup.js +1 -1
  26. package/dist/resources/Presence.js +55 -3
  27. package/dist/resources/Relationship.js +10 -1
  28. package/dist/resources/SemanticSearch.js +14 -14
  29. package/dist/resources/Soul.js +14 -0
  30. package/dist/resources/WorkspaceLatest.js +1 -1
  31. package/dist/resources/WorkspaceState.js +1 -1
  32. package/dist/resources/agent-auth.js +1 -1
  33. package/dist/resources/agentcard-fields.js +2 -2
  34. package/dist/resources/auth-middleware.js +24 -5
  35. package/dist/resources/bm25-filter.js +1 -1
  36. package/dist/resources/bm25.js +1 -1
  37. package/dist/resources/dedup.js +2 -2
  38. package/dist/resources/ed25519-auth.js +2 -2
  39. package/dist/resources/embeddings-provider.js +1 -1
  40. package/dist/resources/federation-nonce-store.js +195 -0
  41. package/dist/resources/instance-identity.js +53 -0
  42. package/dist/resources/memory-bootstrap-lib.js +1 -1
  43. package/dist/resources/memory-read-scope.js +58 -71
  44. package/dist/resources/memory-visibility.js +37 -0
  45. package/dist/version-check.js +167 -0
  46. package/package.json +2 -2
  47. package/schemas/agent.graphql +7 -0
  48. package/schemas/federation.graphql +12 -0
  49. 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: a grant-visible teammate's
18
- * SHARED memory that scores against currentTask lands here instead of
19
- * #4, attributed via "[via <agentId>]". Presentation only what's
20
- * readable is entirely Layer 1's resolveReadScope()/ops-2dm3; this only
21
- * changes how an already-read cross-agent record is formatted/sectioned)
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 any granted owner's
26
- * SHARED memories (ops-2dm3 Layer 1, never their private ones), so this
27
- * section nudges toward memory_search for anything beyond that window)
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 granted owners' SHARED memories (ops-2dm3
203
- // Layer 1) but this section stays: memory_search/SemanticSearch remains
204
- // the deliberate, query-driven way to find a teammate's finding, vs.
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) + granted owners' SHARED memories only
229
- // (ops-2dm3 Layer 1 never a granted owner's private ones). Centralized
230
- // in resolveReadScope(): the condition is pushed into the Harper query
231
- // (so the table itself never returns an out-of-scope row), and
232
- // `scope.isAllowed` re-checks in-process as defense-in-depth (same
233
- // belt-and-suspenders discipline as SemanticSearch's BM25 pre-fusion
234
- // filter) — this is the #550 foundation: bootstrap can now safely expand
235
- // beyond own-only without a parallel scoping rule.
231
+ // Read-scope: own (any visibility) + every OTHER in-org agent's
232
+ // non-private memoryopen-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
- // ops-hesq: a past validTo ALWAYS means the record has been closed out
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 ops-9rc6's SemanticSearch/bm25-filter exclusion. Unconditional
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 (granted-owner) records — same convention
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. Post-Layer-1
272
- // `activeMemories` also carries grant-visible teammate records (`_source`
273
- // set), but grant-visibility exists to feed the task-relevant "Teammate
274
- // findings" surfacing (#550) below, NOT to blend a teammate's memories into
275
- // the reader's recent/permanent/predicted view. So these three sections
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; a teammate's (grant-visible, already
423
- // read-scoped by Layer 1 — `m._source` is only ever set for a
424
- // cross-agent record, see the allMemories loop above) go to the new
425
- // `teammate` section so the agent can tell it apart at a glance.
426
- // Both draw from the SAME `tokenBudget` in one score-ordered pass
427
- // highest-relevance memories win the remaining budget regardless of
428
- // which section they land in, so neither section double-spends.
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 grant-visible teammate findings for
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, ops-oox7 — same pattern as Memory.ts/Soul.ts/
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
@@ -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 (ops-sal4: the previous
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 Response.redirect(`${redirectUri}?${params}`, 302);
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 (ops-sal4 identity spoof).
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 Response.redirect(`${redirectUri}?${params}`, 302);
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 (ops-sal4: the previous `(this as
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 (bd ops-c4op) — the single
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
- currentTask: sanitizeCurrentTask(row?.currentTask),
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, ops-oox7 — same pattern as Memory.ts/Soul.ts/
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 (ops-i39b / FLAIR-BM25-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
- // (ops-2dm3 Layer 1). Centralized in resolveReadScope() — this used to be
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 (ops-nzxa). Both are gone;
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 (ops-i39b); the
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 (ops-i39b) ─────────────────────────
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
- // ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
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
- // ops-9rc6: a past validTo ALWAYS means the record has been closed out
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
- // ─── ops-syzm: Harper's cosine-sort query omits $distance for a
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 in ops-ume4 (resources/Memory.ts
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
- // ops-2dm3 Layer 1 made this common: the no-grants agent scope used
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 ops-ume4's
358
- // findings) and compute cosine similarity ourselves in JS from its
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
- // ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
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 (ops-i39b) — since both converge into
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
@@ -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 (ops-sal4: the previous
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, ops-oox7 — applying the Memory.ts/Soul.ts pattern
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 (bd ops-c4op) — the single
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 — ops-vz6j).
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 ops-vz6j leak — an agent with no description soul would publish an
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
  */