@tpsdev-ai/flair 0.22.0 → 0.23.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/dist/cli.js +612 -119
- package/dist/doctor-client.js +176 -0
- package/dist/install/clients.js +46 -11
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +80 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/migrations/runner.js +2 -3
- package/dist/resources/migrations/space.js +111 -17
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +2 -2
- package/schemas/memory.graphql +2 -2
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* distinguishes internal/agent/anonymous explicitly.
|
|
12
12
|
*/
|
|
13
13
|
import { databases } from "@harperfast/harper";
|
|
14
|
-
import { resolveAgentAuth
|
|
14
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
15
15
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
import { makeAuthGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
17
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
18
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
19
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
20
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
21
|
+
const orgEventAuthGate = makeAuthGate();
|
|
18
22
|
export class OrgEvent extends databases.flair.OrgEvent {
|
|
19
|
-
allowRead() { return
|
|
23
|
+
allowRead() { return orgEventAuthGate.call(this); }
|
|
20
24
|
_auth() {
|
|
21
25
|
return resolveAgentAuth(this.getContext?.());
|
|
22
26
|
}
|
|
@@ -24,19 +28,21 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
24
28
|
const auth = await this._auth();
|
|
25
29
|
if (auth.kind === "anonymous")
|
|
26
30
|
return UNAUTH();
|
|
27
|
-
// No-forge attribution
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.OrgEvent
|
|
32
|
+
// (record-types slice 2, flair#520) rather than hand-typed literals.
|
|
33
|
+
// "stamp-default" (see record-type-kit.ts's stampAttribution doc): a
|
|
34
|
+
// non-admin agent's events are ALWAYS attributed to its authenticated
|
|
35
|
+
// identity (from the Ed25519 signature), never the body — an agent can
|
|
36
|
+
// only publish AS itself. We overwrite `authorId` rather than 403'ing a
|
|
37
|
+
// mismatch so a CLI client never has to echo its own id into the body
|
|
38
|
+
// (mirrors A2A message/send's "sender must match params.agentId" guard
|
|
39
|
+
// and Presence's "agentId from signature, NOT from body"). Admin agents
|
|
40
|
+
// may publish on behalf of another agent (body authorId honored, else
|
|
41
|
+
// their own).
|
|
42
|
+
// "stamp-default" never denies (no rejection branch for non-admin) —
|
|
43
|
+
// the forbiddenMessage arg is dead for this mode, passed for signature
|
|
44
|
+
// completeness only.
|
|
45
|
+
stampAttribution(auth, content, RECORD_TYPES.OrgEvent.ownerField, RECORD_TYPES.OrgEvent.attribution.post, "forbidden: unreachable for stamp-default");
|
|
40
46
|
if (!content.id)
|
|
41
47
|
content.id = `${content.authorId}-${new Date().toISOString()}`;
|
|
42
48
|
content.createdAt = new Date().toISOString();
|
|
@@ -53,9 +59,13 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
53
59
|
const auth = await this._auth();
|
|
54
60
|
if (auth.kind === "anonymous")
|
|
55
61
|
return UNAUTH();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.OrgEvent.
|
|
63
|
+
// "validate-strict" (see record-type-kit.ts's stampAttribution doc):
|
|
64
|
+
// rejects a mismatch INCLUDING when authorId is absent (a bare `!==`
|
|
65
|
+
// compare, no truthy guard).
|
|
66
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.OrgEvent.ownerField, RECORD_TYPES.OrgEvent.attribution.put, "forbidden: authorId must match authenticated agent");
|
|
67
|
+
if (attr.denied)
|
|
68
|
+
return attr.denied;
|
|
59
69
|
// attention-plane vocabulary gate (flair#675) — same as post() above.
|
|
60
70
|
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
61
71
|
if (entitiesError)
|
|
@@ -63,16 +73,17 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
63
73
|
return databases.flair.OrgEvent.put(content);
|
|
64
74
|
}
|
|
65
75
|
async delete(id, context) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
76
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
77
|
+
// three-way branch Memory.ts/Relationship.ts/WorkspaceState.ts use.
|
|
78
|
+
const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
|
|
79
|
+
if (gate.kind === "denied")
|
|
80
|
+
return gate.response;
|
|
81
|
+
if (gate.kind === "unfiltered")
|
|
70
82
|
return super.delete(id, context);
|
|
71
|
-
}
|
|
72
83
|
const record = await this.get(id);
|
|
73
84
|
if (!record)
|
|
74
85
|
return super.delete(id, context);
|
|
75
|
-
if (record.authorId !==
|
|
86
|
+
if (record.authorId !== gate.agentId) {
|
|
76
87
|
return FORBIDDEN("forbidden: cannot delete events authored by another agent");
|
|
77
88
|
}
|
|
78
89
|
return super.delete(id, context);
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
4
|
import { localInstanceId } from "./instance-identity.js";
|
|
5
|
-
import { buildProvenance } from "./
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
6
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
7
|
+
// Parameterized from RECORD_TYPES.Relationship (record-types slice 2,
|
|
8
|
+
// flair#520) rather than a hand-typed "owner-only" literal — the registry is
|
|
9
|
+
// now the single source of truth this class draws its read-scope mode from.
|
|
10
|
+
// Exported solely so test/unit/record-types-registry.test.ts's drift
|
|
11
|
+
// tripwire can introspect the composed resolver's tagged `.mode`/
|
|
12
|
+
// `.ownerField` against RECORD_TYPES.Relationship — not for any other
|
|
13
|
+
// runtime consumer.
|
|
14
|
+
export const relationshipReadScope = makeReadScope(RECORD_TYPES.Relationship.readScope, RECORD_TYPES.Relationship.ownerField);
|
|
15
|
+
const relationshipByIdReadGate = makeByIdReadGate(relationshipReadScope);
|
|
16
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
17
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
18
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
19
|
+
const relationshipAuthGate = makeAuthGate();
|
|
9
20
|
/**
|
|
10
21
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
11
22
|
*
|
|
@@ -28,13 +39,14 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
28
39
|
* ownership scoping happens in get() below; the collection scope is still
|
|
29
40
|
* in search().
|
|
30
41
|
*/
|
|
31
|
-
allowRead() { return
|
|
42
|
+
allowRead() { return relationshipAuthGate.call(this); }
|
|
32
43
|
/**
|
|
33
44
|
* Override get() to scope by-id reads the same way search() scopes
|
|
34
45
|
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
35
46
|
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
36
47
|
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
37
|
-
* relationship ids.
|
|
48
|
+
* relationship ids. Wired through record-type-kit.ts's makeByIdReadGate,
|
|
49
|
+
* scoped "owner-only" — same dispatch shape Memory.ts's get() uses.
|
|
38
50
|
*/
|
|
39
51
|
async get(target) {
|
|
40
52
|
// Collection / query reads arrive as a RequestTarget with
|
|
@@ -47,39 +59,23 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
47
59
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
48
60
|
return this.search(target);
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
52
|
-
// this is defense-in-depth if get() is ever reached directly.
|
|
53
|
-
if (auth.kind === "anonymous") {
|
|
54
|
-
return NOT_FOUND();
|
|
55
|
-
}
|
|
56
|
-
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
57
|
-
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
58
|
-
return super.get(target);
|
|
59
|
-
}
|
|
60
|
-
// Non-admin agent: only its own relationships.
|
|
61
|
-
const record = await super.get(target);
|
|
62
|
-
if (!record)
|
|
63
|
-
return NOT_FOUND();
|
|
64
|
-
if (record.agentId !== auth.agentId)
|
|
65
|
-
return NOT_FOUND();
|
|
66
|
-
return record;
|
|
62
|
+
return relationshipByIdReadGate.call(this, target, (t) => super.get(t));
|
|
67
63
|
}
|
|
68
64
|
async search(query) {
|
|
69
|
-
const
|
|
65
|
+
const ctx = this.getContext?.();
|
|
70
66
|
// Anonymous HTTP must NOT read relationships (previously `!authAgent` was
|
|
71
|
-
// treated as unfiltered — the anonymous-read leak).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
67
|
+
// treated as unfiltered — the anonymous-read leak). Trusted internal call
|
|
68
|
+
// or admin agent → unfiltered. Dispatch shape shared via
|
|
69
|
+
// record-type-kit.ts's resolveAuthGate — same three-way branch Memory.ts/
|
|
70
|
+
// WorkspaceState.ts's search() use.
|
|
71
|
+
const gate = await resolveAuthGate(ctx, UNAUTH());
|
|
72
|
+
if (gate.kind === "denied")
|
|
73
|
+
return gate.response;
|
|
74
|
+
if (gate.kind === "unfiltered")
|
|
79
75
|
return super.search(query);
|
|
80
|
-
}
|
|
81
76
|
// Non-admin agent: scope to own relationships.
|
|
82
|
-
const
|
|
77
|
+
const scope = await relationshipReadScope(gate.agentId);
|
|
78
|
+
const agentCondition = scope.condition;
|
|
83
79
|
if (!query?.conditions) {
|
|
84
80
|
return super.search({ conditions: [agentCondition], ...(query || {}) });
|
|
85
81
|
}
|
|
@@ -130,16 +126,15 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
130
126
|
if (auth.kind === "anonymous") {
|
|
131
127
|
return UNAUTH();
|
|
132
128
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// internal: content.agentId left as provided (unfiltered) — see doc above.
|
|
129
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.Relationship
|
|
130
|
+
// (record-types slice 2, flair#520) rather than a hand-typed literal.
|
|
131
|
+
// "stamp-strict" (see record-type-kit.ts's stampAttribution doc): reject
|
|
132
|
+
// a PRESENT, mismatched agentId, else unconditionally stamp with the
|
|
133
|
+
// verified identity. Admin/internal: content.agentId left as provided
|
|
134
|
+
// (unfiltered) — see doc above.
|
|
135
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.Relationship.ownerField, RECORD_TYPES.Relationship.attribution.put, "cannot write a relationship owned by another agent");
|
|
136
|
+
if (attr.denied)
|
|
137
|
+
return attr.denied;
|
|
143
138
|
if (!content.agentId || typeof content.agentId !== "string") {
|
|
144
139
|
return new Response(JSON.stringify({ error: "agentId is required" }), {
|
|
145
140
|
status: 400, headers: { "content-type": "application/json" },
|
|
@@ -190,6 +185,11 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
190
185
|
// pre-existing row with no provenance field reads back `undefined`,
|
|
191
186
|
// unchanged behavior (migration-equivalence gate).
|
|
192
187
|
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
188
|
+
// flair#718 authorship-provenance — same contract as resources/Memory.ts's
|
|
189
|
+
// post()/put(): `claimedClient` is a write-body-only passthrough, already
|
|
190
|
+
// folded into `provenance.claimed.client` above. Strip it so it is NEVER
|
|
191
|
+
// persisted as a row field.
|
|
192
|
+
delete content.claimedClient;
|
|
193
193
|
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
194
194
|
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
195
195
|
// full contract. No-op if already set (never fires for a genuine local
|
|
@@ -214,17 +214,16 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
214
214
|
*/
|
|
215
215
|
async delete(_) {
|
|
216
216
|
const ctx = this.getContext?.();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
217
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
218
|
+
// three-way branch put()/get()/search() above use.
|
|
219
|
+
const gate = await resolveAuthGate(ctx, UNAUTH());
|
|
220
|
+
if (gate.kind === "denied")
|
|
221
|
+
return gate.response;
|
|
222
|
+
if (gate.kind === "unfiltered")
|
|
223
223
|
return super.delete(_);
|
|
224
|
-
}
|
|
225
224
|
// Non-admin agent: verify ownership before delete.
|
|
226
225
|
const existing = await super.get();
|
|
227
|
-
if (existing?.agentId && existing.agentId !==
|
|
226
|
+
if (existing?.agentId && existing.agentId !== gate.agentId) {
|
|
228
227
|
return FORBIDDEN("cannot delete another agent's relationship");
|
|
229
228
|
}
|
|
230
229
|
return super.delete(_);
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
3
|
import { localInstanceId } from "./instance-identity.js";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { makeAuthGate, stampAttribution, UNAUTH } from "./record-type-kit.js";
|
|
5
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
6
6
|
/**
|
|
7
7
|
* Deny anonymous; enforce per-agent write ownership for non-admin agents.
|
|
8
8
|
* The previous header-based check only fired when an agent WAS present (it read
|
|
9
9
|
* x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
|
|
10
10
|
* through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
|
|
11
11
|
* distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
|
|
12
|
+
*
|
|
13
|
+
* No-forge attribution — mode/field drawn from RECORD_TYPES.Soul (record-
|
|
14
|
+
* types slice 2, flair#520) rather than hand-typed literals. "validate-
|
|
15
|
+
* truthy" (see record-type-kit.ts's stampAttribution doc) — rejects a
|
|
16
|
+
* PRESENT, mismatched agentId; passes through untouched when absent. Same
|
|
17
|
+
* idiom as Memory.post()/put().
|
|
12
18
|
*/
|
|
13
19
|
async function enforceWriteAuth(self, data) {
|
|
14
20
|
const auth = await resolveAgentAuth(self.getContext?.());
|
|
15
21
|
if (auth.kind === "anonymous")
|
|
16
22
|
return UNAUTH();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
return null;
|
|
23
|
+
const attr = stampAttribution(auth, data, RECORD_TYPES.Soul.ownerField, RECORD_TYPES.Soul.attribution.post, "forbidden: agentId must match authenticated agent");
|
|
24
|
+
return attr.denied ?? null;
|
|
21
25
|
}
|
|
26
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
27
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
28
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
29
|
+
const soulAuthGate = makeAuthGate();
|
|
22
30
|
export class Soul extends databases.flair.Soul {
|
|
23
31
|
/**
|
|
24
32
|
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
@@ -30,7 +38,7 @@ export class Soul extends databases.flair.Soul {
|
|
|
30
38
|
* verified agent — same posture as Agent.ts's allowRead. Write ownership
|
|
31
39
|
* is unaffected — enforceWriteAuth() below already gates post()/put().
|
|
32
40
|
*/
|
|
33
|
-
allowRead() { return
|
|
41
|
+
allowRead() { return soulAuthGate.call(this); }
|
|
34
42
|
async post(content, context) {
|
|
35
43
|
const denied = await enforceWriteAuth(this, content);
|
|
36
44
|
if (denied)
|
|
@@ -8,11 +8,23 @@
|
|
|
8
8
|
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
|
-
import { resolveAgentAuth
|
|
11
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
12
12
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import { makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
14
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
15
|
+
// Parameterized from RECORD_TYPES.WorkspaceState (record-types slice 2,
|
|
16
|
+
// flair#520) rather than a hand-typed "owner-only" literal — the registry is
|
|
17
|
+
// now the single source of truth this class draws its read-scope mode from.
|
|
18
|
+
// Exported solely so test/unit/record-types-registry.test.ts's drift
|
|
19
|
+
// tripwire can introspect the composed resolver's tagged `.mode`/
|
|
20
|
+
// `.ownerField` against RECORD_TYPES.WorkspaceState — not for any other
|
|
21
|
+
// runtime consumer.
|
|
22
|
+
export const workspaceReadScope = makeReadScope(RECORD_TYPES.WorkspaceState.readScope, RECORD_TYPES.WorkspaceState.ownerField);
|
|
23
|
+
const workspaceByIdReadGate = makeByIdReadGate(workspaceReadScope);
|
|
24
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
25
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
26
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
27
|
+
const workspaceAuthGate = makeAuthGate();
|
|
16
28
|
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
17
29
|
/** Auth verdict from the request context. internal = trusted in-process call;
|
|
18
30
|
* agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
|
|
@@ -29,13 +41,15 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
29
41
|
* full record content. Per-record ownership scoping happens in get() below;
|
|
30
42
|
* the collection scope is still in search().
|
|
31
43
|
*/
|
|
32
|
-
allowRead() { return
|
|
44
|
+
allowRead() { return workspaceAuthGate.call(this); }
|
|
33
45
|
/**
|
|
34
46
|
* Override get() to scope by-id reads the same way search() scopes
|
|
35
47
|
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
36
48
|
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
37
49
|
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
38
|
-
* workspace-state ids.
|
|
50
|
+
* workspace-state ids. Wired through record-type-kit.ts's
|
|
51
|
+
* makeByIdReadGate, scoped "owner-only" — same dispatch shape
|
|
52
|
+
* Memory.ts/Relationship.ts's get() use.
|
|
39
53
|
*/
|
|
40
54
|
async get(target) {
|
|
41
55
|
// Collection / query reads — the `GET /WorkspaceState/?<query>` form and
|
|
@@ -49,23 +63,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
49
63
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
50
64
|
return this.search(target);
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
54
|
-
// this is defense-in-depth if get() is ever reached directly.
|
|
55
|
-
if (auth.kind === "anonymous") {
|
|
56
|
-
return NOT_FOUND();
|
|
57
|
-
}
|
|
58
|
-
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
59
|
-
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
60
|
-
return super.get(target);
|
|
61
|
-
}
|
|
62
|
-
// Non-admin agent: only its own workspace-state records.
|
|
63
|
-
const record = await super.get(target);
|
|
64
|
-
if (!record)
|
|
65
|
-
return NOT_FOUND();
|
|
66
|
-
if (record.agentId !== auth.agentId)
|
|
67
|
-
return NOT_FOUND();
|
|
68
|
-
return record;
|
|
66
|
+
return workspaceByIdReadGate.call(this, target, (t) => super.get(t));
|
|
69
67
|
}
|
|
70
68
|
/**
|
|
71
69
|
* Override search() to scope collection GETs to the authenticated agent's own
|
|
@@ -73,13 +71,15 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
73
71
|
* (previously `!authAgent` was treated as unfiltered — the anonymous-read leak).
|
|
74
72
|
*/
|
|
75
73
|
async search(query) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
74
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
75
|
+
// three-way branch Memory.ts/Relationship.ts's search() use.
|
|
76
|
+
const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
|
|
77
|
+
if (gate.kind === "denied")
|
|
78
|
+
return gate.response;
|
|
79
|
+
if (gate.kind === "unfiltered")
|
|
80
80
|
return super.search(query);
|
|
81
|
-
|
|
82
|
-
const agentIdCondition =
|
|
81
|
+
const scope = await workspaceReadScope(gate.agentId);
|
|
82
|
+
const agentIdCondition = scope.condition;
|
|
83
83
|
// Harper passes `query` as a request target object (pathname, id, isCollection…).
|
|
84
84
|
// Inject the scope condition into its `.conditions` array.
|
|
85
85
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
@@ -100,19 +100,20 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
100
100
|
// there was no authenticated agent, so anonymous could write any record).
|
|
101
101
|
if (auth.kind === "anonymous")
|
|
102
102
|
return UNAUTH();
|
|
103
|
-
// No-forge
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.WorkspaceState
|
|
104
|
+
// (record-types slice 2, flair#520) rather than hand-typed literals.
|
|
105
|
+
// "stamp-default" (see record-type-kit.ts's stampAttribution doc): a
|
|
106
|
+
// non-admin agent's workspace record is ALWAYS attributed to the
|
|
107
|
+
// authenticated identity (from the Ed25519 signature), never the body.
|
|
108
|
+
// We do NOT trust `content.agentId` — overwriting it (rather than
|
|
109
|
+
// 403'ing a mismatch) mirrors Presence.post(): "agentId from signature,
|
|
110
|
+
// NOT from body". An admin may write on behalf of another agent
|
|
111
|
+
// (content.agentId honored if present, else defaults to the admin's own
|
|
112
|
+
// id). Internal in-process callers keep whatever agentId they pass.
|
|
113
|
+
// "stamp-default" never denies (no rejection branch for non-admin) —
|
|
114
|
+
// the forbiddenMessage arg is dead for this mode, passed for signature
|
|
115
|
+
// completeness only.
|
|
116
|
+
stampAttribution(auth, content, RECORD_TYPES.WorkspaceState.ownerField, RECORD_TYPES.WorkspaceState.attribution.post, "forbidden: unreachable for stamp-default");
|
|
116
117
|
content.createdAt = new Date().toISOString();
|
|
117
118
|
content.timestamp ||= content.createdAt;
|
|
118
119
|
// attention-plane vocabulary gate (flair#675): `entities`, if present,
|
|
@@ -127,9 +128,13 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
127
128
|
const auth = await this._auth();
|
|
128
129
|
if (auth.kind === "anonymous")
|
|
129
130
|
return UNAUTH();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.WorkspaceState.
|
|
132
|
+
// "validate-strict" (see record-type-kit.ts's stampAttribution doc):
|
|
133
|
+
// rejects a mismatch INCLUDING when agentId is absent (a bare `!==`
|
|
134
|
+
// compare, no truthy guard).
|
|
135
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.WorkspaceState.ownerField, RECORD_TYPES.WorkspaceState.attribution.put, "forbidden: cannot write workspace state for another agent");
|
|
136
|
+
if (attr.denied)
|
|
137
|
+
return attr.denied;
|
|
133
138
|
// attention-plane vocabulary gate (flair#675) — same as post() above.
|
|
134
139
|
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
135
140
|
if (entitiesError)
|
|
@@ -137,15 +142,14 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
137
142
|
return super.put(content);
|
|
138
143
|
}
|
|
139
144
|
async delete(id) {
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
return
|
|
145
|
-
if (
|
|
145
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
146
|
+
// three-way branch get()/search() above use.
|
|
147
|
+
const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
|
|
148
|
+
if (gate.kind === "denied")
|
|
149
|
+
return gate.response;
|
|
150
|
+
if (gate.kind === "unfiltered")
|
|
146
151
|
return super.delete(id);
|
|
147
|
-
|
|
148
|
-
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
152
|
+
// Use super.get(id), NOT this.get(id): the get() override above 404s
|
|
149
153
|
// (a truthy Response) for a non-owner id, which would otherwise defeat
|
|
150
154
|
// the `if (!record)` check below and mis-route a genuinely-missing
|
|
151
155
|
// record into the FORBIDDEN branch instead of a clean super.delete(id)
|
|
@@ -153,7 +157,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
153
157
|
const record = await super.get(id);
|
|
154
158
|
if (!record)
|
|
155
159
|
return super.delete(id);
|
|
156
|
-
if (record.agentId !==
|
|
160
|
+
if (record.agentId !== gate.agentId) {
|
|
157
161
|
return FORBIDDEN("forbidden: cannot delete workspace state for another agent");
|
|
158
162
|
}
|
|
159
163
|
return super.delete(id);
|
|
@@ -65,12 +65,21 @@ function jitProvisionEnabled() {
|
|
|
65
65
|
* XAA's ID-JAG path uses (resources/XAA.ts resolveOrCreatePrincipal) — one
|
|
66
66
|
* identity model, keyed on the IdP subject.
|
|
67
67
|
*
|
|
68
|
+
* `clientId` (flair#718 authorship-provenance) is NOT part of sub resolution —
|
|
69
|
+
* it rides along from the verified token's `client_id` claim (see
|
|
70
|
+
* handleToolCall's stamp-site comment for why `client_id` and never
|
|
71
|
+
* `client_name`) and is copied onto the resolved agent unchanged so downstream
|
|
72
|
+
* write tools (memory_store/memory_update) can thread it into `claimedClient`.
|
|
73
|
+
* Omitted from the returned object entirely when absent — never stamped as
|
|
74
|
+
* `undefined` — so existing callers/tests that don't pass one see the exact
|
|
75
|
+
* same `{ agentId, isAdmin }` shape as before this field existed.
|
|
76
|
+
*
|
|
68
77
|
* Returns:
|
|
69
|
-
* - `{ agentId, isAdmin }` when a Credential maps the sub to an Agent.
|
|
78
|
+
* - `{ agentId, isAdmin, clientId? }` when a Credential maps the sub to an Agent.
|
|
70
79
|
* - null when no Credential maps the sub AND JIT-provisioning is disabled or
|
|
71
80
|
* failed → the handler denies the tool call (sub is unresolvable).
|
|
72
81
|
*/
|
|
73
|
-
export async function resolveAgentFromSub(sub) {
|
|
82
|
+
export async function resolveAgentFromSub(sub, clientId) {
|
|
74
83
|
if (!sub)
|
|
75
84
|
return null;
|
|
76
85
|
// 1. Existing IdP credential → its principalId is the Agent id.
|
|
@@ -87,7 +96,10 @@ export async function resolveAgentFromSub(sub) {
|
|
|
87
96
|
await databases.flair.Credential.put({ ...cred, lastUsedAt: new Date().toISOString() });
|
|
88
97
|
}
|
|
89
98
|
catch { /* non-fatal */ }
|
|
90
|
-
|
|
99
|
+
const resolved = { agentId: String(cred.principalId), isAdmin: await isAgentAdmin(cred.principalId) };
|
|
100
|
+
if (clientId)
|
|
101
|
+
resolved.clientId = clientId;
|
|
102
|
+
return resolved;
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
105
|
}
|
|
@@ -98,7 +110,10 @@ export async function resolveAgentFromSub(sub) {
|
|
|
98
110
|
try {
|
|
99
111
|
const principalId = await jitProvisionPrincipal(sub);
|
|
100
112
|
// A JIT-provisioned principal is a fresh, non-admin agent by construction.
|
|
101
|
-
|
|
113
|
+
const resolved = { agentId: principalId, isAdmin: false };
|
|
114
|
+
if (clientId)
|
|
115
|
+
resolved.clientId = clientId;
|
|
116
|
+
return resolved;
|
|
102
117
|
}
|
|
103
118
|
catch {
|
|
104
119
|
return null;
|
|
@@ -221,7 +236,16 @@ async function handleToolCall(request, id, params) {
|
|
|
221
236
|
if (!sub) {
|
|
222
237
|
return rpcError(id, -32001, "unauthorized: no verified token subject");
|
|
223
238
|
}
|
|
224
|
-
|
|
239
|
+
// flair#718 authorship-provenance, Sherlock's binding refinement: source
|
|
240
|
+
// the authorship stamp from `client_id` (the server-generated
|
|
241
|
+
// `flair_cl_...` machine id — resources/OAuth.ts, an RS256-verified JWT
|
|
242
|
+
// claim, not a secret) — NEVER `client_name` (a free-text label the client
|
|
243
|
+
// supplied at Dynamic Client Registration time and fully controls). Do NOT
|
|
244
|
+
// "helpfully" switch this to `client_name` for a prettier label; that would
|
|
245
|
+
// turn a server-verified, stable id into caller-forgeable data landing in
|
|
246
|
+
// stored provenance. Absent/non-string client_id → omitted, not invented.
|
|
247
|
+
const clientId = typeof request?.mcp?.client_id === "string" ? request.mcp.client_id : undefined;
|
|
248
|
+
const agent = await resolveAgentFromSub(String(sub), clientId);
|
|
225
249
|
if (!agent) {
|
|
226
250
|
// Sub verified by the AS but not mapped to a flair Agent (and JIT disabled /
|
|
227
251
|
// failed). Deny — do NOT fall back to anonymous or admin.
|