@ouro.bot/friends 0.1.0-alpha.4 → 0.1.0-alpha.6

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 (70) hide show
  1. package/README.md +79 -8
  2. package/changelog.json +12 -0
  3. package/dist/a2a-client/a2a-message.d.ts +39 -0
  4. package/dist/a2a-client/a2a-message.js +54 -0
  5. package/dist/a2a-client/adapter.d.ts +97 -0
  6. package/dist/a2a-client/adapter.js +114 -0
  7. package/dist/a2a-client/agent-card.d.ts +50 -0
  8. package/dist/a2a-client/agent-card.js +32 -0
  9. package/dist/a2a-client/did-key.d.ts +38 -0
  10. package/dist/a2a-client/did-key.js +120 -0
  11. package/dist/a2a-client/did-verifier.d.ts +109 -0
  12. package/dist/a2a-client/did-verifier.js +163 -0
  13. package/dist/a2a-client/did-web.d.ts +26 -0
  14. package/dist/a2a-client/did-web.js +140 -0
  15. package/dist/a2a-client/index.d.ts +24 -0
  16. package/dist/a2a-client/index.js +76 -0
  17. package/dist/a2a-client/jcs.d.ts +5 -0
  18. package/dist/a2a-client/jcs.js +84 -0
  19. package/dist/a2a-client/reachability.d.ts +22 -0
  20. package/dist/a2a-client/reachability.js +17 -0
  21. package/dist/a2a-client/roster-verify.d.ts +15 -0
  22. package/dist/a2a-client/roster-verify.js +61 -0
  23. package/dist/a2a-client/seal.d.ts +47 -0
  24. package/dist/a2a-client/seal.js +95 -0
  25. package/dist/a2a-client/sealed-envelope.d.ts +55 -0
  26. package/dist/a2a-client/sealed-envelope.js +94 -0
  27. package/dist/a2a-client/sign.d.ts +42 -0
  28. package/dist/a2a-client/sign.js +87 -0
  29. package/dist/a2a-client/sodium.d.ts +5 -0
  30. package/dist/a2a-client/sodium.js +19 -0
  31. package/dist/account-roster.d.ts +52 -0
  32. package/dist/account-roster.js +108 -0
  33. package/dist/agent-peer.js +10 -2
  34. package/dist/audit.d.ts +38 -0
  35. package/dist/audit.js +86 -0
  36. package/dist/file-bundle.d.ts +5 -0
  37. package/dist/file-bundle.js +4 -0
  38. package/dist/friend-lookup.d.ts +9 -0
  39. package/dist/friend-lookup.js +69 -0
  40. package/dist/identity.d.ts +17 -0
  41. package/dist/identity.js +68 -0
  42. package/dist/index.d.ts +13 -0
  43. package/dist/index.js +31 -2
  44. package/dist/{a2a → mailbox}/index.js +10 -3
  45. package/dist/mcp/bin.js +0 -0
  46. package/dist/mcp/dispatch.d.ts +12 -1
  47. package/dist/mcp/dispatch.js +45 -3
  48. package/dist/mcp/run-main.js +8 -5
  49. package/dist/mcp/schemas.js +1 -1
  50. package/dist/mcp/server.d.ts +9 -0
  51. package/dist/mcp/server.js +2 -2
  52. package/dist/resolver.d.ts +32 -1
  53. package/dist/resolver.js +50 -3
  54. package/dist/roster-store-file.d.ts +16 -0
  55. package/dist/roster-store-file.js +125 -0
  56. package/dist/roster-store-memory.d.ts +9 -0
  57. package/dist/roster-store-memory.js +20 -0
  58. package/dist/roster-store.d.ts +29 -0
  59. package/dist/roster-store.js +9 -0
  60. package/dist/roster-verifier.d.ts +23 -0
  61. package/dist/roster-verifier.js +47 -0
  62. package/dist/store-file.d.ts +6 -2
  63. package/dist/store-file.js +28 -5
  64. package/dist/trust-explanation.d.ts +7 -1
  65. package/dist/trust-explanation.js +52 -34
  66. package/dist/trust-mutation.d.ts +13 -1
  67. package/dist/trust-mutation.js +31 -2
  68. package/dist/types.d.ts +33 -7
  69. package/package.json +15 -6
  70. /package/dist/{a2a → mailbox}/index.d.ts +0 -0
@@ -1,14 +1,25 @@
1
1
  import type { FriendStore } from "../store";
2
2
  import type { GrantStore } from "../grant-store";
3
3
  import type { MissionStore } from "../mission-store";
4
+ import type { AuditSink } from "../audit";
4
5
  type Args = Record<string, unknown>;
5
6
  export interface DispatchResult {
6
7
  result: unknown;
7
8
  isError: boolean;
8
9
  }
10
+ /** WHO/WHENCE context stamped onto a control-plane audit record (finding 3). The MCP
11
+ * server passes the local owner/sense it was constructed with. */
12
+ export interface ControlPlaneContext {
13
+ actor?: string;
14
+ originSense?: string;
15
+ }
16
+ /** Whether wiring an AuditSink should also stamp a record for an `onboard_agent` trust
17
+ * seat: only when the owner explicitly set a trustLevel (a deliberate trust decision).
18
+ * A cold contact with no trustLevel lands at the safe `stranger` default (Bug A) and is
19
+ * NOT an owner trust mutation, so it is not audited. */
9
20
  export declare function coerceBool(v: unknown): boolean;
10
21
  export declare function coerceInt(v: unknown): number | undefined;
11
22
  export declare function coerceString(v: unknown): string;
12
23
  export declare function coerceOptionalString(v: unknown): string | undefined;
13
- export declare function dispatchTool(store: FriendStore, name: string, args: Args, grants?: GrantStore, missions?: MissionStore): Promise<DispatchResult>;
24
+ export declare function dispatchTool(store: FriendStore, name: string, args: Args, grants?: GrantStore, missions?: MissionStore, audit?: AuditSink, controlContext?: ControlPlaneContext): Promise<DispatchResult>;
14
25
  export {};
@@ -11,6 +11,7 @@ exports.dispatchTool = dispatchTool;
11
11
  // the library fns. `dispatchTool` is a flat tool → library-fn map (D9/D10) with
12
12
  // NO domain logic of its own — every behavior lives in the friends library.
13
13
  const observability_1 = require("../observability");
14
+ const identity_1 = require("../identity");
14
15
  const types_1 = require("../types");
15
16
  const resolver_1 = require("../resolver");
16
17
  const trust_explanation_1 = require("../trust-explanation");
@@ -31,6 +32,17 @@ const missions_1 = require("../missions");
31
32
  const mission_share_1 = require("../mission-share");
32
33
  const coordination_1 = require("../coordination");
33
34
  const types_2 = require("../types");
35
+ /** SECURITY (finding 3-A): the friends MCP server speaks JSON-RPC over **stdio**, and
36
+ * stdio is an owner-only channel — the local user who launched the process is the only
37
+ * actor. So when no explicit controlContext is wired, audited mutations are attributed
38
+ * to the stdio owner boundary rather than the generic "unknown". A network/multi-tenant
39
+ * transport MUST pass its own authenticated actor instead of relying on these. */
40
+ const STDIO_OWNER_ACTOR = "owner:stdio";
41
+ const STDIO_ORIGIN_SENSE = "stdio";
42
+ /** Whether wiring an AuditSink should also stamp a record for an `onboard_agent` trust
43
+ * seat: only when the owner explicitly set a trustLevel (a deliberate trust decision).
44
+ * A cold contact with no trustLevel lands at the safe `stranger` default (Bug A) and is
45
+ * NOT an owner trust mutation, so it is not audited. */
34
46
  function coerceBool(v) {
35
47
  return v === true || v === "true";
36
48
  }
@@ -69,13 +81,18 @@ const NO_GRANT_STORE = { ok: false, status: "unsupported", message: "no grant st
69
81
  * embedding). The mission ledger needs mission persistence, so report it cleanly
70
82
  * rather than guessing. */
71
83
  const NO_MISSION_STORE = { ok: false, status: "unsupported", message: "no mission store configured (mission tools require one)" };
72
- async function dispatchTool(store, name, args, grants, missions) {
84
+ async function dispatchTool(store, name, args, grants, missions, audit, controlContext) {
73
85
  (0, observability_1.emitNervesEvent)({
74
86
  component: "clients",
75
87
  event: "clients.mcp_dispatch",
76
88
  message: "dispatching friends mcp tool",
77
89
  meta: { tool: name },
78
90
  });
91
+ // SECURITY (finding 3 / 3-A): resolve the WHO/WHENCE for an audited mutation. With
92
+ // no explicit context, attribute to the stdio owner boundary (the only actor on an
93
+ // owner-only stdio channel) rather than the generic "unknown".
94
+ const auditActor = controlContext?.actor ?? STDIO_OWNER_ACTOR;
95
+ const auditOriginSense = controlContext?.originSense ?? STDIO_ORIGIN_SENSE;
79
96
  switch (name) {
80
97
  case "resolve_party": {
81
98
  const provider = coerceString(args.provider);
@@ -188,7 +205,14 @@ async function dispatchTool(store, name, args, grants, missions) {
188
205
  return { result: results, isError: false };
189
206
  }
190
207
  case "set_trust": {
191
- const result = await (0, trust_mutation_1.setFriendTrust)(store, coerceString(args.friendId), coerceString(args.trustLevel));
208
+ // SECURITY (finding 3): thread the audit sink + owner/sense context so the LIVE
209
+ // trust mutation actually writes a control-plane record. With no sink wired,
210
+ // setFriendTrust treats the ctx as a no-op (back-compat).
211
+ const result = await (0, trust_mutation_1.setFriendTrust)(store, coerceString(args.friendId), coerceString(args.trustLevel), {
212
+ ...(audit ? { sink: audit } : {}),
213
+ actor: auditActor,
214
+ originSense: auditOriginSense,
215
+ });
192
216
  return { result, isError: result.ok === false };
193
217
  }
194
218
  case "link_identity": {
@@ -207,14 +231,32 @@ async function dispatchTool(store, name, args, grants, missions) {
207
231
  return { result, isError: result.ok === false };
208
232
  }
209
233
  case "onboard_agent": {
234
+ const explicitTrustLevel = coerceOptionalString(args.trustLevel);
210
235
  const record = await (0, agent_peer_1.upsertAgentPeer)(store, {
211
236
  name: coerceString(args.name),
212
237
  agentId: coerceString(args.agentId),
213
- trustLevel: coerceOptionalString(args.trustLevel),
238
+ trustLevel: explicitTrustLevel,
214
239
  a2a: parseMaybeJson(args.a2a),
215
240
  mailbox: parseMaybeJson(args.mailbox),
216
241
  bundleName: coerceOptionalString(args.bundleName),
217
242
  });
243
+ // SECURITY (finding 3): an owner-initiated trust SEAT (an explicit trustLevel) is
244
+ // a control-plane trust mutation, so audit it through the wired sink. A cold
245
+ // contact with no trustLevel falls to the safe `stranger` default (Bug A) — not
246
+ // an owner trust decision — so it is left unaudited.
247
+ if (audit && explicitTrustLevel !== undefined) {
248
+ const targetDid = (0, identity_1.resolveAgentIdentity)(record.agentMeta).did;
249
+ const auditRecord = {
250
+ action: "set_trust",
251
+ targetId: record.id,
252
+ ...(targetDid !== undefined ? { targetDid } : {}),
253
+ level: explicitTrustLevel,
254
+ actor: auditActor,
255
+ originSense: auditOriginSense,
256
+ ts: record.updatedAt,
257
+ };
258
+ await audit.append(auditRecord);
259
+ }
218
260
  return { result: record, isError: false };
219
261
  }
220
262
  case "whoami": {
@@ -35,11 +35,14 @@ function runMain(argv, env, io) {
35
35
  message: "friends mcp run-main",
36
36
  meta: { source },
37
37
  });
38
- // The consent-grant + mission collections are sibling `_grants/` + `_missions/`
39
- // dirs under the friends dir, so the single `--dir` wires the whole substrate
40
- // (friends + consent + missions). `openFileBundle` encapsulates that convention.
41
- const { store, grants, missions } = (0, file_bundle_1.openFileBundle)(dir);
42
- const server = (0, server_1.createFriendsMcpServer)({ store, grants, missions, stdin: io.stdin, stdout: io.stdout });
38
+ // The consent-grant + mission + audit collections are sibling `_grants/`,
39
+ // `_missions/`, `_audit/` dirs under the friends dir, so the single `--dir` wires the
40
+ // whole substrate. `openFileBundle` encapsulates that convention.
41
+ const { store, grants, missions, audit } = (0, file_bundle_1.openFileBundle)(dir);
42
+ // SECURITY (finding 3 / 3-A): thread the FileAuditSink into the live server so trust
43
+ // mutations are audited. The `friends-mcp` bin speaks owner-only stdio, so the
44
+ // default actor/originSense (the stdio owner boundary) is the correct attribution.
45
+ const server = (0, server_1.createFriendsMcpServer)({ store, grants, missions, audit, stdin: io.stdin, stdout: io.stdout });
43
46
  server.start();
44
47
  return server;
45
48
  }
@@ -187,7 +187,7 @@ function getToolSchemas() {
187
187
  properties: {
188
188
  name: { type: "string", description: "the peer agent's name" },
189
189
  agentId: { type: "string", description: "the a2a agent id" },
190
- trustLevel: { type: "string", description: "trust level (default acquaintance)" },
190
+ trustLevel: { type: "string", description: "trust level (default stranger (cold contact))" },
191
191
  a2a: { type: "object", description: "a2a coordinates { cardUrl?, endpointUrl?, protocolVersion? }" },
192
192
  mailbox: { type: "object", description: "optional A2A git-mailbox coords { repo, selfOutboxAgentId }" },
193
193
  bundleName: { type: "string", description: "optional bundle name" },
@@ -1,6 +1,8 @@
1
1
  import type { FriendStore } from "../store";
2
2
  import type { GrantStore } from "../grant-store";
3
3
  import type { MissionStore } from "../mission-store";
4
+ import type { AuditSink } from "../audit";
5
+ import type { ControlPlaneContext } from "./dispatch";
4
6
  export interface FriendsMcpServerOptions {
5
7
  store: FriendStore;
6
8
  /** Optional consent-grant store. When omitted, the consent/share tools
@@ -11,6 +13,13 @@ export interface FriendsMcpServerOptions {
11
13
  * (record_mission / get_mission / list_missions / share_mission /
12
14
  * import_mission) report `unsupported`; everything else works without it. */
13
15
  missions?: MissionStore;
16
+ /** Optional control-plane audit sink (Bug B, finding 3). When wired, the LIVE
17
+ * trust mutations (`set_trust`, and an explicit-trust-seat `onboard_agent`) append
18
+ * an append-only audit record. When omitted, those mutations are unaudited. */
19
+ audit?: AuditSink;
20
+ /** Optional WHO/WHENCE context for audited mutations. Defaults to the stdio
21
+ * owner-only boundary (finding 3-A) when omitted. */
22
+ controlContext?: ControlPlaneContext;
14
23
  stdin: NodeJS.ReadableStream;
15
24
  stdout: NodeJS.WritableStream;
16
25
  }
@@ -13,7 +13,7 @@ const observability_1 = require("../observability");
13
13
  const schemas_1 = require("./schemas");
14
14
  const dispatch_1 = require("./dispatch");
15
15
  function createFriendsMcpServer(options) {
16
- const { store, grants, missions, stdin, stdout } = options;
16
+ const { store, grants, missions, audit, controlContext, stdin, stdout } = options;
17
17
  let buffer = "";
18
18
  let running = false;
19
19
  let useContentLengthFraming = true;
@@ -145,7 +145,7 @@ function createFriendsMcpServer(options) {
145
145
  const toolName = params.name ?? "";
146
146
  const toolArgs = params.arguments ?? {};
147
147
  try {
148
- const { result, isError } = await (0, dispatch_1.dispatchTool)(store, toolName, toolArgs, grants, missions);
148
+ const { result, isError } = await (0, dispatch_1.dispatchTool)(store, toolName, toolArgs, grants, missions, audit, controlContext);
149
149
  writeResponse({
150
150
  jsonrpc: "2.0",
151
151
  id: request.id,
@@ -1,5 +1,29 @@
1
1
  import type { FriendStore } from "./store";
2
2
  import type { IdentityProvider, ResolvedContext } from "./types";
3
+ import type { RosterStore } from "./roster-store";
4
+ import type { RosterVerifier } from "./roster-verifier";
5
+ /** Optional roster context for a cold-contact resolution (Bug C). When supplied AND
6
+ * the candidate's `did` is a key-verified member of the pinned account roster, the
7
+ * resolver seats `family` (attributable to `same_account`) even when the peer is on
8
+ * a different OS user. Constructor-injected (not a `resolve()` arg) so existing
9
+ * `new FriendResolver(store, params)` call sites stay source-compatible. The
10
+ * resolver stays core-clean: the Ed25519 `verifier` arrives via the seam — never an
11
+ * a2a-client import. */
12
+ export interface FriendResolverRosterContext {
13
+ store: RosterStore;
14
+ accountId: string;
15
+ /** The peer's did. PRECONDITION (finding 2): the host has already authenticated
16
+ * that the peer controls this did (the a2a-client sealed-envelope gate runs
17
+ * `DidVerifier` before resolution). The resolver wraps it in a `VerifiedCandidate`
18
+ * on the caller's behalf — so only seed this from a verified inbound identity, never
19
+ * an unauthenticated, peer-claimed string. */
20
+ candidateDid: string;
21
+ /** PRECONDITION (finding 1): to actually seat family, this MUST be a cryptographic
22
+ * verifier (`ed25519RosterVerifier`, `grantsFamily: true`). With it absent (the
23
+ * identity-only default), `evaluateAccountMembership` fails closed and the resolver
24
+ * keeps the cold-A2A `stranger` default — never family. */
25
+ verifier?: RosterVerifier;
26
+ }
3
27
  export interface FriendResolverParams {
4
28
  provider: IdentityProvider;
5
29
  externalId: string;
@@ -22,7 +46,14 @@ export declare function isLocalMachineOwnerIdentity(provider: string, externalId
22
46
  export declare class FriendResolver {
23
47
  private readonly store;
24
48
  private readonly params;
25
- constructor(store: FriendStore, params: FriendResolverParams);
49
+ private readonly roster?;
50
+ constructor(store: FriendStore, params: FriendResolverParams, roster?: FriendResolverRosterContext);
26
51
  resolve(): Promise<ResolvedContext>;
27
52
  private resolveOrCreate;
53
+ /** Bug C — whether the candidate is family via a key-verified account roster.
54
+ * Reads the pinned roster + roster key from the injected RosterStore and reuses
55
+ * `evaluateAccountMembership`. Returns false (no family) when no roster context is
56
+ * wired, the roster/pin is absent, or membership is anything but
57
+ * `family_same_account` (unverified / not-member / key-mismatch all stay false). */
58
+ private evaluateRosterFamily;
28
59
  }
package/dist/resolver.js CHANGED
@@ -11,6 +11,7 @@ const crypto_1 = require("crypto");
11
11
  const os_1 = require("os");
12
12
  const channel_1 = require("./channel");
13
13
  const observability_1 = require("./observability");
14
+ const account_roster_1 = require("./account-roster");
14
15
  const CURRENT_SCHEMA_VERSION = 1;
15
16
  // Test seam: when set (including to null), overrides OS detection of the
16
17
  // machine-owner username so resolver tests are deterministic.
@@ -46,9 +47,11 @@ function isLocalMachineOwnerIdentity(provider, externalId, ownerUsername) {
46
47
  class FriendResolver {
47
48
  store;
48
49
  params;
49
- constructor(store, params) {
50
+ roster;
51
+ constructor(store, params, roster) {
50
52
  this.store = store;
51
53
  this.params = params;
54
+ this.roster = roster;
52
55
  }
53
56
  async resolve() {
54
57
  const friend = await this.resolveOrCreate();
@@ -123,6 +126,13 @@ class FriendResolver {
123
126
  }
124
127
  const isFirstImprint = !hasAnyFriends;
125
128
  const isA2AAgent = this.params.provider === "a2a-agent";
129
+ // Bug C — roster-awareness. When a roster context is injected AND the candidate's
130
+ // did is a key-verified member of the pinned account roster, seat `family` (even
131
+ // on a different OS user). Reuses `evaluateAccountMembership` against the pinned
132
+ // roster fetched from the injected RosterStore. Absent/unverifiable roster ⇒ the
133
+ // OS-owner + cold-A2A default below is unchanged. The resolver never imports
134
+ // a2a-client — the Ed25519 verifier arrives via the injected seam.
135
+ const isRosterFamily = await this.evaluateRosterFamily();
126
136
  // The local friend that names the OS user running the daemon is the machine
127
137
  // owner (family) — they own the agent + its bundle. Usually this friend already
128
138
  // exists as a family/primary hatch imprint; this covers the un-imprinted boss
@@ -146,8 +156,11 @@ class FriendResolver {
146
156
  const friend = {
147
157
  id: (0, crypto_1.randomUUID)(),
148
158
  name: this.params.displayName,
149
- role: isA2AAgent ? "agent-peer" : isFirstImprint ? "primary" : isLocalMachineOwner ? "family" : "stranger",
150
- trustLevel: isA2AAgent ? "stranger" : (isFirstImprint || isLocalMachineOwner) ? "family" : "stranger",
159
+ // Bug C: a key-verified roster member is family (role + trustLevel), overriding
160
+ // the cold-A2A `agent-peer`/`stranger` default. Otherwise the matrix is
161
+ // unchanged: a2a ⇒ agent-peer/stranger; first imprint or machine owner ⇒ family.
162
+ role: isRosterFamily ? "family" : isA2AAgent ? "agent-peer" : isFirstImprint ? "primary" : isLocalMachineOwner ? "family" : "stranger",
163
+ trustLevel: isRosterFamily ? "family" : isA2AAgent ? "stranger" : (isFirstImprint || isLocalMachineOwner) ? "family" : "stranger",
151
164
  connections: [],
152
165
  externalIds: [externalId],
153
166
  tenantMemberships,
@@ -183,5 +196,39 @@ class FriendResolver {
183
196
  }
184
197
  return friend;
185
198
  }
199
+ /** Bug C — whether the candidate is family via a key-verified account roster.
200
+ * Reads the pinned roster + roster key from the injected RosterStore and reuses
201
+ * `evaluateAccountMembership`. Returns false (no family) when no roster context is
202
+ * wired, the roster/pin is absent, or membership is anything but
203
+ * `family_same_account` (unverified / not-member / key-mismatch all stay false). */
204
+ async evaluateRosterFamily() {
205
+ const ctx = this.roster;
206
+ if (!ctx)
207
+ return false;
208
+ const roster = await ctx.store.getRoster(ctx.accountId);
209
+ const pin = await ctx.store.getPin(ctx.accountId);
210
+ // The resolver grants family only against an ALREADY-pinned roster key (the pin
211
+ // is established during explicit onboarding, never silently at resolve time).
212
+ if (!roster || !pin)
213
+ return false;
214
+ const result = await (0, account_roster_1.evaluateAccountMembership)({
215
+ roster,
216
+ // The host authenticated the peer's control of this did upstream (see the
217
+ // FriendResolverRosterContext.candidateDid precondition); wrap it as verified.
218
+ candidate: (0, account_roster_1.verifiedCandidate)(ctx.candidateDid),
219
+ rosterKey: pin.rosterKey,
220
+ store: ctx.store,
221
+ ...(ctx.verifier !== undefined ? { verifier: ctx.verifier } : {}),
222
+ });
223
+ if (result.decision !== "family_same_account")
224
+ return false;
225
+ (0, observability_1.emitNervesEvent)({
226
+ component: "friends",
227
+ event: "friends.family_via_roster",
228
+ message: "seated family via the account roster (same_account)",
229
+ meta: { accountId: ctx.accountId, candidateDid: ctx.candidateDid },
230
+ });
231
+ return true;
232
+ }
186
233
  }
187
234
  exports.FriendResolver = FriendResolver;
@@ -0,0 +1,16 @@
1
+ import type { AccountRoster, RosterPin, RosterStore } from "./roster-store";
2
+ /** The sibling rosters directory for a given friends directory:
3
+ * `<friendsDir>/_rosters`. A reserved `_`-prefixed subdir (like `_grants`) so one
4
+ * `--dir` still points the whole substrate at one place. */
5
+ export declare function rostersDirFor(friendsDir: string): string;
6
+ export declare class FileRosterStore implements RosterStore {
7
+ private readonly rostersPath;
8
+ constructor(rostersPath: string);
9
+ getRoster(accountId: string): Promise<AccountRoster | null>;
10
+ putRoster(roster: AccountRoster): Promise<void>;
11
+ getPin(accountId: string): Promise<RosterPin | null>;
12
+ putPin(pin: RosterPin): Promise<void>;
13
+ /** Read + parse a JSON file, returning null on a missing file, invalid JSON, or a
14
+ * non-object payload (guarded; mirrors FileGrantStore.readJson). */
15
+ private readJson;
16
+ }
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FileRosterStore = void 0;
37
+ exports.rostersDirFor = rostersDirFor;
38
+ // FileRosterStore — filesystem adapter for RosterStore.
39
+ //
40
+ // Stores the account roster and its pinned key as JSON files in a sibling
41
+ // `_rosters/` collection next to the friends directory (one `<accountId>.roster.json`
42
+ // and one `<accountId>.pin.json` per account). Mirrors FileGrantStore's structure
43
+ // (mkdir on construct, one file per record, guarded reads) so the stores feel
44
+ // uniform.
45
+ const fs = __importStar(require("fs"));
46
+ const fsPromises = __importStar(require("fs/promises"));
47
+ const path = __importStar(require("path"));
48
+ const observability_1 = require("./observability");
49
+ /** The sibling rosters directory for a given friends directory:
50
+ * `<friendsDir>/_rosters`. A reserved `_`-prefixed subdir (like `_grants`) so one
51
+ * `--dir` still points the whole substrate at one place. */
52
+ function rostersDirFor(friendsDir) {
53
+ return path.join(friendsDir, "_rosters");
54
+ }
55
+ /** SECURITY (finding 8, LOW): the accountId is interpolated into the
56
+ * `<accountId>.roster.json` / `.pin.json` filename, so a wire-influenced value like
57
+ * "../evil" could otherwise escape the `_rosters/` dir. Enforce a strict allowlist
58
+ * (alphanumerics, dot, underscore, hyphen — no path separators) AND a basename
59
+ * identity (no normalization surprises), rejecting `.`/`..` and the empty string.
60
+ * Throws on anything unsafe so a bad accountId can never reach the filesystem. */
61
+ const SAFE_ACCOUNT_ID = /^[A-Za-z0-9._-]+$/;
62
+ function assertSafeAccountId(accountId) {
63
+ // Basename identity is the primary traversal guard: any value carrying a path
64
+ // separator (`/` on POSIX, `/` or `\` on Windows) basenames to something else and is
65
+ // rejected. The charset allowlist then rejects the remaining unsafe-but-separatorless
66
+ // bytes (spaces, shell metachars, control chars, the empty string — `+` needs ≥1
67
+ // char). The explicit `.`/`..` reject closes the two in-charset relative-dir refs.
68
+ if (path.basename(accountId) !== accountId) {
69
+ throw new Error(`unsafe accountId ${JSON.stringify(accountId)}: must not contain a path separator`);
70
+ }
71
+ if (!SAFE_ACCOUNT_ID.test(accountId) || accountId === "." || accountId === "..") {
72
+ throw new Error(`unsafe accountId ${JSON.stringify(accountId)}: must match ${SAFE_ACCOUNT_ID} (not "." or "..")`);
73
+ }
74
+ }
75
+ class FileRosterStore {
76
+ rostersPath;
77
+ constructor(rostersPath) {
78
+ this.rostersPath = rostersPath;
79
+ fs.mkdirSync(rostersPath, { recursive: true });
80
+ (0, observability_1.emitNervesEvent)({
81
+ component: "friends",
82
+ event: "friends.roster_store_init",
83
+ message: "file roster store initialized",
84
+ meta: {},
85
+ });
86
+ }
87
+ async getRoster(accountId) {
88
+ assertSafeAccountId(accountId);
89
+ const raw = await this.readJson(path.join(this.rostersPath, `${accountId}.roster.json`));
90
+ return raw;
91
+ }
92
+ async putRoster(roster) {
93
+ assertSafeAccountId(roster.accountId);
94
+ await fsPromises.writeFile(path.join(this.rostersPath, `${roster.accountId}.roster.json`), JSON.stringify(roster, null, 2), "utf-8");
95
+ }
96
+ async getPin(accountId) {
97
+ assertSafeAccountId(accountId);
98
+ const raw = await this.readJson(path.join(this.rostersPath, `${accountId}.pin.json`));
99
+ return raw;
100
+ }
101
+ async putPin(pin) {
102
+ assertSafeAccountId(pin.accountId);
103
+ await fsPromises.writeFile(path.join(this.rostersPath, `${pin.accountId}.pin.json`), JSON.stringify(pin, null, 2), "utf-8");
104
+ }
105
+ /** Read + parse a JSON file, returning null on a missing file, invalid JSON, or a
106
+ * non-object payload (guarded; mirrors FileGrantStore.readJson). */
107
+ async readJson(filePath) {
108
+ try {
109
+ const raw = await fsPromises.readFile(filePath, "utf-8");
110
+ try {
111
+ const parsed = JSON.parse(raw);
112
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
113
+ return null;
114
+ return parsed;
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ }
125
+ exports.FileRosterStore = FileRosterStore;
@@ -0,0 +1,9 @@
1
+ import type { AccountRoster, RosterPin, RosterStore } from "./roster-store";
2
+ export declare class MemoryRosterStore implements RosterStore {
3
+ private readonly rosters;
4
+ private readonly pins;
5
+ getRoster(accountId: string): Promise<AccountRoster | null>;
6
+ putRoster(roster: AccountRoster): Promise<void>;
7
+ getPin(accountId: string): Promise<RosterPin | null>;
8
+ putPin(pin: RosterPin): Promise<void>;
9
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryRosterStore = void 0;
4
+ class MemoryRosterStore {
5
+ rosters = new Map();
6
+ pins = new Map();
7
+ async getRoster(accountId) {
8
+ return this.rosters.get(accountId) ?? null;
9
+ }
10
+ async putRoster(roster) {
11
+ this.rosters.set(roster.accountId, roster);
12
+ }
13
+ async getPin(accountId) {
14
+ return this.pins.get(accountId) ?? null;
15
+ }
16
+ async putPin(pin) {
17
+ this.pins.set(pin.accountId, pin);
18
+ }
19
+ }
20
+ exports.MemoryRosterStore = MemoryRosterStore;
@@ -0,0 +1,29 @@
1
+ /** The signed account roster as it lives on the wire / on disk. `members` lists the
2
+ * owner's agents by `{ handle, did }`; `epoch` is the monotonic roster version; the
3
+ * Ed25519 `sig` is over `jcsBytes({ accountId, members, epoch })` (the roster minus
4
+ * `sig`), exactly how `verifyEnvelopeSignature` signs the proof-stripped envelope. */
5
+ export interface AccountRoster {
6
+ accountId: string;
7
+ members: {
8
+ handle: string;
9
+ did: string;
10
+ }[];
11
+ epoch: number;
12
+ sig: string;
13
+ }
14
+ /** The TOFU-pinned roster signing key for an account (first-contact pin; a changed
15
+ * key HARD-FAILS rather than silently re-pinning). `rosterKey` is the base64
16
+ * Ed25519 public key the roster `sig` must verify under. */
17
+ export interface RosterPin {
18
+ accountId: string;
19
+ rosterKey: string;
20
+ pinnedAt: string;
21
+ }
22
+ /** Domain-specific store for the account roster + its pinned signing key. One
23
+ * roster and one pin per accountId. */
24
+ export interface RosterStore {
25
+ getRoster(accountId: string): Promise<AccountRoster | null>;
26
+ putRoster(roster: AccountRoster): Promise<void>;
27
+ getPin(accountId: string): Promise<RosterPin | null>;
28
+ putPin(pin: RosterPin): Promise<void>;
29
+ }
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ // Roster store abstraction (p11 Item 3 — the account roster).
3
+ //
4
+ // The pinned account roster + its TOFU roster-key pin persist through RosterStore —
5
+ // a sibling to GrantStore/MissionStore, mirroring their shape. The core stays
6
+ // storage-agnostic; backends stay pluggable. No roster module imports `fs` directly
7
+ // except the FileRosterStore adapter. This file is a PURE INTERFACE (no logic) and
8
+ // is coverage-excluded in vitest.config.ts, mirroring src/store.ts.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,23 @@
1
+ import type { AccountRoster } from "./roster-store";
2
+ export interface RosterVerifier {
3
+ /** Whether `roster` is authentic under the pinned `rosterKey`. The identity-only
4
+ * default returns true for any well-formed roster (ignores the sig); an Ed25519
5
+ * impl verifies the detached sig over the canonical roster bytes. */
6
+ verify(roster: AccountRoster, rosterKey: string): boolean;
7
+ /** SECURITY (finding 1, HIGH): whether this verifier is strong enough to back a
8
+ * FAMILY grant. The identity-only default ignores the sig, so it MUST NOT grant
9
+ * family — only a real cryptographic verifier (the a2a-client `ed25519RosterVerifier`)
10
+ * sets this true. `evaluateAccountMembership` fails closed (→ `unverified`, never
11
+ * `family_same_account`) when the active verifier is not family-granting. Optional
12
+ * + defaulting-to-false so a custom verifier is non-granting unless it opts in. */
13
+ grantsFamily?: boolean;
14
+ }
15
+ /** Identity-only roster verifier: accept any well-formed roster, ignore the sig.
16
+ * The day-one default for NON-GRANT identity checks; it deliberately omits
17
+ * `grantsFamily` (defaults to false) so the family-granting path
18
+ * (`evaluateAccountMembership`) fails closed under it — a garbage-signed roster can
19
+ * never yield a family grant without a real cryptographic verifier injected. Mirrors
20
+ * `tofuVerifier`. */
21
+ export declare const identityRosterVerifier: RosterVerifier;
22
+ /** The default verifier used when no crypto verifier is injected. */
23
+ export declare const DEFAULT_ROSTER_VERIFIER: RosterVerifier;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_ROSTER_VERIFIER = exports.identityRosterVerifier = void 0;
4
+ // RosterVerifier — the pluggable account-roster authentication seam (Q1; mirrors
5
+ // AgentVerifier exactly). The core declares the INTERFACE + an identity-only
6
+ // default that does NO crypto; the a2a-client side provides the real Ed25519
7
+ // implementation (`ed25519RosterVerifier`), which the host injects — the same split
8
+ // that keeps `DidVerifier` out of the core. THIS MODULE MUST NOT import
9
+ // src/a2a-client/ or libsodium (the no-restricted-imports lint enforces it).
10
+ //
11
+ // The canonical-bytes contract both sides agree on: the roster `sig` is an Ed25519
12
+ // detached signature over `jcsBytes({ accountId, members, epoch })` — the roster
13
+ // MINUS its `sig` field — exactly how `verifyEnvelopeSignature` signs the
14
+ // proof-stripped envelope. The identity default ignores the sig (TOFU-equivalent);
15
+ // the crypto impl checks it.
16
+ const observability_1 = require("./observability");
17
+ /** A roster is well-formed when it has the structural shape the membership check
18
+ * relies on: a string accountId, an array of `{handle, did}` members, a numeric
19
+ * epoch, and a string sig. (The identity verifier accepts any well-formed roster
20
+ * without checking the sig — TOFU-equivalent, mirroring `tofuVerifier`.) */
21
+ function isWellFormedRoster(roster) {
22
+ return (typeof roster.accountId === "string" &&
23
+ Array.isArray(roster.members) &&
24
+ roster.members.every((m) => typeof m.handle === "string" && typeof m.did === "string") &&
25
+ typeof roster.epoch === "number" &&
26
+ typeof roster.sig === "string");
27
+ }
28
+ /** Identity-only roster verifier: accept any well-formed roster, ignore the sig.
29
+ * The day-one default for NON-GRANT identity checks; it deliberately omits
30
+ * `grantsFamily` (defaults to false) so the family-granting path
31
+ * (`evaluateAccountMembership`) fails closed under it — a garbage-signed roster can
32
+ * never yield a family grant without a real cryptographic verifier injected. Mirrors
33
+ * `tofuVerifier`. */
34
+ exports.identityRosterVerifier = {
35
+ verify(roster) {
36
+ const ok = isWellFormedRoster(roster);
37
+ (0, observability_1.emitNervesEvent)({
38
+ component: "friends",
39
+ event: "friends.roster_verified",
40
+ message: "verified account roster (tofu)",
41
+ meta: { accountId: roster.accountId, epoch: roster.epoch, ok },
42
+ });
43
+ return ok;
44
+ },
45
+ };
46
+ /** The default verifier used when no crypto verifier is injected. */
47
+ exports.DEFAULT_ROSTER_VERIFIER = exports.identityRosterVerifier;
@@ -12,9 +12,13 @@ export declare class FileFriendStore implements FriendStore {
12
12
  private normalize;
13
13
  private normalizeAgentMeta;
14
14
  private normalizeA2AMeta;
15
- /** Preserve an additive a2a.mailbox coord only when both fields are strings;
16
- * otherwise drop it (absent ⇒ unchanged — the additive guarantee). */
15
+ /** Preserve the top-level mailbox coord only when both fields are strings;
16
+ * otherwise drop it (absent ⇒ unchanged — the additive guarantee). Also used to
17
+ * migrate a legacy nested `a2a.mailbox`. */
17
18
  private normalizeMailbox;
19
+ /** Preserve an additive a2a.relay coord only when both fields are strings;
20
+ * otherwise drop it (absent ⇒ unchanged — the additive guarantee). */
21
+ private normalizeRelay;
18
22
  private readJson;
19
23
  private writeJson;
20
24
  private removeFile;