@ouro.bot/friends 0.1.0-alpha.5 → 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 (41) hide show
  1. package/README.md +14 -3
  2. package/changelog.json +6 -0
  3. package/dist/a2a-client/index.d.ts +1 -0
  4. package/dist/a2a-client/index.js +5 -1
  5. package/dist/a2a-client/roster-verify.d.ts +15 -0
  6. package/dist/a2a-client/roster-verify.js +61 -0
  7. package/dist/account-roster.d.ts +52 -0
  8. package/dist/account-roster.js +108 -0
  9. package/dist/agent-peer.js +5 -1
  10. package/dist/audit.d.ts +38 -0
  11. package/dist/audit.js +86 -0
  12. package/dist/file-bundle.d.ts +5 -0
  13. package/dist/file-bundle.js +4 -0
  14. package/dist/friend-lookup.d.ts +9 -0
  15. package/dist/friend-lookup.js +69 -0
  16. package/dist/identity.d.ts +17 -0
  17. package/dist/identity.js +68 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +31 -2
  20. package/dist/mcp/dispatch.d.ts +12 -1
  21. package/dist/mcp/dispatch.js +45 -3
  22. package/dist/mcp/run-main.js +8 -5
  23. package/dist/mcp/schemas.js +1 -1
  24. package/dist/mcp/server.d.ts +9 -0
  25. package/dist/mcp/server.js +2 -2
  26. package/dist/resolver.d.ts +32 -1
  27. package/dist/resolver.js +50 -3
  28. package/dist/roster-store-file.d.ts +16 -0
  29. package/dist/roster-store-file.js +125 -0
  30. package/dist/roster-store-memory.d.ts +9 -0
  31. package/dist/roster-store-memory.js +20 -0
  32. package/dist/roster-store.d.ts +29 -0
  33. package/dist/roster-store.js +9 -0
  34. package/dist/roster-verifier.d.ts +23 -0
  35. package/dist/roster-verifier.js +47 -0
  36. package/dist/trust-explanation.d.ts +7 -1
  37. package/dist/trust-explanation.js +52 -34
  38. package/dist/trust-mutation.d.ts +13 -1
  39. package/dist/trust-mutation.js +31 -2
  40. package/dist/types.d.ts +11 -0
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -535,7 +535,13 @@ setNervesEmitter((event) => {
535
535
  `ImportMissionShareResult`, `ImportMissionShareStatus`, `CoordinationEnvelope`,
536
536
  `PrepareCoordinationInput`, `PrepareCoordinationResult`, `PrepareCoordinationStatus`,
537
537
  `ImportCoordinationInput`, `ImportCoordinationOptions`, `ImportCoordinationResult`,
538
- `ImportCoordinationStatus`.
538
+ `ImportCoordinationStatus`, `SetFriendTrustContext`, `AuditSink`, `ControlPlaneAuditRecord`,
539
+ `ResolvedAgentIdentity`, `RosterStore`, `AccountRoster`, `RosterPin`, `RosterVerifier`,
540
+ `AccountMembershipDecision`, `AccountMembershipResult`, `EvaluateAccountMembershipInput`,
541
+ `FriendResolverRosterContext`. (`TrustBasis` additively gains the `"same_account"` member — the basis
542
+ for family granted via the signed account roster — and `AgentMeta` additively gains an optional
543
+ `identity { did, pinnedKey?, handle?, pinnedAt? }` durable-identity home; both are schemaVersion-1
544
+ additive, and a legacy `a2a.did` migrates-on-read into `identity.did`.)
539
545
 
540
546
  **Values:** `TRUSTED_LEVELS`, `IDENTITY_SCOPES`, `isTrustedLevel`, `isIdentityProvider`,
541
547
  `isIntegration`, `isShareScope`, `isCoordinationIntent`, `FileFriendStore`, `FileGrantStore`,
@@ -548,7 +554,10 @@ setNervesEmitter((event) => {
548
554
  `trustImpliedPolicy`, `tieredPolicy`, `DEFAULT_CONSENT_POLICY`, `tofuVerifier`,
549
555
  `DEFAULT_AGENT_VERIFIER`, `prepareProfileShare`, `importProfileShare`, `prepareMissionShare`,
550
556
  `importMissionShare`, `prepareCoordination`, `importCoordination`, `grantShare`, `revokeShare`,
551
- `listShares`, `isGrantEffective`, `setNervesEmitter`, `emitNervesEvent`.
557
+ `listShares`, `isGrantEffective`, `setNervesEmitter`, `emitNervesEvent`, `resolveAgentIdentity`,
558
+ `withMigratedIdentity`, `findFriendByDid`, `MemoryAuditSink`, `FileAuditSink`, `auditPathFor`,
559
+ `FileRosterStore`, `rostersDirFor`, `MemoryRosterStore`, `identityRosterVerifier`,
560
+ `DEFAULT_ROSTER_VERIFIER`, `evaluateAccountMembership`.
552
561
 
553
562
  **From `@ouro.bot/friends/mcp`:** `createFriendsMcpServer`, `getToolSchemas`, `runMain` (plus the
554
563
  `McpToolSchema`, `FriendsMcpServer`, and `RunMainIo` types).
@@ -565,7 +574,9 @@ setNervesEmitter((event) => {
565
574
  identity helpers `parseDidKey` / `keyAgreementFromDidKey` / `didKeyIdentityFromEd25519` /
566
575
  `ed25519PubToDidKey` and `didWebToUrl` / `resolveDidWeb` / `parseDidDocument`; the primitives
567
576
  `sealTo` / `openSealed`, `signEnvelope` / `verifyEnvelopeSignature`, `jcsString` / `jcsBytes`, and
568
- the `ready` init seam (plus the `A2ATransport`, `DidResolution`, `SealedEnvelope`, `StructuredProof`,
577
+ the `ready` init seam; and the account-roster Ed25519 verify `ed25519RosterVerifier` / `signRoster`
578
+ (the crypto implementation of the core `RosterVerifier` seam — host-injected, so the core stays
579
+ transport-free) (plus the `A2ATransport`, `DidResolution`, `SealedEnvelope`, `StructuredProof`,
569
580
  `ReachabilityPlan`, `FriendsAgentCard`, `DidKeyIdentity`, `DidDocument` types). The transports
570
581
  (direct A2A / relay / git op) are injected by the host — this module does no network or git itself.
571
582
 
package/changelog.json CHANGED
@@ -1,5 +1,11 @@
1
1
  {
2
2
  "versions": [
3
+ {
4
+ "version": "0.1.0-alpha.6",
5
+ "changes": [
6
+ "p11 increment 1 — own-fleet foundation. Bug A: cold A2A contact defaults to stranger (safe-by-default; explicit owner-initiated trustLevel still wins). Bug B: append-only control-plane audit (AuditSink + MemoryAuditSink/FileAuditSink) on every setFriendTrust, now wired into the live MCP dispatch path (set_trust + the onboard_agent trust seat) via openFileBundle's FileAuditSink, attributed to the owner-only stdio boundary. Bug C: FriendResolver is roster-aware — a key-verified member of the pinned account roster is recognized as family across OS users, without loosening the cold-A2A stranger default. DID re-key (additive, schemaVersion still 1): AgentMeta.identity durable home with a2a.did migrate-on-read; did-aware findFriendByDid. Account roster: RosterStore/FileRosterStore/MemoryRosterStore + the RosterVerifier seam (core identity-only default; a2a-client ed25519RosterVerifier Ed25519 impl) granting family via a new same_account TrustBasis only to a TOFU-pinned, key-verified roster member (changed roster key hard-fails). Security hardening of the trust primitives: the family-grant path FAILS CLOSED — the identity-only default can never grant family (only a cryptographic verifier can, gated by a grantsFamily capability), with a loud one-time warning; evaluateAccountMembership requires an unforgeable VerifiedCandidate so the did-control precondition can't be skipped; findFriendByDid rejects falsy-did queries and no longer rewards back-dating on a duplicate-did anomaly (prefers the pinned record, warns); empty-string DIDs are never matchable keys; and FileRosterStore guards accountId against path traversal. Core remains transport-free (the CI core⊥a2a-client dependency rule is unmodified)."
7
+ ]
8
+ },
3
9
  {
4
10
  "version": "0.1.0-alpha.5",
5
11
  "changes": [
@@ -19,5 +19,6 @@ export { buildFriendsAgentCard } from "./agent-card";
19
19
  export type { A2ACapabilities, A2ASkill, FriendsAgentCard } from "./agent-card";
20
20
  export { resolveReachability } from "./reachability";
21
21
  export type { ReachabilityPlan } from "./reachability";
22
+ export { ed25519RosterVerifier, signRoster } from "./roster-verify";
22
23
  export { receiveShare, sendShare } from "./adapter";
23
24
  export type { A2ATransport, DidResolution, ReceiveShareInput, ReceiveShareResult, SeenLedgerLike, SendShareInput, SendShareResult, } from "./adapter";
@@ -10,7 +10,7 @@
10
10
  // recipient DID bound into the AEAD AD), so a relay carries CIPHERTEXT ONLY — it
11
11
  // can never read, forge, tamper, re-target, replay-to-effect, or escalate.
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.sendShare = exports.receiveShare = exports.resolveReachability = exports.buildFriendsAgentCard = exports.wrapInDataPart = exports.unwrapDataPart = exports.sealEnvelope = exports.openSealedEnvelope = exports.verifyCardDidBinding = exports.signSuccessor = exports.pinOnFirstContact = exports.MemoryPinStore = exports.isPinned = exports.getPinned = exports.evaluateRotation = exports.DidVerifier = exports.resolveDidWeb = exports.parseDidDocument = exports.didWebToUrl = exports.parseDidKey = exports.keyAgreementFromDidKey = exports.ed25519PubToDidKey = exports.didKeyIdentityFromEd25519 = exports.base58btcEncode = exports.base58btcDecode = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.serializeProof = exports.parseProof = exports.SealOpenError = exports.sealTo = exports.openSealed = exports.jcsString = exports.jcsBytes = exports.ready = void 0;
13
+ exports.sendShare = exports.receiveShare = exports.signRoster = exports.ed25519RosterVerifier = exports.resolveReachability = exports.buildFriendsAgentCard = exports.wrapInDataPart = exports.unwrapDataPart = exports.sealEnvelope = exports.openSealedEnvelope = exports.verifyCardDidBinding = exports.signSuccessor = exports.pinOnFirstContact = exports.MemoryPinStore = exports.isPinned = exports.getPinned = exports.evaluateRotation = exports.DidVerifier = exports.resolveDidWeb = exports.parseDidDocument = exports.didWebToUrl = exports.parseDidKey = exports.keyAgreementFromDidKey = exports.ed25519PubToDidKey = exports.didKeyIdentityFromEd25519 = exports.base58btcEncode = exports.base58btcDecode = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.serializeProof = exports.parseProof = exports.SealOpenError = exports.sealTo = exports.openSealed = exports.jcsString = exports.jcsBytes = exports.ready = void 0;
14
14
  // ── init seam ──
15
15
  var sodium_1 = require("./sodium");
16
16
  Object.defineProperty(exports, "ready", { enumerable: true, get: function () { return sodium_1.ready; } });
@@ -66,6 +66,10 @@ Object.defineProperty(exports, "buildFriendsAgentCard", { enumerable: true, get:
66
66
  // ── reachability ladder ──
67
67
  var reachability_1 = require("./reachability");
68
68
  Object.defineProperty(exports, "resolveReachability", { enumerable: true, get: function () { return reachability_1.resolveReachability; } });
69
+ // ── account-roster Ed25519 verify (the RosterVerifier seam's crypto impl) ──
70
+ var roster_verify_1 = require("./roster-verify");
71
+ Object.defineProperty(exports, "ed25519RosterVerifier", { enumerable: true, get: function () { return roster_verify_1.ed25519RosterVerifier; } });
72
+ Object.defineProperty(exports, "signRoster", { enumerable: true, get: function () { return roster_verify_1.signRoster; } });
69
73
  // ── send / receive adapter ──
70
74
  var adapter_1 = require("./adapter");
71
75
  Object.defineProperty(exports, "receiveShare", { enumerable: true, get: function () { return adapter_1.receiveShare; } });
@@ -0,0 +1,15 @@
1
+ import type { Sodium } from "./sodium";
2
+ import type { RosterVerifier } from "../roster-verifier";
3
+ import type { AccountRoster } from "../roster-store";
4
+ /** A RosterVerifier that checks the roster's Ed25519 signature against the base64
5
+ * `rosterKey`. Returns false (never throws) on a malformed key/sig or a bad
6
+ * signature. (Unit 7a stub — verify not implemented.) */
7
+ export declare function ed25519RosterVerifier(sodium: Sodium): RosterVerifier;
8
+ /** Test/host helper: produce a valid roster `sig` by signing `rosterSigningBytes`
9
+ * with the account's Ed25519 private key (mirrors `signSuccessor`/`signEnvelope`).
10
+ * Returns the base64 (ORIGINAL) detached signature. */
11
+ export declare function signRoster(input: {
12
+ sodium: Sodium;
13
+ accountKeyPriv: Uint8Array;
14
+ roster: Omit<AccountRoster, "sig">;
15
+ }): string;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ed25519RosterVerifier = ed25519RosterVerifier;
4
+ exports.signRoster = signRoster;
5
+ // ed25519RosterVerifier — the RosterVerifier seam's REAL crypto implementation
6
+ // (a2a-client side; MAY import libsodium/jcs/sign). Mirrors how DidVerifier
7
+ // implements the AgentVerifier seam. The host injects this so the core stays
8
+ // transport-free.
9
+ //
10
+ // Contract (shared with src/roster-verifier.ts): the roster `sig` is an Ed25519
11
+ // detached signature over `jcsBytes({ accountId, members, epoch })` — the roster
12
+ // MINUS `sig` — exactly how `verifyEnvelopeSignature` signs the proof-stripped
13
+ // envelope. `rosterKey` is the base64 (ORIGINAL) Ed25519 public key.
14
+ const jcs_1 = require("./jcs");
15
+ /** The canonical bytes the roster `sig` is computed over: the roster minus its
16
+ * `sig` field, JCS-canonicalized. Both signer and verifier MUST produce these
17
+ * identical bytes. */
18
+ function rosterSigningBytes(roster) {
19
+ return (0, jcs_1.jcsBytes)({ accountId: roster.accountId, members: roster.members, epoch: roster.epoch });
20
+ }
21
+ /** A RosterVerifier that checks the roster's Ed25519 signature against the base64
22
+ * `rosterKey`. Returns false (never throws) on a malformed key/sig or a bad
23
+ * signature. (Unit 7a stub — verify not implemented.) */
24
+ function ed25519RosterVerifier(sodium) {
25
+ return {
26
+ // SECURITY (finding 1): the REAL cryptographic verifier — the only RosterVerifier
27
+ // strong enough to back a family grant. `evaluateAccountMembership` checks this
28
+ // flag and fails closed (→ unverified) under any verifier that lacks it.
29
+ grantsFamily: true,
30
+ verify(roster, rosterKey) {
31
+ let pub;
32
+ let sig;
33
+ try {
34
+ pub = sodium.from_base64(rosterKey, sodium.base64_variants.ORIGINAL);
35
+ sig = sodium.from_base64(roster.sig, sodium.base64_variants.ORIGINAL);
36
+ }
37
+ catch {
38
+ // Malformed base64 in the key or sig → a failed verification, never a throw.
39
+ return false;
40
+ }
41
+ const msg = rosterSigningBytes(roster);
42
+ try {
43
+ return sodium.crypto_sign_verify_detached(sig, msg, pub);
44
+ }
45
+ catch {
46
+ // A wrong-length key/sig can throw inside libsodium — treat as a failed
47
+ // verification, never an uncaught error (mirrors verifyEnvelopeSignature).
48
+ return false;
49
+ }
50
+ },
51
+ };
52
+ }
53
+ /** Test/host helper: produce a valid roster `sig` by signing `rosterSigningBytes`
54
+ * with the account's Ed25519 private key (mirrors `signSuccessor`/`signEnvelope`).
55
+ * Returns the base64 (ORIGINAL) detached signature. */
56
+ function signRoster(input) {
57
+ const { sodium, accountKeyPriv, roster } = input;
58
+ const msg = (0, jcs_1.jcsBytes)({ accountId: roster.accountId, members: roster.members, epoch: roster.epoch });
59
+ const sig = sodium.crypto_sign_detached(msg, accountKeyPriv);
60
+ return sodium.to_base64(sig, sodium.base64_variants.ORIGINAL);
61
+ }
@@ -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,
@@ -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;