@tpsdev-ai/flair 0.11.0 → 0.12.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 +199 -16
- package/dist/resources/Agent.js +20 -7
- package/dist/resources/AgentCard.js +6 -0
- package/dist/resources/AgentSeed.js +7 -1
- package/dist/resources/Credential.js +24 -13
- package/dist/resources/IngestEvents.js +7 -0
- package/dist/resources/Instance.js +13 -0
- package/dist/resources/Integration.js +64 -0
- package/dist/resources/Memory.js +53 -8
- package/dist/resources/MemoryBootstrap.js +8 -0
- package/dist/resources/MemoryConsolidate.js +7 -1
- package/dist/resources/MemoryFeed.js +8 -0
- package/dist/resources/MemoryGrant.js +79 -0
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +7 -1
- package/dist/resources/MemoryReindex.js +7 -1
- package/dist/resources/OAuthClient.js +15 -0
- package/dist/resources/ObsAgentSnapshot.js +13 -0
- package/dist/resources/ObsEventFeed.js +13 -0
- package/dist/resources/ObsOffice.js +19 -0
- package/dist/resources/OrgEvent.js +37 -21
- package/dist/resources/OrgEventCatchup.js +8 -0
- package/dist/resources/OrgEventMaintenance.js +7 -0
- package/dist/resources/PairingToken.js +14 -0
- package/dist/resources/Peer.js +15 -0
- package/dist/resources/Presence.js +30 -0
- package/dist/resources/Relationship.js +13 -7
- package/dist/resources/SemanticSearch.js +25 -8
- package/dist/resources/Soul.js +18 -9
- package/dist/resources/SoulFeed.js +7 -0
- package/dist/resources/WorkspaceLatest.js +7 -0
- package/dist/resources/WorkspaceState.js +38 -28
- package/dist/resources/XAA.js +8 -0
- package/dist/resources/agent-auth.js +209 -0
- package/dist/resources/auth-middleware.js +83 -55
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -736,25 +736,30 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
|
|
|
736
736
|
// FederationPair resource handler. The role carries no table permissions itself
|
|
737
737
|
// — the resource's own allowCreate bypass handles route-level access once the
|
|
738
738
|
// request gets through the auth gate.
|
|
739
|
-
/**
|
|
739
|
+
/**
|
|
740
|
+
* Canonical permission spec for flair_pair_initiator.
|
|
741
|
+
*
|
|
742
|
+
* The role intentionally carries NO table permissions — its only job is to exist
|
|
743
|
+
* so bootstrap credentials can pass Harper platform auth before reaching the
|
|
744
|
+
* FederationPair resource handler (the resource's own allowCreate bypass handles
|
|
745
|
+
* route-level access). A bare role with both flags false is valid, grants nothing,
|
|
746
|
+
* and is exactly that intent.
|
|
747
|
+
*
|
|
748
|
+
* This previously carried an all-false `flair.tables` block, but Harper's add_role
|
|
749
|
+
* REJECTED the whole spec with a 400 (verified live against a spawned Harper):
|
|
750
|
+
* - `cluster_user` is not a recognized top-level key — Harper reads unknown keys
|
|
751
|
+
* as database names ("database 'cluster_user' does not exist");
|
|
752
|
+
* - the table names were the logical shorthand, not the real @table names
|
|
753
|
+
* ("Table 'flair.Workspace' does not exist" — it's WorkspaceState; "Event" is
|
|
754
|
+
* OrgEvent; "OAuth" is OAuthClient);
|
|
755
|
+
* - each grant omitted the required `attribute_permissions` array.
|
|
756
|
+
* The all-false block granted nothing anyway, so dropping it loses no capability
|
|
757
|
+
* and unbreaks fresh hub provisioning (where add_role runs and the 400 aborted
|
|
758
|
+
* `flair init --remote`). Only top-level booleans Harper recognizes remain.
|
|
759
|
+
*/
|
|
740
760
|
const PAIR_INITIATOR_PERMISSION = {
|
|
741
761
|
super_user: false,
|
|
742
|
-
cluster_user: false,
|
|
743
762
|
structure_user: false,
|
|
744
|
-
flair: {
|
|
745
|
-
tables: {
|
|
746
|
-
Memory: { read: false, insert: false, update: false, delete: false },
|
|
747
|
-
Soul: { read: false, insert: false, update: false, delete: false },
|
|
748
|
-
Agent: { read: false, insert: false, update: false, delete: false },
|
|
749
|
-
Workspace: { read: false, insert: false, update: false, delete: false },
|
|
750
|
-
Event: { read: false, insert: false, update: false, delete: false },
|
|
751
|
-
OAuth: { read: false, insert: false, update: false, delete: false },
|
|
752
|
-
Instance: { read: false, insert: false, update: false, delete: false },
|
|
753
|
-
Peer: { read: false, insert: false, update: false, delete: false },
|
|
754
|
-
PairingToken: { read: false, insert: false, update: false, delete: false },
|
|
755
|
-
SyncLog: { read: false, insert: false, update: false, delete: false },
|
|
756
|
-
},
|
|
757
|
-
},
|
|
758
763
|
};
|
|
759
764
|
/**
|
|
760
765
|
* Idempotently ensures the `flair_pair_initiator` role exists on the Harper
|
|
@@ -805,6 +810,174 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
|
|
|
805
810
|
}, adminUser, adminPass);
|
|
806
811
|
console.log(`Role '${ROLE_NAME}' updated ✓`);
|
|
807
812
|
}
|
|
813
|
+
// ─── flair_agent role ────────────────────────────────────────────────────────
|
|
814
|
+
//
|
|
815
|
+
// The auth reshape replaces the global gate's "verified agent borrows admin
|
|
816
|
+
// super_user" elevation with a real, least-privilege Harper role. After an
|
|
817
|
+
// agent's Ed25519 signature verifies, resources resolve the request to the
|
|
818
|
+
// shared `flair_agent`-roled user instead of admin — so agents get exactly the
|
|
819
|
+
// table CRUD below and nothing more. Critically: with no `super_user` and no
|
|
820
|
+
// operations grants, /sql and /graphql become NATIVELY 403 for agents (the
|
|
821
|
+
// raw-query block the gate hand-rolled is now enforced by Harper itself).
|
|
822
|
+
//
|
|
823
|
+
// Row-level ownership (an agent touches only its OWN memories/soul/events) is
|
|
824
|
+
// NOT expressible in Harper's role model — it stays in each resource's allow*,
|
|
825
|
+
// keyed on the Ed25519-verified agentId. So these per-table grants are the
|
|
826
|
+
// coarse CRUD envelope; allow* is the ownership boundary inside it.
|
|
827
|
+
//
|
|
828
|
+
// VALIDATION GATES (must confirm against a live Harper before this role goes
|
|
829
|
+
// live — flagged for Sherlock + the PR, not assumed):
|
|
830
|
+
// 1. HNSW/vector search (SemanticSearch over Memory) works with table `read`
|
|
831
|
+
// alone — the old elevation comment claimed admin perms were needed for
|
|
832
|
+
// "HNSW-capable" access; confirm `read` suffices or widen precisely.
|
|
833
|
+
// 2. Role table keys must EXACTLY match the @table names (Memory, OrgEvent,
|
|
834
|
+
// WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
|
|
835
|
+
// shorthand the flair_pair_initiator spec used, which was harmless only
|
|
836
|
+
// because every grant there is false).
|
|
837
|
+
// 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
|
|
838
|
+
// it needs insert/update — currently read-only here; confirm the writer's
|
|
839
|
+
// identity (system vs agent) and widen only if it's the agent.
|
|
840
|
+
// Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
|
|
841
|
+
// grant (empty = no attribute-level restriction, so the table-level CRUD applies);
|
|
842
|
+
// omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
|
|
843
|
+
// array"). Validated live against a spawned Harper. This helper guarantees the
|
|
844
|
+
// array is never forgotten. Also: `cluster_user` is NOT a valid top-level key —
|
|
845
|
+
// Harper reads unrecognized top-level keys as database names ("database
|
|
846
|
+
// 'cluster_user' does not exist"); only super_user / structure_user are recognized.
|
|
847
|
+
const grant = (read, insert, update, del) => ({ read, insert, update, delete: del, attribute_permissions: [] });
|
|
848
|
+
/** Canonical permission spec for flair_agent (least-privilege; real @table names). */
|
|
849
|
+
const FLAIR_AGENT_PERMISSION = {
|
|
850
|
+
super_user: false,
|
|
851
|
+
structure_user: false,
|
|
852
|
+
flair: {
|
|
853
|
+
tables: {
|
|
854
|
+
// Core agent-owned data — CRUD envelope; ownership enforced in allow*.
|
|
855
|
+
Memory: grant(true, true, true, true),
|
|
856
|
+
MemoryCandidate: grant(true, true, true, true),
|
|
857
|
+
MemoryGrant: grant(true, true, true, true),
|
|
858
|
+
Soul: grant(true, true, true, false),
|
|
859
|
+
OrgEvent: grant(true, true, true, true),
|
|
860
|
+
WorkspaceState: grant(true, true, true, true),
|
|
861
|
+
Relationship: grant(true, true, true, true),
|
|
862
|
+
Integration: grant(true, true, true, true),
|
|
863
|
+
Credential: grant(true, true, true, true),
|
|
864
|
+
Presence: grant(true, true, true, false),
|
|
865
|
+
// Agent: read for discovery, update own card; creation/removal is admin.
|
|
866
|
+
Agent: grant(true, false, true, false),
|
|
867
|
+
// Read-only reference data.
|
|
868
|
+
Instance: grant(true, false, false, false),
|
|
869
|
+
// Observatory read-models — public reads; writes are system-driven (gate 3).
|
|
870
|
+
ObsOffice: grant(true, false, false, false),
|
|
871
|
+
ObsAgentSnapshot: grant(true, false, false, false),
|
|
872
|
+
ObsEventFeed: grant(true, false, false, false),
|
|
873
|
+
// Federation / OAuth / IdP / internal — system + admin only; agents get none.
|
|
874
|
+
Peer: grant(false, false, false, false),
|
|
875
|
+
PairingToken: grant(false, false, false, false),
|
|
876
|
+
SyncLog: grant(false, false, false, false),
|
|
877
|
+
OAuthClient: grant(false, false, false, false),
|
|
878
|
+
OAuthToken: grant(false, false, false, false),
|
|
879
|
+
OAuthAuthCode: grant(false, false, false, false),
|
|
880
|
+
IdpConfig: grant(false, false, false, false),
|
|
881
|
+
IdJagReplay: grant(false, false, false, false),
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
/**
|
|
886
|
+
* Idempotently ensures the `flair_agent` role exists on the Harper instance at
|
|
887
|
+
* `opsUrl` with the canonical least-privilege spec. Same list/add/alter shape as
|
|
888
|
+
* ensureFlairPairInitiatorRole.
|
|
889
|
+
*
|
|
890
|
+
* - absent → `add_role`
|
|
891
|
+
* - exists with different permissions → `alter_role`
|
|
892
|
+
* - already matches → no-op
|
|
893
|
+
*/
|
|
894
|
+
export async function ensureFlairAgentRole(opsUrl, adminUser, adminPass) {
|
|
895
|
+
const ROLE_NAME = "flair_agent";
|
|
896
|
+
let roles = [];
|
|
897
|
+
try {
|
|
898
|
+
const result = await callOpsApi(opsUrl, { operation: "list_roles" }, adminUser, adminPass);
|
|
899
|
+
roles = Array.isArray(result) ? result : [];
|
|
900
|
+
}
|
|
901
|
+
catch (err) {
|
|
902
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
903
|
+
throw new Error(`ensureFlairAgentRole: list_roles failed: ${msg}`);
|
|
904
|
+
}
|
|
905
|
+
const existing = roles.find((r) => r.role === ROLE_NAME || r.name === ROLE_NAME);
|
|
906
|
+
if (!existing) {
|
|
907
|
+
console.log(`Creating role '${ROLE_NAME}'...`);
|
|
908
|
+
await callOpsApi(opsUrl, {
|
|
909
|
+
operation: "add_role",
|
|
910
|
+
role: ROLE_NAME,
|
|
911
|
+
permission: FLAIR_AGENT_PERMISSION,
|
|
912
|
+
}, adminUser, adminPass);
|
|
913
|
+
console.log(`Role '${ROLE_NAME}' created ✓`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const existingPerm = existing.permission ?? existing.role?.permission;
|
|
917
|
+
const canonicalStr = JSON.stringify(FLAIR_AGENT_PERMISSION);
|
|
918
|
+
const existingStr = JSON.stringify(existingPerm);
|
|
919
|
+
if (existingStr === canonicalStr) {
|
|
920
|
+
console.log(`Role '${ROLE_NAME}' already exists with correct permissions — skipping`);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
console.log(`Role '${ROLE_NAME}' exists but permissions differ — updating...`);
|
|
924
|
+
await callOpsApi(opsUrl, {
|
|
925
|
+
operation: "alter_role",
|
|
926
|
+
role: ROLE_NAME,
|
|
927
|
+
permission: FLAIR_AGENT_PERMISSION,
|
|
928
|
+
}, adminUser, adminPass);
|
|
929
|
+
console.log(`Role '${ROLE_NAME}' updated ✓`);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Shared Harper user that verified Ed25519 agents are resolved to.
|
|
933
|
+
* MUST match FLAIR_AGENT_USERNAME in resources/agent-auth.ts (the gate side).
|
|
934
|
+
* Not imported from there because cli.ts is standalone and that module pulls in
|
|
935
|
+
* Harper's native bindings — kept as a cross-referenced literal instead.
|
|
936
|
+
*/
|
|
937
|
+
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
938
|
+
/**
|
|
939
|
+
* Idempotently ensures the shared `flair-agent` Harper user exists with the
|
|
940
|
+
* `flair_agent` role. Verified Ed25519 agents are resolved to THIS user
|
|
941
|
+
* (`getUser("flair-agent", null)` — no password check, identity already proven
|
|
942
|
+
* cryptographically), replacing the old `getUser("admin")` super_user elevation.
|
|
943
|
+
*
|
|
944
|
+
* The password is random and never used for authentication: the agent path
|
|
945
|
+
* resolves the user without it, and the auth gate's Basic path only accepts
|
|
946
|
+
* super_user / pair-bootstrap (a flair_agent Basic login is rejected there), so
|
|
947
|
+
* a Basic login as this user can't reach anything. Random + unused = safe.
|
|
948
|
+
*
|
|
949
|
+
* Row-level ownership stays in each resource's allow* (keyed on the verified
|
|
950
|
+
* agentId); this shared user only carries the flair_agent role's table grants.
|
|
951
|
+
*/
|
|
952
|
+
export async function ensureFlairAgentUser(opsUrl, adminUser, adminPass) {
|
|
953
|
+
const unusedPassword = randomBytes(32).toString("base64url");
|
|
954
|
+
try {
|
|
955
|
+
await callOpsApi(opsUrl, {
|
|
956
|
+
operation: "add_user",
|
|
957
|
+
username: FLAIR_AGENT_USERNAME,
|
|
958
|
+
password: unusedPassword,
|
|
959
|
+
role: "flair_agent",
|
|
960
|
+
active: true,
|
|
961
|
+
}, adminUser, adminPass);
|
|
962
|
+
console.log(`User '${FLAIR_AGENT_USERNAME}' created ✓`);
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
966
|
+
if (msg.includes("already exists") || msg.includes("duplicate")) {
|
|
967
|
+
// Idempotent: ensure the role is correct without churning the password.
|
|
968
|
+
await callOpsApi(opsUrl, {
|
|
969
|
+
operation: "alter_user",
|
|
970
|
+
username: FLAIR_AGENT_USERNAME,
|
|
971
|
+
role: "flair_agent",
|
|
972
|
+
active: true,
|
|
973
|
+
}, adminUser, adminPass);
|
|
974
|
+
console.log(`User '${FLAIR_AGENT_USERNAME}' already exists — role ensured ✓`);
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
throw err;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
808
981
|
// ─── Upgrade presence probes ──────────────────────────────────────────────────
|
|
809
982
|
//
|
|
810
983
|
// `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
|
|
@@ -1126,6 +1299,16 @@ program
|
|
|
1126
1299
|
if (opts.remote) {
|
|
1127
1300
|
await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
1128
1301
|
}
|
|
1302
|
+
// Every flair instance has agents, so provision the least-privilege
|
|
1303
|
+
// flair_agent role (idempotent, harmless until a user is assigned to it).
|
|
1304
|
+
await ensureFlairAgentRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
1305
|
+
// THE FLIP (auth-rbac): provision the shared least-privilege flair-agent user.
|
|
1306
|
+
// This ACTIVATES the gate's per-agent de-elevation (verified non-admin agents
|
|
1307
|
+
// resolve to flair-agent instead of admin super_user). Safe now: #487 gave
|
|
1308
|
+
// every agent-facing resource its own allow* + resolveAgentAuth, so they no
|
|
1309
|
+
// longer rely on the admin super_user bypass. The gate also falls back to
|
|
1310
|
+
// admin if this user is ever absent, so de-elevation degrades gracefully.
|
|
1311
|
+
await ensureFlairAgentUser(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
1129
1312
|
}
|
|
1130
1313
|
else {
|
|
1131
1314
|
// ── Existing behavior: --admin-pass required for already-running Flair ──
|
package/dist/resources/Agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
|
|
2
3
|
/**
|
|
3
4
|
* Agent resource — serves as the Principal table in 1.0.
|
|
4
5
|
*
|
|
@@ -16,6 +17,14 @@ import { databases } from "@harperfast/harper";
|
|
|
16
17
|
* - subjects: soul-level subject interests
|
|
17
18
|
*/
|
|
18
19
|
export class Agent extends databases.flair.Agent {
|
|
20
|
+
// Self-authorize now that the global gate is non-rejecting. Verified agents read
|
|
21
|
+
// the principal table for discovery; an agent updates only its OWN record (put
|
|
22
|
+
// handler enforces ownership). Creating/deleting principals is admin-only
|
|
23
|
+
// (flair_agent grant: insert=false, delete=false). Anonymous denied throughout.
|
|
24
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
25
|
+
allowCreate() { return allowAdmin(this.getContext?.()); }
|
|
26
|
+
allowUpdate() { return allowVerified(this.getContext?.()); }
|
|
27
|
+
allowDelete() { return allowAdmin(this.getContext?.()); }
|
|
19
28
|
async post(content, context) {
|
|
20
29
|
const now = new Date().toISOString();
|
|
21
30
|
// Backward compat: set type for legacy code
|
|
@@ -34,14 +43,18 @@ export class Agent extends databases.flair.Agent {
|
|
|
34
43
|
return super.post(content, context);
|
|
35
44
|
}
|
|
36
45
|
async put(content) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
47
|
+
// Anonymous denied (defense-in-depth alongside allowUpdate; the old check read
|
|
48
|
+
// tpsAgent and treated a missing agent as trusted, so anonymous slipped through).
|
|
49
|
+
if (auth.kind === "anonymous") {
|
|
50
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
51
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// Only admin principals can modify OTHER principals; an agent updates its own.
|
|
55
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
43
56
|
const existing = await super.get();
|
|
44
|
-
if (existing && existing.id !==
|
|
57
|
+
if (existing && existing.id !== auth.agentId) {
|
|
45
58
|
return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
|
|
46
59
|
status: 403, headers: { "content-type": "application/json" },
|
|
47
60
|
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
|
|
3
3
|
export class AgentCard extends Resource {
|
|
4
|
+
// Public discovery metadata (per A2A spec, agent cards are intentionally
|
|
5
|
+
// public). Self-gates as public so it survives the global gate's removal — and
|
|
6
|
+
// its get() already returns only field-allowlisted public-safe data.
|
|
7
|
+
allowRead() {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
4
10
|
async get(pathInfo) {
|
|
5
11
|
const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
|
|
6
12
|
this.getId?.() ??
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Auth: admin only.
|
|
18
18
|
*/
|
|
19
19
|
import { Resource, databases } from "@harperfast/harper";
|
|
20
|
-
import { isAdmin } from "./auth
|
|
20
|
+
import { isAdmin, allowAdmin } from "./agent-auth.js";
|
|
21
21
|
const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
|
|
22
22
|
name: displayName,
|
|
23
23
|
role,
|
|
@@ -32,6 +32,12 @@ const DEFAULT_MEMORIES = (agentId, now) => [
|
|
|
32
32
|
},
|
|
33
33
|
];
|
|
34
34
|
export class AgentSeed extends Resource {
|
|
35
|
+
// Admin-only: permit verified ADMIN agents (Basic-admin is super_user and
|
|
36
|
+
// bypasses allow*); non-admin agents denied. Real authorization now that the
|
|
37
|
+
// gate no longer elevates agents to admin.
|
|
38
|
+
async allowCreate() {
|
|
39
|
+
return allowAdmin(this.getContext?.());
|
|
40
|
+
}
|
|
35
41
|
async post(data) {
|
|
36
42
|
const actorId = this.request?.tpsAgent;
|
|
37
43
|
if (!actorId || !(await isAdmin(actorId))) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
3
4
|
/**
|
|
4
5
|
* Credential resource — authentication surfaces for Principals.
|
|
@@ -13,15 +14,21 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
13
14
|
*/
|
|
14
15
|
export class Credential extends databases.flair.Credential {
|
|
15
16
|
async search(query) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (
|
|
17
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
18
|
+
// Anonymous HTTP must NOT read credentials. (Previously `!authAgent` was
|
|
19
|
+
// treated as trusted/unfiltered — which leaked every credential to an
|
|
20
|
+
// unauthenticated caller once the gate stopped rejecting.)
|
|
21
|
+
if (auth.kind === "anonymous") {
|
|
22
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
23
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// Trusted internal call or admin agent → unfiltered.
|
|
27
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
21
28
|
return super.search(query);
|
|
22
29
|
}
|
|
23
|
-
// Non-admin: scope to own credentials
|
|
24
|
-
const condition = { attribute: "principalId", comparator: "equals", value:
|
|
30
|
+
// Non-admin agent: scope to own credentials.
|
|
31
|
+
const condition = { attribute: "principalId", comparator: "equals", value: auth.agentId };
|
|
25
32
|
if (!query?.conditions) {
|
|
26
33
|
return super.search({ conditions: [condition], ...(query || {}) });
|
|
27
34
|
}
|
|
@@ -35,12 +42,16 @@ export class Credential extends databases.flair.Credential {
|
|
|
35
42
|
const result = await super.get();
|
|
36
43
|
if (!result)
|
|
37
44
|
return result;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
46
|
+
// Anonymous HTTP must NOT read a credential (previously it fell through the
|
|
47
|
+
// ownership check and returned the record sans tokenHash — a metadata leak).
|
|
48
|
+
if (auth.kind === "anonymous") {
|
|
49
|
+
return new Response(JSON.stringify({ error: "forbidden" }), {
|
|
50
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Non-admin agent can only see their own credentials. (Internal + admin pass.)
|
|
54
|
+
if (auth.kind === "agent" && !auth.isAdmin && result.principalId !== auth.agentId) {
|
|
44
55
|
return new Response(JSON.stringify({ error: "forbidden" }), {
|
|
45
56
|
status: 403, headers: { "content-type": "application/json" },
|
|
46
57
|
});
|
|
@@ -55,6 +55,13 @@ function verifyEd25519Signature(publicKeyHex, authHeader, officeId) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
export class IngestEvents extends Resource {
|
|
58
|
+
// Public POST by design: the handler self-verifies the OFFICE's Ed25519
|
|
59
|
+
// signature against ObsOffice.publicKey (not an agent identity), so it can't use
|
|
60
|
+
// agent auth. allowCreate=true lets the request reach post() where that
|
|
61
|
+
// verification happens — same self-gating pattern as FederationPair/FederationSync.
|
|
62
|
+
allowCreate() {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
58
65
|
async post(body, context) {
|
|
59
66
|
const request = this.request;
|
|
60
67
|
const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { allowVerified, allowAdmin } from "./agent-auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Instance is read-only reference data for agents (flair_agent grant: read only).
|
|
5
|
+
* Verified agents + admin may read; writes are admin/internal only. Self-authorizes
|
|
6
|
+
* now that the global gate is non-rejecting (anonymous denied).
|
|
7
|
+
*/
|
|
8
|
+
export class Instance extends databases.flair.Instance {
|
|
9
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
10
|
+
allowCreate() { return allowAdmin(this.getContext?.()); }
|
|
11
|
+
allowUpdate() { return allowAdmin(this.getContext?.()); }
|
|
12
|
+
allowDelete() { return allowAdmin(this.getContext?.()); }
|
|
13
|
+
}
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
/**
|
|
6
|
+
* Integration records are agent-owned. Auth: the non-rejecting gate annotates the
|
|
7
|
+
* request; this resource self-enforces (resolveAgentAuth → internal/agent/anonymous).
|
|
8
|
+
* Anonymous HTTP is denied on every path; non-admin agents are scoped to their own
|
|
9
|
+
* agentId. Mirrors the WorkspaceState pattern.
|
|
10
|
+
*/
|
|
2
11
|
export class Integration extends databases.flair.Integration {
|
|
12
|
+
_auth() {
|
|
13
|
+
return resolveAgentAuth(this.getContext?.());
|
|
14
|
+
}
|
|
15
|
+
async search(query) {
|
|
16
|
+
const auth = await this._auth();
|
|
17
|
+
if (auth.kind === "anonymous")
|
|
18
|
+
return UNAUTH();
|
|
19
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
20
|
+
return super.search(query);
|
|
21
|
+
}
|
|
22
|
+
const agentIdCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
|
|
23
|
+
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
24
|
+
const existing = query.conditions ?? [];
|
|
25
|
+
query.conditions = Array.isArray(existing) ? [agentIdCondition, ...existing] : [agentIdCondition, existing];
|
|
26
|
+
return super.search(query);
|
|
27
|
+
}
|
|
28
|
+
const conditions = Array.isArray(query) && query.length > 0 ? [agentIdCondition, ...query] : [agentIdCondition];
|
|
29
|
+
return super.search(conditions);
|
|
30
|
+
}
|
|
3
31
|
async post(content, context) {
|
|
32
|
+
const auth = await this._auth();
|
|
33
|
+
if (auth.kind === "anonymous")
|
|
34
|
+
return UNAUTH();
|
|
35
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
36
|
+
return FORBIDDEN("forbidden: cannot write integration for another agent");
|
|
37
|
+
}
|
|
4
38
|
// S31-A: API never accepts plaintext credentials.
|
|
5
39
|
if (typeof content?.credential === "string" || typeof content?.token === "string") {
|
|
6
40
|
return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
|
|
@@ -10,4 +44,34 @@ export class Integration extends databases.flair.Integration {
|
|
|
10
44
|
}
|
|
11
45
|
return super.post(content, context);
|
|
12
46
|
}
|
|
47
|
+
async put(content, context) {
|
|
48
|
+
const auth = await this._auth();
|
|
49
|
+
if (auth.kind === "anonymous")
|
|
50
|
+
return UNAUTH();
|
|
51
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
52
|
+
return FORBIDDEN("forbidden: cannot write integration for another agent");
|
|
53
|
+
}
|
|
54
|
+
if (typeof content?.credential === "string" || typeof content?.token === "string") {
|
|
55
|
+
return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
|
|
56
|
+
status: 400,
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return super.put(content, context);
|
|
61
|
+
}
|
|
62
|
+
async delete(id) {
|
|
63
|
+
const auth = await this._auth();
|
|
64
|
+
if (auth.kind === "anonymous")
|
|
65
|
+
return UNAUTH();
|
|
66
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
67
|
+
return super.delete(id);
|
|
68
|
+
}
|
|
69
|
+
const record = await this.get(id);
|
|
70
|
+
if (!record)
|
|
71
|
+
return super.delete(id);
|
|
72
|
+
if (record.agentId !== auth.agentId) {
|
|
73
|
+
return FORBIDDEN("forbidden: cannot delete integration for another agent");
|
|
74
|
+
}
|
|
75
|
+
return super.delete(id);
|
|
76
|
+
}
|
|
13
77
|
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
|
-
import { isAdmin } from "./auth
|
|
3
|
+
import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
5
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
@@ -16,16 +16,22 @@ export class Memory extends databases.flair.Memory {
|
|
|
16
16
|
* Non-admin calls also check MemoryGrant to include granted memories.
|
|
17
17
|
*/
|
|
18
18
|
async search(query) {
|
|
19
|
-
// Access request context via Harper's Resource instance context
|
|
19
|
+
// Access request context via Harper's Resource instance context.
|
|
20
20
|
const ctx = this.getContext?.();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
const auth = await resolveAgentAuth(ctx);
|
|
22
|
+
// Anonymous HTTP must NOT read memories. (Previously `!authAgent` was treated
|
|
23
|
+
// as unfiltered — the anonymous-read leak once the gate stops rejecting.)
|
|
24
|
+
if (auth.kind === "anonymous") {
|
|
25
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
26
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// Trusted internal call (no request context) or admin agent — unfiltered.
|
|
30
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
26
31
|
return super.search(query);
|
|
27
32
|
}
|
|
28
|
-
//
|
|
33
|
+
// Non-admin agent: scope to own + granted owners.
|
|
34
|
+
const authAgent = auth.agentId;
|
|
29
35
|
const allowedOwners = [authAgent];
|
|
30
36
|
try {
|
|
31
37
|
for await (const grant of databases.flair.MemoryGrant.search({
|
|
@@ -68,6 +74,26 @@ export class Memory extends databases.flair.Memory {
|
|
|
68
74
|
if (!rl.allowed)
|
|
69
75
|
return rateLimitResponse(rl.retryAfterMs, "write");
|
|
70
76
|
}
|
|
77
|
+
// Create ownership: a non-admin agent may only write memories it owns. Use
|
|
78
|
+
// resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
|
|
79
|
+
// .username, which is the fallback "admin" super_user while de-elevation is
|
|
80
|
+
// dormant and would wrongly 403 every agent's own write. internal/admin → pass.
|
|
81
|
+
{
|
|
82
|
+
const auth = await resolveAgentAuth(ctx);
|
|
83
|
+
// Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
|
|
84
|
+
// upstream; with the non-rejecting gate, each write path self-enforces (same
|
|
85
|
+
// rule search() applies to reads).
|
|
86
|
+
if (auth.kind === "anonymous") {
|
|
87
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
88
|
+
status: 401, headers: { "Content-Type": "application/json" },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
92
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
|
|
93
|
+
status: 403, headers: { "Content-Type": "application/json" },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
71
97
|
content.durability ||= "standard";
|
|
72
98
|
content.createdAt = new Date().toISOString();
|
|
73
99
|
content.updatedAt = content.createdAt;
|
|
@@ -151,6 +177,25 @@ export class Memory extends databases.flair.Memory {
|
|
|
151
177
|
delete content._reindex;
|
|
152
178
|
return super.put(content);
|
|
153
179
|
}
|
|
180
|
+
// Create/update ownership (same rule as post): a non-admin agent may only
|
|
181
|
+
// write memories it owns, via resolveAgentAuth (gate annotation), not
|
|
182
|
+
// context.user.username (the dormant-de-elevation fallback is "admin").
|
|
183
|
+
// The _reindex admin path above bypasses this.
|
|
184
|
+
{
|
|
185
|
+
const octx = this.getContext?.();
|
|
186
|
+
const auth = await resolveAgentAuth(octx);
|
|
187
|
+
// Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
|
|
188
|
+
if (auth.kind === "anonymous") {
|
|
189
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
190
|
+
status: 401, headers: { "Content-Type": "application/json" },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
194
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
|
|
195
|
+
status: 403, headers: { "Content-Type": "application/json" },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
154
199
|
const now = new Date().toISOString();
|
|
155
200
|
content.updatedAt = now;
|
|
156
201
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { allowVerified } from "./agent-auth.js";
|
|
2
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
3
4
|
import { wrapUntrusted } from "./content-safety.js";
|
|
4
5
|
/**
|
|
@@ -40,6 +41,13 @@ function formatMemory(m, supersedes) {
|
|
|
40
41
|
return base;
|
|
41
42
|
}
|
|
42
43
|
export class BootstrapMemories extends Resource {
|
|
44
|
+
// Self-authorize via the Ed25519 agent verify (the auth reshape removes the
|
|
45
|
+
// gate's admin super_user elevation, so custom resources must self-gate or
|
|
46
|
+
// Harper denies them for the least-privilege flair_agent role). Any verified
|
|
47
|
+
// agent may bootstrap; per-agent scoping is enforced in post() below.
|
|
48
|
+
async allowCreate() {
|
|
49
|
+
return allowVerified(this.getContext?.());
|
|
50
|
+
}
|
|
43
51
|
async post(data, _context) {
|
|
44
52
|
const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
45
53
|
surface, // e.g., "tps-build", "tps-review", "cli-session"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* prompt string
|
|
17
17
|
*/
|
|
18
18
|
import { Resource, databases } from "@harperfast/harper";
|
|
19
|
-
import { isAdmin } from "./auth
|
|
19
|
+
import { isAdmin, allowVerified } from "./agent-auth.js";
|
|
20
20
|
function parseDuration(s) {
|
|
21
21
|
const m = s.match(/^(\d+)([dhm])$/);
|
|
22
22
|
if (!m)
|
|
@@ -56,6 +56,12 @@ function evaluate(record, now, olderThanMs) {
|
|
|
56
56
|
return { memory, suggestion: "keep", reason: `Retrieved ${count} times, ${Math.round(daysSinceRetrieved)} days since last retrieval` };
|
|
57
57
|
}
|
|
58
58
|
export class ConsolidateMemories extends Resource {
|
|
59
|
+
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
60
|
+
// admin elevation). Any verified agent may consolidate; the isAdmin checks in
|
|
61
|
+
// post() handle finer-grained authorization.
|
|
62
|
+
async allowCreate() {
|
|
63
|
+
return allowVerified(this.getContext?.());
|
|
64
|
+
}
|
|
59
65
|
async post(data) {
|
|
60
66
|
const { agentId: bodyAgentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
|
|
61
67
|
// See SemanticSearch / MemoryBootstrap — `this.request` isn't populated on
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { allowVerified } from "./agent-auth.js";
|
|
2
3
|
import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
|
|
3
4
|
export class FeedMemories extends Resource {
|
|
5
|
+
// Self-authorize via the Ed25519 agent verify (the auth reshape removes the
|
|
6
|
+
// gate's admin elevation). NOTE: post() trusts content.agentId from the body —
|
|
7
|
+
// closing that create-spoofing gap is tracked with the table-resource
|
|
8
|
+
// create-ownership work (Memory.allowCreate), not in this auth-coverage pass.
|
|
9
|
+
async allowCreate() {
|
|
10
|
+
return allowVerified(this.getContext?.());
|
|
11
|
+
}
|
|
4
12
|
async post(content) {
|
|
5
13
|
const agentId = String(content?.agentId ?? "");
|
|
6
14
|
const body = String(content?.content ?? "");
|