@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
@@ -0,0 +1,52 @@
1
+ import type { AccountRoster, RosterStore } from "./roster-store";
2
+ import type { RosterVerifier } from "./roster-verifier";
3
+ export type AccountMembershipDecision = "family_same_account" | "not_member" | "unverified" | "roster_key_mismatch";
4
+ /** SECURITY (finding 2, HIGH): an opaque "the caller has verified this peer controls
5
+ * this did" token. `evaluateAccountMembership` grants family ONLY for a value of this
6
+ * type, and the ONLY way to produce one is `verifiedCandidate(did)` — so the
7
+ * candidate-DID precondition is impossible to forget at a call site (a bare string
8
+ * does not type-check). The private brand makes it unforgeable from a plain object. */
9
+ export interface VerifiedCandidate {
10
+ readonly did: string;
11
+ /** Private brand — prevents `{ did }` from structurally satisfying the type. */
12
+ readonly [VERIFIED_BRAND]: true;
13
+ }
14
+ declare const VERIFIED_BRAND: unique symbol;
15
+ /** Mint a {@link VerifiedCandidate}. CALLING THIS IS AN ASSERTION: the caller has
16
+ * already proven (via a DID/pinned-key handshake — e.g. the a2a-client sealed-envelope
17
+ * gate that runs `DidVerifier` before this) that the peer controls `did`. Never call
18
+ * it on an attacker-supplied did that has not been authenticated. */
19
+ export declare function verifiedCandidate(did: string): VerifiedCandidate;
20
+ export interface EvaluateAccountMembershipInput {
21
+ roster: AccountRoster;
22
+ /** SECURITY (finding 2): the verified candidate — only mintable via
23
+ * `verifiedCandidate(did)` after the caller has authenticated the peer's control of
24
+ * the did. The roster membership + sig checks are NOT a proof of did-control on
25
+ * their own; this token supplies that missing precondition. */
26
+ candidate: VerifiedCandidate;
27
+ rosterKey: string;
28
+ store: RosterStore;
29
+ verifier?: RosterVerifier;
30
+ }
31
+ export interface AccountMembershipResult {
32
+ decision: AccountMembershipDecision;
33
+ reason?: string;
34
+ }
35
+ /** Test seam: reset the one-time-warning latch so a test can assert the loud warning
36
+ * fires (and de-dupes) deterministically, independent of test order. */
37
+ export declare function _resetRosterVerifierWarningForTest(): void;
38
+ /** Decide whether the VERIFIED `candidate` is family-via-same-account under `roster`.
39
+ *
40
+ * Preconditions (all enforced, not merely documented):
41
+ * - The caller has authenticated that the peer controls `candidate.did` (carried by
42
+ * the unforgeable {@link VerifiedCandidate} — finding 2). Membership + sig are NOT
43
+ * a substitute for did-control.
44
+ * - A real cryptographic `verifier` (`grantsFamily: true`) is injected. The
45
+ * identity-only default fails closed: it can verify identity for non-grant checks
46
+ * but can NEVER produce a `family_same_account` grant (finding 1).
47
+ *
48
+ * Flow: TOFU-pin the roster key on first contact; a changed key hard-fails; the
49
+ * verifier must accept the roster; the verifier must be family-granting; the
50
+ * candidate's did must be in the roster. Any miss yields a non-family decision. */
51
+ export declare function evaluateAccountMembership(input: EvaluateAccountMembershipInput): Promise<AccountMembershipResult>;
52
+ export {};
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifiedCandidate = verifiedCandidate;
4
+ exports._resetRosterVerifierWarningForTest = _resetRosterVerifierWarningForTest;
5
+ exports.evaluateAccountMembership = evaluateAccountMembership;
6
+ // evaluateAccountMembership — the Increment-1 payoff (Item 3). Grants `family` via
7
+ // TrustBasis "same_account" ONLY to a peer whose `did` is in the pinned roster AND
8
+ // whose roster verifies under the TOFU-pinned roster key. A changed roster key
9
+ // HARD-FAILS (no silent re-pin).
10
+ //
11
+ // CORE module: it uses the INJECTED RosterVerifier + RosterStore seams and does NO
12
+ // direct crypto (no a2a-client / libsodium import) — the Ed25519 verifier is
13
+ // injected by the host/test. The lint enforces the dependency direction.
14
+ const observability_1 = require("./observability");
15
+ const roster_verifier_1 = require("./roster-verifier");
16
+ /** Mint a {@link VerifiedCandidate}. CALLING THIS IS AN ASSERTION: the caller has
17
+ * already proven (via a DID/pinned-key handshake — e.g. the a2a-client sealed-envelope
18
+ * gate that runs `DidVerifier` before this) that the peer controls `did`. Never call
19
+ * it on an attacker-supplied did that has not been authenticated. */
20
+ function verifiedCandidate(did) {
21
+ return { did };
22
+ }
23
+ /** One-time loud-warning latch: we warn at most once per process when a family grant
24
+ * is refused purely because the active verifier is not cryptographic (finding 1). */
25
+ let warnedNonCryptographicVerifier = false;
26
+ /** Test seam: reset the one-time-warning latch so a test can assert the loud warning
27
+ * fires (and de-dupes) deterministically, independent of test order. */
28
+ function _resetRosterVerifierWarningForTest() {
29
+ warnedNonCryptographicVerifier = false;
30
+ }
31
+ /** Decide whether the VERIFIED `candidate` is family-via-same-account under `roster`.
32
+ *
33
+ * Preconditions (all enforced, not merely documented):
34
+ * - The caller has authenticated that the peer controls `candidate.did` (carried by
35
+ * the unforgeable {@link VerifiedCandidate} — finding 2). Membership + sig are NOT
36
+ * a substitute for did-control.
37
+ * - A real cryptographic `verifier` (`grantsFamily: true`) is injected. The
38
+ * identity-only default fails closed: it can verify identity for non-grant checks
39
+ * but can NEVER produce a `family_same_account` grant (finding 1).
40
+ *
41
+ * Flow: TOFU-pin the roster key on first contact; a changed key hard-fails; the
42
+ * verifier must accept the roster; the verifier must be family-granting; the
43
+ * candidate's did must be in the roster. Any miss yields a non-family decision. */
44
+ async function evaluateAccountMembership(input) {
45
+ const { roster, candidate, rosterKey, store } = input;
46
+ const accountId = roster.accountId;
47
+ // 1) Roster-key pin (TOFU). First contact pins the key; an EXISTING pin for a
48
+ // DIFFERENT key HARD-FAILS (no silent re-pin); a matching pin proceeds.
49
+ const existingPin = await store.getPin(accountId);
50
+ if (!existingPin) {
51
+ await store.putPin({ accountId, rosterKey, pinnedAt: new Date().toISOString() });
52
+ }
53
+ else if (existingPin.rosterKey !== rosterKey) {
54
+ const result = {
55
+ decision: "roster_key_mismatch",
56
+ reason: "presented roster key does not match the pinned key",
57
+ };
58
+ emit(result.decision, accountId);
59
+ return result;
60
+ }
61
+ // 2) Authenticity: the injected verifier (or the identity default) must accept
62
+ // the roster under the pinned/presented key.
63
+ const verifier = input.verifier ?? roster_verifier_1.DEFAULT_ROSTER_VERIFIER;
64
+ if (!verifier.verify(roster, rosterKey)) {
65
+ const result = { decision: "unverified", reason: "roster signature did not verify" };
66
+ emit(result.decision, accountId);
67
+ return result;
68
+ }
69
+ // 2b) SECURITY (finding 1, HIGH): FAIL CLOSED on the family-granting path. The
70
+ // identity-only default accepts any well-formed roster (it ignores the sig), so it
71
+ // MUST NOT be allowed to grant family — only a real cryptographic verifier
72
+ // (`grantsFamily: true`) can. Without one, the strongest tier is unreachable: a
73
+ // would-be member is `unverified`, never `family_same_account`. Warn LOUDLY once.
74
+ if (verifier.grantsFamily !== true) {
75
+ if (!warnedNonCryptographicVerifier) {
76
+ warnedNonCryptographicVerifier = true;
77
+ (0, observability_1.emitNervesEvent)({
78
+ level: "warn",
79
+ component: "friends",
80
+ event: "friends.roster_verifier_not_cryptographic",
81
+ message: "REFUSING to grant family_same_account: no cryptographic RosterVerifier injected (the identity-only default cannot back a family grant). Inject ed25519RosterVerifier to enable same-account family.",
82
+ meta: { accountId },
83
+ });
84
+ }
85
+ const result = {
86
+ decision: "unverified",
87
+ reason: "no cryptographic roster verifier injected — family grant withheld (fail-closed)",
88
+ };
89
+ emit(result.decision, accountId);
90
+ return result;
91
+ }
92
+ // 3) Membership: the candidate's did must be in the verified roster.
93
+ const isMember = roster.members.some((m) => m.did === candidate.did);
94
+ const result = isMember
95
+ ? { decision: "family_same_account" }
96
+ : { decision: "not_member", reason: "candidate did is not in the roster" };
97
+ emit(result.decision, accountId);
98
+ return result;
99
+ }
100
+ /** Emit the membership-evaluated nerves event. */
101
+ function emit(decision, accountId) {
102
+ (0, observability_1.emitNervesEvent)({
103
+ component: "friends",
104
+ event: "friends.account_membership_evaluated",
105
+ message: "evaluated account-roster membership",
106
+ meta: { accountId, decision },
107
+ });
108
+ }
@@ -13,7 +13,11 @@ async function upsertAgentPeer(store, input) {
13
13
  const { name, agentId, a2a, bundleName } = input;
14
14
  const existing = await store.findByExternalId("a2a-agent", agentId);
15
15
  const now = new Date().toISOString();
16
- const trustLevel = input.trustLevel ?? existing?.trustLevel ?? "acquaintance";
16
+ // Bug A cold contact is safe-by-default: a brand-new peer with no explicit
17
+ // trustLevel and no existing record lands at `stranger`, not `acquaintance`. An
18
+ // owner-initiated onboard that passes an explicit `trustLevel`, and an existing
19
+ // record's level, both still win (they precede this fallback).
20
+ const trustLevel = input.trustLevel ?? existing?.trustLevel ?? "stranger";
17
21
  const baseMeta = existing?.agentMeta ?? {
18
22
  bundleName: bundleName ?? name,
19
23
  familiarity: 0,
@@ -36,9 +40,13 @@ async function upsertAgentPeer(store, input) {
36
40
  trustLevel,
37
41
  kind: "agent",
38
42
  agentMeta: {
43
+ // `...baseMeta` already carries any existing top-level `mailbox`; an explicit
44
+ // `input.mailbox` overrides it below. Mailbox is top-level on AgentMeta since
45
+ // the phase-8 demote (was nested under `a2a` in alpha.4).
39
46
  ...baseMeta,
40
47
  bundleName: baseMeta.bundleName || bundleName || name,
41
- a2a: { ...(a2a ?? {}), agentId, ...(input.mailbox ? { mailbox: input.mailbox } : {}) },
48
+ a2a: { ...(a2a ?? {}), agentId },
49
+ ...(input.mailbox ? { mailbox: input.mailbox } : {}),
42
50
  },
43
51
  externalIds: [
44
52
  ...(existing?.externalIds.filter((id) => !(id.provider === "a2a-agent" && id.externalId === agentId)) ?? []),
@@ -0,0 +1,38 @@
1
+ import type { TrustBasis } from "./trust-explanation";
2
+ import type { TrustLevel } from "./types";
3
+ /** One append-only control-plane audit record. Captures a single trust mutation:
4
+ * WHO (`actor`), to WHOM (`targetId` / optional `targetDid`), the new `level`, the
5
+ * `basis` it was granted on, the `originSense` it came through, and WHEN (`ts`). */
6
+ export interface ControlPlaneAuditRecord {
7
+ action: "set_trust";
8
+ targetId: string;
9
+ targetDid?: string;
10
+ level: TrustLevel;
11
+ basis?: TrustBasis;
12
+ actor: string;
13
+ originSense?: string;
14
+ ts: string;
15
+ }
16
+ /** The append-only sink a control-plane mutation writes through. The host
17
+ * implements it (in-memory in tests, a file/JSONL adapter in production). */
18
+ export interface AuditSink {
19
+ append(record: ControlPlaneAuditRecord): Promise<void> | void;
20
+ }
21
+ /** In-memory append-only sink — test/host convenience, mirroring MemoryPinStore.
22
+ * `list()` exposes the records in append order; there is no overwrite. */
23
+ export declare class MemoryAuditSink implements AuditSink {
24
+ private readonly records;
25
+ append(record: ControlPlaneAuditRecord): void;
26
+ list(): ControlPlaneAuditRecord[];
27
+ }
28
+ /** The append-only control-plane log file for a given friends directory:
29
+ * `<friendsDir>/_audit/control.jsonl`. A reserved `_`-prefixed sibling (like
30
+ * `_grants/`) so one `--dir` covers it; JSONL so appends never rewrite history. */
31
+ export declare function auditPathFor(friendsDir: string): string;
32
+ /** Filesystem AuditSink — appends each record as one JSON line to
33
+ * `_audit/control.jsonl`. mkdir-on-construct, mirroring FileGrantStore. */
34
+ export declare class FileAuditSink implements AuditSink {
35
+ private readonly filePath;
36
+ constructor(filePath: string);
37
+ append(record: ControlPlaneAuditRecord): Promise<void>;
38
+ }
package/dist/audit.js ADDED
@@ -0,0 +1,86 @@
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.FileAuditSink = exports.MemoryAuditSink = void 0;
37
+ exports.auditPathFor = auditPathFor;
38
+ // Control-plane audit (Bug B) — an append-only record of every trust mutation.
39
+ //
40
+ // The control plane is "who changed a peer's standing, from where, and why". The
41
+ // package must stay storage-agnostic (and 100%-coverable), so the audit is an
42
+ // injectable SINK — not a hard-wired `fs` write — mirroring the observability
43
+ // seam and the GrantStore/FileGrantStore split. `setFriendTrust` writes one record
44
+ // on a successful mutation; the host wires a `FileAuditSink` (or its own) to
45
+ // persist it. With no sink injected, the mutation is unchanged (no-op audit).
46
+ const fs = __importStar(require("fs"));
47
+ const fsPromises = __importStar(require("fs/promises"));
48
+ const path = __importStar(require("path"));
49
+ const observability_1 = require("./observability");
50
+ /** In-memory append-only sink — test/host convenience, mirroring MemoryPinStore.
51
+ * `list()` exposes the records in append order; there is no overwrite. */
52
+ class MemoryAuditSink {
53
+ records = [];
54
+ append(record) {
55
+ this.records.push(record);
56
+ }
57
+ list() {
58
+ return [...this.records];
59
+ }
60
+ }
61
+ exports.MemoryAuditSink = MemoryAuditSink;
62
+ /** The append-only control-plane log file for a given friends directory:
63
+ * `<friendsDir>/_audit/control.jsonl`. A reserved `_`-prefixed sibling (like
64
+ * `_grants/`) so one `--dir` covers it; JSONL so appends never rewrite history. */
65
+ function auditPathFor(friendsDir) {
66
+ return path.join(friendsDir, "_audit", "control.jsonl");
67
+ }
68
+ /** Filesystem AuditSink — appends each record as one JSON line to
69
+ * `_audit/control.jsonl`. mkdir-on-construct, mirroring FileGrantStore. */
70
+ class FileAuditSink {
71
+ filePath;
72
+ constructor(filePath) {
73
+ this.filePath = filePath;
74
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
75
+ (0, observability_1.emitNervesEvent)({
76
+ component: "friends",
77
+ event: "friends.audit_sink_init",
78
+ message: "file audit sink initialized",
79
+ meta: {},
80
+ });
81
+ }
82
+ async append(record) {
83
+ await fsPromises.appendFile(this.filePath, JSON.stringify(record) + "\n", "utf-8");
84
+ }
85
+ }
86
+ exports.FileAuditSink = FileAuditSink;
@@ -1,12 +1,17 @@
1
1
  import { FileFriendStore } from "./store-file";
2
2
  import { FileGrantStore } from "./grant-store-file";
3
3
  import { FileMissionStore } from "./mission-store-file";
4
+ import { FileAuditSink } from "./audit";
4
5
  export interface FileBundle {
5
6
  store: FileFriendStore;
6
7
  grants: FileGrantStore;
7
8
  missions: FileMissionStore;
9
+ /** Control-plane audit sink (Bug B, finding 3) over the sibling `_audit/control.jsonl`,
10
+ * so the live MCP `set_trust` / `onboard_agent` trust seat write audit records. */
11
+ audit: FileAuditSink;
8
12
  friendsDir: string;
9
13
  grantsDir: string;
10
14
  missionsDir: string;
15
+ auditPath: string;
11
16
  }
12
17
  export declare function openFileBundle(friendsDir: string): FileBundle;
@@ -7,17 +7,21 @@ exports.openFileBundle = openFileBundle;
7
7
  const store_file_1 = require("./store-file");
8
8
  const grant_store_file_1 = require("./grant-store-file");
9
9
  const mission_store_file_1 = require("./mission-store-file");
10
+ const audit_1 = require("./audit");
10
11
  const observability_1 = require("./observability");
11
12
  function openFileBundle(friendsDir) {
12
13
  const grantsDir = (0, grant_store_file_1.grantsDirFor)(friendsDir);
13
14
  const missionsDir = (0, mission_store_file_1.missionsDirFor)(friendsDir);
15
+ const auditPath = (0, audit_1.auditPathFor)(friendsDir);
14
16
  (0, observability_1.emitNervesEvent)({ component: "friends", event: "friends.file_bundle_opened", message: "opened file bundle", meta: {} });
15
17
  return {
16
18
  store: new store_file_1.FileFriendStore(friendsDir),
17
19
  grants: new grant_store_file_1.FileGrantStore(grantsDir),
18
20
  missions: new mission_store_file_1.FileMissionStore(missionsDir),
21
+ audit: new audit_1.FileAuditSink(auditPath),
19
22
  friendsDir,
20
23
  grantsDir,
21
24
  missionsDir,
25
+ auditPath,
22
26
  };
23
27
  }
@@ -0,0 +1,9 @@
1
+ import type { FriendStore } from "./store";
2
+ import type { FriendRecord } from "./types";
3
+ /** Find the friend record whose durable identity DID equals `did`. A DUPLICATE did is
4
+ * an anomaly: it emits a loud `friends.duplicate_did` warning and resolves
5
+ * deterministically WITHOUT rewarding back-dating — a pinned/verified record wins, else
6
+ * the lowest record `id` (a stable, non-temporal tie-break) — see {@link preferOverBest}.
7
+ * Returns null when no record matches, the query did is falsy, or the store has no
8
+ * `listAll`. */
9
+ export declare function findFriendByDid(store: FriendStore, did: string): Promise<FriendRecord | null>;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findFriendByDid = findFriendByDid;
4
+ // did-aware friend lookup (p11 Item 2 — the DID re-key).
5
+ //
6
+ // `did` is the durable cross-agent primary key. This pure helper finds a friend
7
+ // record by did WITHOUT changing the FriendStore interface contract: it scans
8
+ // `store.listAll?.()` and matches on the record's resolved identity
9
+ // (`resolveAgentIdentity(f.agentMeta).did`, which already prefers identity.did and
10
+ // migrates a2a.did on read). Additive — `findByExternalId` is untouched. A store
11
+ // with no `listAll` yields null (the lookup is best-effort, never a throw).
12
+ const observability_1 = require("./observability");
13
+ const identity_1 = require("./identity");
14
+ /** Whether `candidate` should replace the current best among duplicate-did records.
15
+ *
16
+ * SECURITY (finding 5, MEDIUM): the tie-break must NOT reward back-dating — the old
17
+ * "lowest createdAt wins" rule let an attacker mint a duplicate-did record with an
18
+ * earlier createdAt to silently shadow a legit one. Instead:
19
+ * 1) Prefer a trust-relevant signal — a record carrying a TOFU-pinned key
20
+ * (`pinnedKey`) is the verified one and beats an unpinned duplicate.
21
+ * 2) When pinned-status is equal, break the tie by the record `id` (a stable,
22
+ * non-temporal key) — back-dating `createdAt` no longer gains anything. */
23
+ function preferOverBest(candidate, best) {
24
+ const candidatePinned = (0, identity_1.resolveAgentIdentity)(candidate.agentMeta).pinnedKey !== undefined;
25
+ const bestPinned = (0, identity_1.resolveAgentIdentity)(best.agentMeta).pinnedKey !== undefined;
26
+ if (candidatePinned !== bestPinned)
27
+ return candidatePinned; // pinned beats unpinned
28
+ return candidate.id < best.id; // stable, non-temporal tie-break
29
+ }
30
+ /** Find the friend record whose durable identity DID equals `did`. A DUPLICATE did is
31
+ * an anomaly: it emits a loud `friends.duplicate_did` warning and resolves
32
+ * deterministically WITHOUT rewarding back-dating — a pinned/verified record wins, else
33
+ * the lowest record `id` (a stable, non-temporal tie-break) — see {@link preferOverBest}.
34
+ * Returns null when no record matches, the query did is falsy, or the store has no
35
+ * `listAll`. */
36
+ async function findFriendByDid(store, did) {
37
+ // SECURITY (finding 4, MEDIUM): a falsy did query must never match. Without this,
38
+ // findFriendByDid(store, undefined|"") matched the first did-less record (a did-less
39
+ // record resolves to `undefined`, and `undefined !== undefined` is false → match).
40
+ if (!did)
41
+ return null;
42
+ if (typeof store.listAll !== "function")
43
+ return null;
44
+ const all = await store.listAll();
45
+ let best = null;
46
+ let matchCount = 0;
47
+ for (const f of all) {
48
+ const resolvedDid = (0, identity_1.resolveAgentIdentity)(f.agentMeta).did;
49
+ // Skip records whose resolved did is falsy (absent/empty) so they can never match —
50
+ // belt-and-braces with resolveAgentIdentity's own empty-string guard (finding 6).
51
+ if (!resolvedDid || resolvedDid !== did)
52
+ continue;
53
+ matchCount += 1;
54
+ if (best === null || preferOverBest(f, best))
55
+ best = f;
56
+ }
57
+ // SECURITY (finding 5): a duplicate did is itself an anomaly — surface it loudly so a
58
+ // shadowing attempt is visible, rather than silently resolving it away.
59
+ if (matchCount > 1) {
60
+ (0, observability_1.emitNervesEvent)({
61
+ level: "warn",
62
+ component: "friends",
63
+ event: "friends.duplicate_did",
64
+ message: `duplicate did detected across ${matchCount} friend records — resolving to the pinned/lowest-id record (NOT lowest-createdAt); investigate possible record shadowing`,
65
+ meta: { did, matchCount },
66
+ });
67
+ }
68
+ return best;
69
+ }
@@ -0,0 +1,17 @@
1
+ import type { AgentMeta } from "./types";
2
+ /** The resolved durable identity of an agent peer — independent of which on-disk
3
+ * shape carried it. All fields optional: a did-less legacy record reads clean. */
4
+ export interface ResolvedAgentIdentity {
5
+ did?: string;
6
+ pinnedKey?: string;
7
+ handle?: string;
8
+ pinnedAt?: string;
9
+ }
10
+ /** Read an agent's durable identity, preferring `meta.identity` and lifting the
11
+ * legacy `meta.a2a.did` on a miss (migrate-on-read). Returns `{}` for a did-less
12
+ * or absent meta. (Unit 4a stub — not implemented.) */
13
+ export declare function resolveAgentIdentity(meta: AgentMeta | undefined): ResolvedAgentIdentity;
14
+ /** Return a meta whose `identity.did` is backfilled from `a2a.did` when the durable
15
+ * home is absent (migrate-on-write); a meta already carrying `identity` is returned
16
+ * unchanged (no clobber). Absent meta is returned as-is. (Unit 4a stub.) */
17
+ export declare function withMigratedIdentity(meta: AgentMeta | undefined): AgentMeta | undefined;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveAgentIdentity = resolveAgentIdentity;
4
+ exports.withMigratedIdentity = withMigratedIdentity;
5
+ // Agent identity migrate-on-read (p11 Item 2 — the DID re-key).
6
+ //
7
+ // The durable identity home is `AgentMeta.identity` ({ did, pinnedKey?, handle?,
8
+ // pinnedAt? }). Legacy records carry only the optional `a2a.did` hint (or nothing).
9
+ // `resolveAgentIdentity` reads either, preferring the durable home and lifting
10
+ // `a2a.did` on a miss (migrate-on-read), mirroring FileGrantStore.normalize's
11
+ // legacy-field handling. `withMigratedIdentity` backfills `identity.did` from
12
+ // `a2a.did` so the next `put` persists it forward (migrate-on-write), matching the
13
+ // resolver's local-id migration + the grant subjectFriendId→subjectKey pattern.
14
+ const observability_1 = require("./observability");
15
+ /** Read an agent's durable identity, preferring `meta.identity` and lifting the
16
+ * legacy `meta.a2a.did` on a miss (migrate-on-read). Returns `{}` for a did-less
17
+ * or absent meta. (Unit 4a stub — not implemented.) */
18
+ function resolveAgentIdentity(meta) {
19
+ if (!meta)
20
+ return {};
21
+ // Durable home wins (authoritative). Spread only the present optional fields so
22
+ // a partial identity ({ did } only) doesn't surface undefined keys. SECURITY
23
+ // (finding 6, LOW): an empty-string did is NOT a did — omit it so it can never be a
24
+ // matchable identity key (ties to findFriendByDid's falsy-did guard, finding 4).
25
+ //
26
+ // NOTE (finding 5-D): when BOTH meta.identity.did and a legacy meta.a2a.did are
27
+ // present and DIVERGE, the durable home silently wins here. That divergence can be a
28
+ // tampering signal (a record's legacy hint disagreeing with its pinned identity).
29
+ // We don't warn from this hot path (resolveAgentIdentity runs per-record in the
30
+ // findFriendByDid scan and per-audit-derivation); a divergence audit belongs in a
31
+ // write-time reconciliation (e.g. withMigratedIdentity / the onboard path), not here.
32
+ if (meta.identity) {
33
+ const { did, pinnedKey, handle, pinnedAt } = meta.identity;
34
+ return {
35
+ ...(did ? { did } : {}),
36
+ ...(pinnedKey !== undefined ? { pinnedKey } : {}),
37
+ ...(handle !== undefined ? { handle } : {}),
38
+ ...(pinnedAt !== undefined ? { pinnedAt } : {}),
39
+ };
40
+ }
41
+ // Migrate-on-read: lift the legacy a2a.did hint when the durable home is absent.
42
+ // A falsy (absent or empty-string) hint is treated as no-did.
43
+ if (meta.a2a?.did)
44
+ return { did: meta.a2a.did };
45
+ return {};
46
+ }
47
+ /** Return a meta whose `identity.did` is backfilled from `a2a.did` when the durable
48
+ * home is absent (migrate-on-write); a meta already carrying `identity` is returned
49
+ * unchanged (no clobber). Absent meta is returned as-is. (Unit 4a stub.) */
50
+ function withMigratedIdentity(meta) {
51
+ if (!meta)
52
+ return undefined;
53
+ // Already carries the durable home → no clobber.
54
+ if (meta.identity)
55
+ return meta;
56
+ // Nothing to migrate from → unchanged. A falsy (absent or empty-string) a2a.did is
57
+ // not a real did to backfill (finding 6).
58
+ if (!meta.a2a?.did)
59
+ return meta;
60
+ // Backfill identity.did from the legacy a2a.did so the next put persists forward.
61
+ (0, observability_1.emitNervesEvent)({
62
+ component: "friends",
63
+ event: "friends.identity_migrated",
64
+ message: "backfilled AgentMeta.identity.did from legacy a2a.did",
65
+ meta: { did: meta.a2a.did },
66
+ });
67
+ return { ...meta, identity: { did: meta.a2a.did } };
68
+ }
package/dist/index.d.ts CHANGED
@@ -29,8 +29,21 @@ export { upsertGroupContextParticipants } from "./group-context";
29
29
  export { accumulateFriendTokens } from "./tokens";
30
30
  export { applyFriendNote } from "./notes";
31
31
  export { setFriendTrust } from "./trust-mutation";
32
+ export type { SetFriendTrustContext } from "./trust-mutation";
33
+ export { MemoryAuditSink, FileAuditSink, auditPathFor } from "./audit";
34
+ export type { AuditSink, ControlPlaneAuditRecord } from "./audit";
32
35
  export { linkExternalId, unlinkExternalId } from "./link-identity";
33
36
  export { upsertAgentPeer } from "./agent-peer";
37
+ export { resolveAgentIdentity, withMigratedIdentity } from "./identity";
38
+ export type { ResolvedAgentIdentity } from "./identity";
39
+ export { findFriendByDid } from "./friend-lookup";
40
+ export { FileRosterStore, rostersDirFor } from "./roster-store-file";
41
+ export type { RosterStore, AccountRoster, RosterPin } from "./roster-store";
42
+ export { identityRosterVerifier, DEFAULT_ROSTER_VERIFIER } from "./roster-verifier";
43
+ export type { RosterVerifier } from "./roster-verifier";
44
+ export { MemoryRosterStore } from "./roster-store-memory";
45
+ export { evaluateAccountMembership, verifiedCandidate, _resetRosterVerifierWarningForTest } from "./account-roster";
46
+ export type { AccountMembershipDecision, AccountMembershipResult, EvaluateAccountMembershipInput, VerifiedCandidate, } from "./account-roster";
34
47
  export { recordRelationshipOutcome } from "./outcomes";
35
48
  export { recordMission } from "./missions";
36
49
  export type { RecordMissionInput } from "./missions";
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@
6
6
  // multi-agent (a2a peer) aware, consumed through the FriendStore interface +
7
7
  // FriendResolver.
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.revokeShare = exports.grantShare = exports.importCoordination = exports.prepareCoordination = exports.importMissionShare = exports.prepareMissionShare = exports.importProfileShare = exports.prepareProfileShare = exports.DEFAULT_AGENT_VERIFIER = exports.tofuVerifier = exports.DEFAULT_CONSENT_POLICY = exports.tieredPolicy = exports.trustImpliedPolicy = exports.strictPolicy = exports.resolveRoom = exports.whoami = exports.recordMission = exports.recordRelationshipOutcome = exports.upsertAgentPeer = exports.unlinkExternalId = exports.linkExternalId = exports.setFriendTrust = exports.applyFriendNote = exports.accumulateFriendTokens = exports.upsertGroupContextParticipants = exports.DEFAULT_STANDING_RULE = exports.explainStanding = exports.assessStanding = exports.describeTrustContext = exports.getAlwaysOnSenseNames = exports.isRemoteChannel = exports.channelToFacing = exports.getChannelCapabilities = exports._setMachineOwnerUsernameForTest = exports.isLocalMachineOwnerIdentity = exports.machineOwnerUsername = exports.FriendResolver = exports.openFileBundle = exports.missionsDirFor = exports.FileMissionStore = exports.grantsDirFor = exports.FileGrantStore = exports.FileFriendStore = exports.isCoordinationIntent = exports.isShareScope = exports.isIntegration = exports.isIdentityProvider = exports.isTrustedLevel = exports.IDENTITY_SCOPES = exports.TRUSTED_LEVELS = void 0;
10
- exports.setNervesEmitter = exports.emitNervesEvent = exports.isGrantEffective = exports.listShares = void 0;
9
+ exports.resolveRoom = exports.whoami = exports.recordMission = exports.recordRelationshipOutcome = exports._resetRosterVerifierWarningForTest = exports.verifiedCandidate = exports.evaluateAccountMembership = exports.MemoryRosterStore = exports.DEFAULT_ROSTER_VERIFIER = exports.identityRosterVerifier = exports.rostersDirFor = exports.FileRosterStore = exports.findFriendByDid = exports.withMigratedIdentity = exports.resolveAgentIdentity = exports.upsertAgentPeer = exports.unlinkExternalId = exports.linkExternalId = exports.auditPathFor = exports.FileAuditSink = exports.MemoryAuditSink = exports.setFriendTrust = exports.applyFriendNote = exports.accumulateFriendTokens = exports.upsertGroupContextParticipants = exports.DEFAULT_STANDING_RULE = exports.explainStanding = exports.assessStanding = exports.describeTrustContext = exports.getAlwaysOnSenseNames = exports.isRemoteChannel = exports.channelToFacing = exports.getChannelCapabilities = exports._setMachineOwnerUsernameForTest = exports.isLocalMachineOwnerIdentity = exports.machineOwnerUsername = exports.FriendResolver = exports.openFileBundle = exports.missionsDirFor = exports.FileMissionStore = exports.grantsDirFor = exports.FileGrantStore = exports.FileFriendStore = exports.isCoordinationIntent = exports.isShareScope = exports.isIntegration = exports.isIdentityProvider = exports.isTrustedLevel = exports.IDENTITY_SCOPES = exports.TRUSTED_LEVELS = void 0;
10
+ exports.setNervesEmitter = exports.emitNervesEvent = exports.isGrantEffective = exports.listShares = exports.revokeShare = exports.grantShare = exports.importCoordination = exports.prepareCoordination = exports.importMissionShare = exports.prepareMissionShare = exports.importProfileShare = exports.prepareProfileShare = exports.DEFAULT_AGENT_VERIFIER = exports.tofuVerifier = exports.DEFAULT_CONSENT_POLICY = exports.tieredPolicy = exports.trustImpliedPolicy = exports.strictPolicy = void 0;
11
11
  // -- Values --
12
12
  var types_1 = require("./types");
13
13
  Object.defineProperty(exports, "TRUSTED_LEVELS", { enumerable: true, get: function () { return types_1.TRUSTED_LEVELS; } });
@@ -52,11 +52,40 @@ var notes_1 = require("./notes");
52
52
  Object.defineProperty(exports, "applyFriendNote", { enumerable: true, get: function () { return notes_1.applyFriendNote; } });
53
53
  var trust_mutation_1 = require("./trust-mutation");
54
54
  Object.defineProperty(exports, "setFriendTrust", { enumerable: true, get: function () { return trust_mutation_1.setFriendTrust; } });
55
+ // -- Control-plane audit (Bug B): append-only record of trust mutations --
56
+ var audit_1 = require("./audit");
57
+ Object.defineProperty(exports, "MemoryAuditSink", { enumerable: true, get: function () { return audit_1.MemoryAuditSink; } });
58
+ Object.defineProperty(exports, "FileAuditSink", { enumerable: true, get: function () { return audit_1.FileAuditSink; } });
59
+ Object.defineProperty(exports, "auditPathFor", { enumerable: true, get: function () { return audit_1.auditPathFor; } });
55
60
  var link_identity_1 = require("./link-identity");
56
61
  Object.defineProperty(exports, "linkExternalId", { enumerable: true, get: function () { return link_identity_1.linkExternalId; } });
57
62
  Object.defineProperty(exports, "unlinkExternalId", { enumerable: true, get: function () { return link_identity_1.unlinkExternalId; } });
58
63
  var agent_peer_1 = require("./agent-peer");
59
64
  Object.defineProperty(exports, "upsertAgentPeer", { enumerable: true, get: function () { return agent_peer_1.upsertAgentPeer; } });
65
+ // -- Agent identity (p11 Item 2 — DID re-key): durable home + migrate-on-read --
66
+ var identity_1 = require("./identity");
67
+ Object.defineProperty(exports, "resolveAgentIdentity", { enumerable: true, get: function () { return identity_1.resolveAgentIdentity; } });
68
+ Object.defineProperty(exports, "withMigratedIdentity", { enumerable: true, get: function () { return identity_1.withMigratedIdentity; } });
69
+ // did-aware friend lookup (the durable cross-agent primary key is the DID).
70
+ var friend_lookup_1 = require("./friend-lookup");
71
+ Object.defineProperty(exports, "findFriendByDid", { enumerable: true, get: function () { return friend_lookup_1.findFriendByDid; } });
72
+ // -- Account roster (p11 Item 3): pinned roster + TOFU roster-key storage seam --
73
+ var roster_store_file_1 = require("./roster-store-file");
74
+ Object.defineProperty(exports, "FileRosterStore", { enumerable: true, get: function () { return roster_store_file_1.FileRosterStore; } });
75
+ Object.defineProperty(exports, "rostersDirFor", { enumerable: true, get: function () { return roster_store_file_1.rostersDirFor; } });
76
+ // -- RosterVerifier seam (Q1): core declares the interface + identity-only default;
77
+ // the a2a-client provides the Ed25519 impl (host-injected). Core stays crypto-free.
78
+ var roster_verifier_1 = require("./roster-verifier");
79
+ Object.defineProperty(exports, "identityRosterVerifier", { enumerable: true, get: function () { return roster_verifier_1.identityRosterVerifier; } });
80
+ Object.defineProperty(exports, "DEFAULT_ROSTER_VERIFIER", { enumerable: true, get: function () { return roster_verifier_1.DEFAULT_ROSTER_VERIFIER; } });
81
+ var roster_store_memory_1 = require("./roster-store-memory");
82
+ Object.defineProperty(exports, "MemoryRosterStore", { enumerable: true, get: function () { return roster_store_memory_1.MemoryRosterStore; } });
83
+ // -- Account-roster membership (Item 3 payoff): family via same_account for a
84
+ // key-verified, TOFU-pinned roster member; changed roster key hard-fails. --
85
+ var account_roster_1 = require("./account-roster");
86
+ Object.defineProperty(exports, "evaluateAccountMembership", { enumerable: true, get: function () { return account_roster_1.evaluateAccountMembership; } });
87
+ Object.defineProperty(exports, "verifiedCandidate", { enumerable: true, get: function () { return account_roster_1.verifiedCandidate; } });
88
+ Object.defineProperty(exports, "_resetRosterVerifierWarningForTest", { enumerable: true, get: function () { return account_roster_1._resetRosterVerifierWarningForTest; } });
60
89
  var outcomes_1 = require("./outcomes");
61
90
  Object.defineProperty(exports, "recordRelationshipOutcome", { enumerable: true, get: function () { return outcomes_1.recordRelationshipOutcome; } });
62
91
  var missions_1 = require("./missions");
@@ -6,7 +6,13 @@ exports.compareReady = compareReady;
6
6
  exports.readIncoming = readIncoming;
7
7
  exports.isSeen = isSeen;
8
8
  exports.markSeen = markSeen;
9
- // src/a2a — the pure git-mailbox format/routing/dedup library (brick two).
9
+ // src/mailbox — the pure git-mailbox format/routing/dedup library (the demoted
10
+ // offline/no-endpoint FALLBACK transport, NOT the primary A2A path).
11
+ //
12
+ // Real A2A (`message/send` + the friends E2E sign-then-seal overlay — see
13
+ // src/a2a-client/) is the PRIMARY cross-agent transport. This git-mailbox
14
+ // survives only as a clearly-labelled fallback for peers with no reachable
15
+ // endpoint and no relay; a host opts into it explicitly.
10
16
  //
11
17
  // A consumer agent and a producer agent that authenticate as two DISTINCT git
12
18
  // identities share a dedicated PRIVATE mailbox repo. This module computes the
@@ -17,8 +23,9 @@ exports.markSeen = markSeen;
17
23
  // • NO fs / net / http / child_process / process.env / git anywhere — the wire
18
24
  // (clone / pull / add / commit / push) is entirely the caller's job.
19
25
  // Type-only imports of `ProfileShareEnvelope` (../share) + `MissionShareEnvelope`
20
- // (../mission-share) carry no runtime edge. Both are CORE modules, so the a2a→core
21
- // import direction is eslint-legal (a2a may import core; the reverse is forbidden).
26
+ // (../mission-share) carry no runtime edge. Both are CORE modules, so the
27
+ // mailbox→core import direction is eslint-legal (mailbox may import core; the
28
+ // reverse is forbidden).
22
29
  //
23
30
  // Security model (the git-native TOFU): addressing lives in the PATH, and a
24
31
  // single-writer-per-outbox-dir layout means a forged sender can't write into
package/dist/mcp/bin.js CHANGED
File without changes