@ouro.bot/friends 0.1.0-alpha.5 → 0.1.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -16
- package/changelog.json +12 -0
- package/dist/a2a-client/index.d.ts +1 -0
- package/dist/a2a-client/index.js +5 -1
- package/dist/a2a-client/roster-verify.d.ts +15 -0
- package/dist/a2a-client/roster-verify.js +61 -0
- package/dist/account-roster.d.ts +52 -0
- package/dist/account-roster.js +108 -0
- package/dist/agent-peer.js +5 -1
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +86 -0
- package/dist/connect-authority.d.ts +43 -0
- package/dist/connect-authority.js +84 -0
- package/dist/connect.d.ts +55 -0
- package/dist/connect.js +160 -0
- package/dist/coordination.d.ts +17 -1
- package/dist/coordination.js +80 -6
- package/dist/file-bundle.d.ts +5 -0
- package/dist/file-bundle.js +4 -0
- package/dist/friend-lookup.d.ts +9 -0
- package/dist/friend-lookup.js +69 -0
- package/dist/identity.d.ts +17 -0
- package/dist/identity.js +68 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +51 -2
- package/dist/mailbox/index.d.ts +13 -10
- package/dist/mailbox/index.js +1 -1
- package/dist/mcp/dispatch.d.ts +27 -1
- package/dist/mcp/dispatch.js +110 -3
- package/dist/mcp/run-main.js +8 -5
- package/dist/mcp/schemas.js +51 -4
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +2 -2
- package/dist/mission-result.d.ts +82 -0
- package/dist/mission-result.js +200 -0
- package/dist/mission-store-file.js +8 -0
- package/dist/resolver.d.ts +32 -1
- package/dist/resolver.js +50 -3
- package/dist/roster-store-file.d.ts +16 -0
- package/dist/roster-store-file.js +125 -0
- package/dist/roster-store-memory.d.ts +9 -0
- package/dist/roster-store-memory.js +20 -0
- package/dist/roster-store.d.ts +29 -0
- package/dist/roster-store.js +9 -0
- package/dist/roster-verifier.d.ts +23 -0
- package/dist/roster-verifier.js +47 -0
- package/dist/trust-explanation.d.ts +7 -1
- package/dist/trust-explanation.js +52 -34
- package/dist/trust-mutation.d.ts +13 -1
- package/dist/trust-mutation.js +31 -2
- package/dist/types.d.ts +64 -0
- package/package.json +2 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { MissionStore } from "./mission-store";
|
|
2
|
+
import type { FriendStore } from "./store";
|
|
3
|
+
import type { GrantStore } from "./grant-store";
|
|
4
|
+
import type { MissionRecord, MissionResultEnvelope, TrustLevel } from "./types";
|
|
5
|
+
import type { ConsentPolicy } from "./consent";
|
|
6
|
+
import type { AgentVerifier } from "./verifier";
|
|
7
|
+
export interface PrepareMissionResultInput {
|
|
8
|
+
/** The LOCAL mission B is returning a result for, by its local UUID id. */
|
|
9
|
+
missionId: string;
|
|
10
|
+
/** The recipient agent's join-key agentId — A, the delegator. */
|
|
11
|
+
toAgentId: string;
|
|
12
|
+
/** The delegation correlation key (the gap-1 task-spec's requestId). */
|
|
13
|
+
requestId: string;
|
|
14
|
+
/** B's deliverable. `requestId`/`provenance` are stamped by the producer. */
|
|
15
|
+
result: {
|
|
16
|
+
summary: string;
|
|
17
|
+
artifact?: string;
|
|
18
|
+
outputs?: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
/** This agent's own join-key agentId — the attribution (fromAgentId = B). */
|
|
21
|
+
selfAgentId: string;
|
|
22
|
+
/** Optional proof to stamp on the envelope (for a non-TOFU recipient verifier). */
|
|
23
|
+
proof?: string;
|
|
24
|
+
}
|
|
25
|
+
export type PrepareMissionResultStatus = "not_found" | "no_consent";
|
|
26
|
+
export type PrepareMissionResultResult = {
|
|
27
|
+
ok: true;
|
|
28
|
+
envelope: MissionResultEnvelope;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
status: PrepareMissionResultStatus;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Producer half of the result-return. Resolves the local mission by `missionId`; names
|
|
35
|
+
* it by its `missionKey` (NEVER the local UUID); attributes the result to `selfAgentId`
|
|
36
|
+
* (B); correlates by `requestId`. Consent-gated via the `"coordinate"` identity-tier
|
|
37
|
+
* scope (a result is B answering A's own delegation — trust ≥ friend suffices under the
|
|
38
|
+
* tiered default, ZERO new scope). Records the result first-party on B's own record under
|
|
39
|
+
* `results[requestId]`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function prepareMissionResult(missions: MissionStore, store: FriendStore, grants: GrantStore, input: PrepareMissionResultInput, consent?: ConsentPolicy): Promise<PrepareMissionResultResult>;
|
|
42
|
+
export interface ImportMissionResultInput {
|
|
43
|
+
envelope: MissionResultEnvelope;
|
|
44
|
+
/** The agent the envelope arrived from (its join-key agentId) — B. */
|
|
45
|
+
fromAgentId: string;
|
|
46
|
+
/** This agent's resolved trust in the source agent — the cap on acceptance. */
|
|
47
|
+
trustOfSource: TrustLevel;
|
|
48
|
+
}
|
|
49
|
+
export type ImportMissionResultStatus = "imported" | "no_mission" | "no_delegation" | "assignee_mismatch" | "untrusted_source";
|
|
50
|
+
export type ImportMissionResultResult = {
|
|
51
|
+
ok: true;
|
|
52
|
+
status: "imported";
|
|
53
|
+
record: MissionRecord;
|
|
54
|
+
} | {
|
|
55
|
+
ok: false;
|
|
56
|
+
status: "no_mission" | "no_delegation" | "assignee_mismatch" | "untrusted_source";
|
|
57
|
+
};
|
|
58
|
+
export interface ImportMissionResultOptions {
|
|
59
|
+
/** Authentication seam. Defaults to TOFU. Authorization (trust) is still applied
|
|
60
|
+
* regardless of what the verifier says. */
|
|
61
|
+
verifier?: AgentVerifier;
|
|
62
|
+
/** Minimum trust a source must hold for its result to be accepted at all.
|
|
63
|
+
* Default `acquaintance`: a stranger source is refused. */
|
|
64
|
+
minTrustToAccept?: TrustLevel;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Consumer half of the result-return — the non-clobbering merge. Order (PINNED):
|
|
68
|
+
* (1) TOFU verifier + trust cap (both must pass, else `untrusted_source`, write nothing);
|
|
69
|
+
* (2) unknown mission (no findByMissionKey hit) → `no_mission` (NO seeding — a result
|
|
70
|
+
* never creates a mission);
|
|
71
|
+
* (3) the result's `requestId` not present in the record's FIRST-PARTY `delegations`
|
|
72
|
+
* (A never delegated this) → `no_delegation` — correlation honesty;
|
|
73
|
+
* (3b) the matched delegation's recorded `assignee` is not the result's source
|
|
74
|
+
* (`delegation.assignee.agentId !== fromAgentId`) → `assignee_mismatch` — assignee
|
|
75
|
+
* honesty (security-review inc-2 finding 1). FAILS CLOSED on a legacy delegation with
|
|
76
|
+
* no recorded assignee. A mismatch writes NOTHING (not even quarantined);
|
|
77
|
+
* (4) otherwise land under `importedResults[agentId][requestId]` (dedupe on replay),
|
|
78
|
+
* stamped imported + attributed + importedAt, NEVER touching first-party
|
|
79
|
+
* `learnings`/`notes`/`status`/`delegations`/`results`, NEVER recomputing
|
|
80
|
+
* status/participants (non-transitive).
|
|
81
|
+
*/
|
|
82
|
+
export declare function importMissionResult(missions: MissionStore, input: ImportMissionResultInput, options?: ImportMissionResultOptions): Promise<ImportMissionResultResult>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.prepareMissionResult = prepareMissionResult;
|
|
4
|
+
exports.importMissionResult = importMissionResult;
|
|
5
|
+
// prepareMissionResult (producer) + importMissionResult (consumer) — the result-return
|
|
6
|
+
// envelope (gap-2, p11 inc2). The honest north-star DELIVERABLE channel. Structural twin
|
|
7
|
+
// of mission-share.ts, re-aimed from a SHARE (outcomes/learnings) at a RESULT (B's actual
|
|
8
|
+
// produced artifact, attributed to B, correlated to A's delegation via missionKey +
|
|
9
|
+
// requestId). Q1 resolved: a NEW result envelope, NOT a mission_share reuse.
|
|
10
|
+
//
|
|
11
|
+
// Store-only + transport-agnostic: prepareMissionResult returns an envelope,
|
|
12
|
+
// importMissionResult consumes one; the WIRE is the caller's job (the result rides the
|
|
13
|
+
// mailbox under kind:"mission_result"). Pure — the only node builtin is `node:crypto`,
|
|
14
|
+
// mirroring mission-share.ts. Core-clean (no a2a-client import).
|
|
15
|
+
//
|
|
16
|
+
// Through-line invariants (every one tested in mission-result.test.ts):
|
|
17
|
+
// - the mission is named by its JOIN KEY (missionKey), NEVER the local UUID;
|
|
18
|
+
// - fromAgentId === the producing self (attributed to B);
|
|
19
|
+
// - the result correlates to A's delegation via requestId;
|
|
20
|
+
// - consent rides the "coordinate" identity-tier scope — a result is B answering A's OWN
|
|
21
|
+
// delegation request (no third-party content), so trust ≥ friend suffices, exactly like
|
|
22
|
+
// coordinate; NO new ShareScope, NO new content grant;
|
|
23
|
+
// - on import the deliverable lands QUARANTINED under importedResults[agentId][requestId],
|
|
24
|
+
// attributed to B + stamped imported, WITHOUT touching first-party;
|
|
25
|
+
// - correlation honesty: a result whose requestId matches NO prior first-party delegation
|
|
26
|
+
// on A is REJECTED (no_delegation) — A only accepts results for work it delegated;
|
|
27
|
+
// - assignee honesty (security-review inc-2 finding 1): a result whose source is NOT the
|
|
28
|
+
// agent A delegated TO (delegation.assignee.agentId !== fromAgentId) is REJECTED
|
|
29
|
+
// (assignee_mismatch), even from a trusted peer with the right requestId; a legacy
|
|
30
|
+
// delegation with no recorded assignee FAILS CLOSED;
|
|
31
|
+
// - NO seeding: an unknown mission → no_mission (a result never creates a mission);
|
|
32
|
+
// - the source agent's trust CAPS acceptance (a stranger/over-trust source writes nothing),
|
|
33
|
+
// checked BEFORE correlation;
|
|
34
|
+
// - non-transitive: status/participants are NEVER recomputed; first-party byte-untouched;
|
|
35
|
+
// - idempotent on replay (same (agentId, requestId) → no double-land).
|
|
36
|
+
const observability_1 = require("./observability");
|
|
37
|
+
const consent_1 = require("./consent");
|
|
38
|
+
const verifier_1 = require("./verifier");
|
|
39
|
+
/**
|
|
40
|
+
* Producer half of the result-return. Resolves the local mission by `missionId`; names
|
|
41
|
+
* it by its `missionKey` (NEVER the local UUID); attributes the result to `selfAgentId`
|
|
42
|
+
* (B); correlates by `requestId`. Consent-gated via the `"coordinate"` identity-tier
|
|
43
|
+
* scope (a result is B answering A's own delegation — trust ≥ friend suffices under the
|
|
44
|
+
* tiered default, ZERO new scope). Records the result first-party on B's own record under
|
|
45
|
+
* `results[requestId]`.
|
|
46
|
+
*/
|
|
47
|
+
async function prepareMissionResult(missions, store, grants, input, consent = consent_1.DEFAULT_CONSENT_POLICY) {
|
|
48
|
+
const record = await missions.get(input.missionId);
|
|
49
|
+
if (!record) {
|
|
50
|
+
return { ok: false, status: "not_found" };
|
|
51
|
+
}
|
|
52
|
+
// The recipient's trust is read from this agent's own knowledge of it (the a2a-agent
|
|
53
|
+
// friend record for toAgentId). An unknown recipient defaults to stranger.
|
|
54
|
+
const recipientRecord = await store.findByExternalId("a2a-agent", input.toAgentId);
|
|
55
|
+
const recipientTrust = recipientRecord?.trustLevel ?? "stranger";
|
|
56
|
+
const recipient = { agentId: input.toAgentId, trustLevel: recipientTrust };
|
|
57
|
+
// Consent rides the EXISTING "coordinate" identity-tier scope — a result is B
|
|
58
|
+
// answering A's OWN delegation, so trust ≥ friend suffices under the tiered default,
|
|
59
|
+
// with ZERO change to consent logic and NO new scope.
|
|
60
|
+
const consented = await consent.consents({
|
|
61
|
+
subjectKey: record.missionKey,
|
|
62
|
+
recipient,
|
|
63
|
+
scope: "coordinate",
|
|
64
|
+
grants,
|
|
65
|
+
});
|
|
66
|
+
if (!consented) {
|
|
67
|
+
return { ok: false, status: "no_consent" };
|
|
68
|
+
}
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
const envelope = {
|
|
71
|
+
subject: { missionKey: record.missionKey, title: record.title },
|
|
72
|
+
fromAgentId: input.selfAgentId,
|
|
73
|
+
requestId: input.requestId,
|
|
74
|
+
result: {
|
|
75
|
+
requestId: input.requestId,
|
|
76
|
+
summary: input.result.summary,
|
|
77
|
+
...(input.result.artifact !== undefined ? { artifact: input.result.artifact } : {}),
|
|
78
|
+
...(input.result.outputs !== undefined ? { outputs: input.result.outputs } : {}),
|
|
79
|
+
},
|
|
80
|
+
issuedAt: now,
|
|
81
|
+
...(input.proof !== undefined ? { proof: input.proof } : {}),
|
|
82
|
+
};
|
|
83
|
+
// Record the result first-party on B's own mission under results[requestId].
|
|
84
|
+
const firstPartyResult = { ...envelope.result, provenance: { origin: "first_party" } };
|
|
85
|
+
const updated = {
|
|
86
|
+
...record,
|
|
87
|
+
results: { ...(record.results ?? {}), [input.requestId]: firstPartyResult },
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
};
|
|
90
|
+
await missions.put(updated.id, updated);
|
|
91
|
+
(0, observability_1.emitNervesEvent)({
|
|
92
|
+
component: "friends",
|
|
93
|
+
event: "friends.mission_result_prepared",
|
|
94
|
+
message: "prepared mission result envelope",
|
|
95
|
+
meta: { toAgentId: input.toAgentId, requestId: input.requestId, consentPolicy: consent.name },
|
|
96
|
+
});
|
|
97
|
+
return { ok: true, envelope };
|
|
98
|
+
}
|
|
99
|
+
// ── Consumer ──
|
|
100
|
+
const TRUST_RANK = { family: 4, friend: 3, acquaintance: 2, stranger: 1 };
|
|
101
|
+
/** Land B's deliverable under `importedResults[fromAgentId][requestId]`, returning a NEW
|
|
102
|
+
* namespace (never mutates the input). First-party `results` are NOT passed in and stay
|
|
103
|
+
* physically untouched. Idempotent per (agentId, requestId): an entry that already exists
|
|
104
|
+
* is preserved unchanged (never re-stamped). */
|
|
105
|
+
function mergeImportedResult(record, result, fromAgentId, now) {
|
|
106
|
+
const existing = record.importedResults ?? {};
|
|
107
|
+
const forAgent = existing[fromAgentId] ?? {};
|
|
108
|
+
if (forAgent[result.requestId])
|
|
109
|
+
return existing;
|
|
110
|
+
return {
|
|
111
|
+
...existing,
|
|
112
|
+
[fromAgentId]: {
|
|
113
|
+
...forAgent,
|
|
114
|
+
[result.requestId]: {
|
|
115
|
+
requestId: result.requestId,
|
|
116
|
+
summary: result.summary,
|
|
117
|
+
...(result.artifact !== undefined ? { artifact: result.artifact } : {}),
|
|
118
|
+
...(result.outputs !== undefined ? { outputs: result.outputs } : {}),
|
|
119
|
+
provenance: { origin: "imported", assertedBy: { agentId: fromAgentId }, importedAt: now },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Consumer half of the result-return — the non-clobbering merge. Order (PINNED):
|
|
126
|
+
* (1) TOFU verifier + trust cap (both must pass, else `untrusted_source`, write nothing);
|
|
127
|
+
* (2) unknown mission (no findByMissionKey hit) → `no_mission` (NO seeding — a result
|
|
128
|
+
* never creates a mission);
|
|
129
|
+
* (3) the result's `requestId` not present in the record's FIRST-PARTY `delegations`
|
|
130
|
+
* (A never delegated this) → `no_delegation` — correlation honesty;
|
|
131
|
+
* (3b) the matched delegation's recorded `assignee` is not the result's source
|
|
132
|
+
* (`delegation.assignee.agentId !== fromAgentId`) → `assignee_mismatch` — assignee
|
|
133
|
+
* honesty (security-review inc-2 finding 1). FAILS CLOSED on a legacy delegation with
|
|
134
|
+
* no recorded assignee. A mismatch writes NOTHING (not even quarantined);
|
|
135
|
+
* (4) otherwise land under `importedResults[agentId][requestId]` (dedupe on replay),
|
|
136
|
+
* stamped imported + attributed + importedAt, NEVER touching first-party
|
|
137
|
+
* `learnings`/`notes`/`status`/`delegations`/`results`, NEVER recomputing
|
|
138
|
+
* status/participants (non-transitive).
|
|
139
|
+
*/
|
|
140
|
+
async function importMissionResult(missions, input, options = {}) {
|
|
141
|
+
const verifier = options.verifier ?? verifier_1.DEFAULT_AGENT_VERIFIER;
|
|
142
|
+
const minTrust = options.minTrustToAccept ?? "acquaintance";
|
|
143
|
+
// (1) Authentication (caller's seam) AND authorization (trust ladder) must BOTH pass —
|
|
144
|
+
// checked BEFORE correlation, so a stranger never even learns whether the mission exists.
|
|
145
|
+
const authenticated = verifier.verify(input.fromAgentId, input.envelope.proof);
|
|
146
|
+
const trustedEnough = TRUST_RANK[input.trustOfSource] >= TRUST_RANK[minTrust];
|
|
147
|
+
if (!authenticated || !trustedEnough) {
|
|
148
|
+
(0, observability_1.emitNervesEvent)({
|
|
149
|
+
component: "friends",
|
|
150
|
+
event: "friends.mission_result_refused",
|
|
151
|
+
message: "refused mission result from untrusted source",
|
|
152
|
+
meta: { fromAgentId: input.fromAgentId, trustOfSource: input.trustOfSource, authenticated },
|
|
153
|
+
});
|
|
154
|
+
return { ok: false, status: "untrusted_source" };
|
|
155
|
+
}
|
|
156
|
+
// (2) Unknown mission → no_mission. A result NEVER seeds a mission (distinct from
|
|
157
|
+
// mission-share's friend-seed).
|
|
158
|
+
const existing = await missions.findByMissionKey(input.envelope.subject.missionKey);
|
|
159
|
+
if (!existing) {
|
|
160
|
+
return { ok: false, status: "no_mission" };
|
|
161
|
+
}
|
|
162
|
+
// (3) Correlation honesty — the requestId must name a delegation THIS agent issued
|
|
163
|
+
// first-party (A only accepts results for work it actually delegated).
|
|
164
|
+
const delegation = existing.delegations?.[input.envelope.requestId];
|
|
165
|
+
if (!delegation) {
|
|
166
|
+
return { ok: false, status: "no_delegation" };
|
|
167
|
+
}
|
|
168
|
+
// (3b) Assignee honesty (security-review inc-2 finding 1) — the result's SOURCE must be
|
|
169
|
+
// the very agent A delegated TO. The requestId being delegated is NOT enough: a peer C
|
|
170
|
+
// that A trusts (≥ the cap) who learned a requestId A delegated to a DIFFERENT agent B
|
|
171
|
+
// could otherwise inject a forged result. FAILS CLOSED on a legacy/orphan delegation with
|
|
172
|
+
// no recorded assignee (reject, never land). A mismatch writes NOTHING — not even
|
|
173
|
+
// quarantined.
|
|
174
|
+
if (delegation.assignee?.agentId !== input.fromAgentId) {
|
|
175
|
+
(0, observability_1.emitNervesEvent)({
|
|
176
|
+
component: "friends",
|
|
177
|
+
event: "friends.mission_result_refused",
|
|
178
|
+
message: "refused mission result — source is not the delegation's assignee",
|
|
179
|
+
meta: {
|
|
180
|
+
fromAgentId: input.fromAgentId,
|
|
181
|
+
requestId: input.envelope.requestId,
|
|
182
|
+
expectedAssignee: delegation.assignee?.agentId ?? null,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return { ok: false, status: "assignee_mismatch" };
|
|
186
|
+
}
|
|
187
|
+
// (4) Land the deliverable quarantined + attributed, never touching first-party, never
|
|
188
|
+
// recomputing status/participants (non-transitive).
|
|
189
|
+
const now = new Date().toISOString();
|
|
190
|
+
const importedResults = mergeImportedResult(existing, input.envelope.result, input.fromAgentId, now);
|
|
191
|
+
const updated = { ...existing, importedResults, updatedAt: now };
|
|
192
|
+
await missions.put(updated.id, updated);
|
|
193
|
+
(0, observability_1.emitNervesEvent)({
|
|
194
|
+
component: "friends",
|
|
195
|
+
event: "friends.mission_result_imported",
|
|
196
|
+
message: "imported mission result into existing mission",
|
|
197
|
+
meta: { missionId: updated.id, fromAgentId: input.fromAgentId, requestId: input.envelope.requestId },
|
|
198
|
+
});
|
|
199
|
+
return { ok: true, status: "imported", record: updated };
|
|
200
|
+
}
|
|
@@ -126,6 +126,14 @@ class FileMissionStore {
|
|
|
126
126
|
// present iff the record carries one, so a legacy mission with no coordination
|
|
127
127
|
// round-trips unchanged (absent ⇒ unclaimed).
|
|
128
128
|
...(raw.coordination && typeof raw.coordination === "object" ? { coordination: raw.coordination } : {}),
|
|
129
|
+
// The delegation/result namespaces (gap-1 + gap-2, p11 inc2) pass through the same
|
|
130
|
+
// way: present iff the record carries one, so a legacy mission round-trips unchanged
|
|
131
|
+
// (absent ⇒ none). Without these, a FILE-backed store would silently drop them,
|
|
132
|
+
// breaking the result-return correlation (which reads first-party `delegations`).
|
|
133
|
+
...(raw.delegations && typeof raw.delegations === "object" ? { delegations: raw.delegations } : {}),
|
|
134
|
+
...(raw.importedDelegations && typeof raw.importedDelegations === "object" ? { importedDelegations: raw.importedDelegations } : {}),
|
|
135
|
+
...(raw.results && typeof raw.results === "object" ? { results: raw.results } : {}),
|
|
136
|
+
...(raw.importedResults && typeof raw.importedResults === "object" ? { importedResults: raw.importedResults } : {}),
|
|
129
137
|
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(),
|
|
130
138
|
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : new Date().toISOString(),
|
|
131
139
|
schemaVersion: typeof raw.schemaVersion === "number" ? raw.schemaVersion : 1,
|
package/dist/resolver.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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 });
|