@ouro.bot/friends 0.1.0-alpha.6 → 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 +56 -18
- package/changelog.json +6 -0
- package/dist/audit.d.ts +8 -4
- 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/index.d.ts +7 -0
- package/dist/index.js +22 -2
- package/dist/mailbox/index.d.ts +13 -10
- package/dist/mailbox/index.js +1 -1
- package/dist/mcp/dispatch.d.ts +15 -0
- package/dist/mcp/dispatch.js +65 -0
- package/dist/mcp/schemas.js +50 -3
- 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/types.d.ts +53 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -39,12 +39,12 @@ narrow:
|
|
|
39
39
|
- **Bring your own storage.** The library never decides *where* or *how* your data lives — you
|
|
40
40
|
pass a path (or a connection string) and, if you want, your own storage backend.
|
|
41
41
|
|
|
42
|
-
It is built as **
|
|
42
|
+
It is built as **six additive capability layers**. Each is a minimal primitive on the one before
|
|
43
43
|
it; none is a workflow engine; removing any layer leaves the ones beneath it unchanged.
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
47
|
-
## What it does — the
|
|
47
|
+
## What it does — the six capabilities
|
|
48
48
|
|
|
49
49
|
### 1. Identity + the cross-agent moat
|
|
50
50
|
|
|
@@ -158,9 +158,29 @@ an assignee onto anyone (the receiver's own `accept` confirms it — non-transit
|
|
|
158
158
|
advisory metadata rather than a granted capability, and conflicts resolve last-writer-wins by
|
|
159
159
|
timestamp. No queue, no DAG, no workflow DSL.
|
|
160
160
|
|
|
161
|
+
### 6. Own-fleet delegation — link your own agents, then delegate work end-to-end
|
|
162
|
+
|
|
163
|
+
The control-plane thread: the owner can **link two of their own agents** and have one **delegate a
|
|
164
|
+
task** to the other, get it done, and **receive the result back** — over the same consent-gated,
|
|
165
|
+
trust-capped, first-party-inviolable machinery.
|
|
166
|
+
|
|
167
|
+
`connect_to` is a first-class **management-sense** capability — the owner introduces a peer into an
|
|
168
|
+
agent's fleet, but **only from a trusted control surface**: a `local` (owner-only) sense commits
|
|
169
|
+
inline; an `open` sense never does (it downgrades to a confirm-prompt); a `closed` sense is gated by
|
|
170
|
+
a **signed account-roster membership check** (same-account `family` via `same_account`), never a
|
|
171
|
+
blanket allow. The link is recorded as an `action:"connect"` control-plane audit. A bare name with
|
|
172
|
+
no resolvable handle is answered honestly (`needs_handle_or_introduction`) rather than invented.
|
|
173
|
+
|
|
174
|
+
Delegation then rides the layers already here: a **task-spec** travels on a coordination `request`
|
|
175
|
+
(correlated by a minted `requestId`), and the **result-return** (`send_result` / `import_result`)
|
|
176
|
+
carries the actual produced **deliverable** back — attributed to the doer, correlated to the original
|
|
177
|
+
delegation, landing **quarantined** in a separate namespace on import. A result for work you never
|
|
178
|
+
delegated is rejected (`no_delegation`); a stranger's result is refused at the trust cap; first-party
|
|
179
|
+
knowledge is never touched. It is a deliverable channel, **not a remote-exec grant**.
|
|
180
|
+
|
|
161
181
|
> The stack, in one line: agents **recognize** each other (1), **reach** each other (2),
|
|
162
|
-
> **remember** shared work (3), **assess** each other (4),
|
|
163
|
-
> each a minimal primitive on the last.
|
|
182
|
+
> **remember** shared work (3), **assess** each other (4), **negotiate** who does what (5), and the
|
|
183
|
+
> owner **links their own agents to delegate end-to-end** (6) — each a minimal primitive on the last.
|
|
164
184
|
|
|
165
185
|
---
|
|
166
186
|
|
|
@@ -252,7 +272,7 @@ You can also `npm pack` then
|
|
|
252
272
|
Content-Length and newline-delimited JSON — auto-detected from the first message, so it works with
|
|
253
273
|
harnesses on either convention.
|
|
254
274
|
|
|
255
|
-
#### The tool surface —
|
|
275
|
+
#### The tool surface — 32 tools
|
|
256
276
|
|
|
257
277
|
A thin 1:1 mapping over the library (no domain logic in the server):
|
|
258
278
|
|
|
@@ -269,6 +289,7 @@ A thin 1:1 mapping over the library (no domain logic in the server):
|
|
|
269
289
|
| `link_identity` | Link an external identity, merging any orphan record that holds it. |
|
|
270
290
|
| `unlink_identity` | Remove an external identity from a friend. |
|
|
271
291
|
| `onboard_agent` | Upsert an agent-peer record from resolved coordinates (no HTTP fetch). |
|
|
292
|
+
| `connect_to` | **Control plane** — the owner links one of their OWN agents into the fleet (introduce a peer by agentId/did/name at a trust level, default `family`). Authority-gated to a management sense (`local` commits; an `open` sense downgrades to a confirm-prompt; `closed` is gated by a roster/membership check); a bare name with no resolvable handle/DID returns `needs_handle_or_introduction` (never fabricates). Writes an `action:"connect"` control-plane audit. |
|
|
272
293
|
| `whoami` | Resolve the machine owner and which record represents the self. |
|
|
273
294
|
| `channel_caps` | Return a channel's capabilities. |
|
|
274
295
|
| `resolve_room` | Resolve a room (a group's external id) into its members, each with trust context and `knownVia`. |
|
|
@@ -287,13 +308,17 @@ A thin 1:1 mapping over the library (no domain logic in the server):
|
|
|
287
308
|
| `coordinate` | **Producer** — prepare a coordination message (`request` / `offer` / `accept` / `decline` / `handoff`) that negotiates **who** does a mission. |
|
|
288
309
|
| `import_coordination` | **Consumer** — import a coordination message (appends to the mission's coordination log; only a self-`accept` sets the assignee; a `handoff` never forces one). |
|
|
289
310
|
| `get_coordination` | Read a mission's coordination state — its current assignee + the append-only negotiation log. |
|
|
311
|
+
| `send_result` | **Producer** — return B's DELIVERABLE for a delegation, attributed to B + correlated to A's task-spec by `requestId`, named by the mission's `missionKey` (consent-gated via the `coordinate` scope). |
|
|
312
|
+
| `import_result` | **Consumer** — import a result-return; lands B's deliverable quarantined + attributed under `importedResults`, trust-capped; a result whose `requestId` matches no prior first-party delegation is rejected (`no_delegation`); never recomputes status. |
|
|
290
313
|
|
|
291
314
|
The consent tools (`share_profile` / `import_profile` / `grant_share` / `revoke_share` /
|
|
292
|
-
`list_shares`) need a **grant store**; the mission and
|
|
293
|
-
The `friends-mcp` binary wires both
|
|
294
|
-
|
|
295
|
-
`
|
|
296
|
-
|
|
315
|
+
`list_shares`) need a **grant store**; the mission, coordination, and result-return tools
|
|
316
|
+
(`send_result` consumes both) need a **mission store**. The `friends-mcp` binary wires both
|
|
317
|
+
automatically at sibling `_grants/` and `_missions/` directories under `--dir` (plus the
|
|
318
|
+
`_audit/` control-plane log `connect_to` / `set_trust` write through). An embedded server gets
|
|
319
|
+
them by passing `grants` / `missions` / `audit` to `createFriendsMcpServer`. Without the
|
|
320
|
+
relevant store, those tools report `{ ok: false, status: "unsupported" }` and everything else
|
|
321
|
+
works store-only.
|
|
297
322
|
|
|
298
323
|
The server module is consumed in code from the `@ouro.bot/friends/mcp` subpath, exporting
|
|
299
324
|
`createFriendsMcpServer`, `getToolSchemas`, and `runMain`.
|
|
@@ -470,6 +495,10 @@ npm run example:cross-agent-standing # earned standing: first-party-only,
|
|
|
470
495
|
# inert on trust
|
|
471
496
|
npm run example:cross-agent-coordination # the five coordination verbs end-to-end: assignment,
|
|
472
497
|
# non-transitive handoff, last-writer-wins, seeding gate
|
|
498
|
+
npm run example:cross-agent-delegation # own-fleet delegation: two of the owner's agents,
|
|
499
|
+
# same-account family (signed roster), connect_to link,
|
|
500
|
+
# A delegates → B performs → B returns the result → A
|
|
501
|
+
# imports it — every invariant hard-asserted
|
|
473
502
|
```
|
|
474
503
|
|
|
475
504
|
Read them as the honest spec of what the package promises: if a guarantee weren't real, the
|
|
@@ -501,7 +530,7 @@ setNervesEmitter((event) => {
|
|
|
501
530
|
|
|
502
531
|
## Design notes & status
|
|
503
532
|
|
|
504
|
-
- **Store-only, transport-agnostic, additive.** The
|
|
533
|
+
- **Store-only, transport-agnostic, additive.** The six layers were each built as a minimal
|
|
505
534
|
primitive that does not modify the layers beneath it. The cross-agent envelopes are plain data; the
|
|
506
535
|
wire is always the caller's job (the `./mailbox` git-mailbox is one optional, host-driven fallback). A
|
|
507
536
|
CI-enforced dependency rule keeps the core from ever importing the transport.
|
|
@@ -510,8 +539,9 @@ setNervesEmitter((event) => {
|
|
|
510
539
|
clean.
|
|
511
540
|
- **Not a workflow engine.** Each layer deliberately refuses the larger machine it brushes against:
|
|
512
541
|
the mission ledger is not a knowledge base, standing is not a reputation engine, coordination is not
|
|
513
|
-
a scheduler
|
|
514
|
-
|
|
542
|
+
a scheduler, and the delegation channel is a deliverable return — not a remote-exec grant. The
|
|
543
|
+
discipline is the point.
|
|
544
|
+
- **Alpha.** The surface is feature-complete across the six layers but pre-1.0 — expect additive
|
|
515
545
|
changes, and pin a version. Feedback and issues are welcome.
|
|
516
546
|
|
|
517
547
|
### Public API
|
|
@@ -538,10 +568,17 @@ setNervesEmitter((event) => {
|
|
|
538
568
|
`ImportCoordinationStatus`, `SetFriendTrustContext`, `AuditSink`, `ControlPlaneAuditRecord`,
|
|
539
569
|
`ResolvedAgentIdentity`, `RosterStore`, `AccountRoster`, `RosterPin`, `RosterVerifier`,
|
|
540
570
|
`AccountMembershipDecision`, `AccountMembershipResult`, `EvaluateAccountMembershipInput`,
|
|
541
|
-
`FriendResolverRosterContext
|
|
542
|
-
|
|
543
|
-
`
|
|
544
|
-
|
|
571
|
+
`FriendResolverRosterContext`, `ConnectPeer`, `ConnectAgentsInput`, `ConnectAgentsDeps`,
|
|
572
|
+
`ConnectResult`, `ConnectStatus`, `AuthorizeConnectInput`, `ConnectAuthorization`, `MissionTaskSpec`,
|
|
573
|
+
`MissionResult`, `MissionResultEnvelope`, `PrepareMissionResultInput`, `PrepareMissionResultResult`,
|
|
574
|
+
`PrepareMissionResultStatus`, `ImportMissionResultInput`, `ImportMissionResultOptions`,
|
|
575
|
+
`ImportMissionResultResult`, `ImportMissionResultStatus`. (`TrustBasis` additively gains the
|
|
576
|
+
`"same_account"` member — the basis for family granted via the signed account roster — and `AgentMeta`
|
|
577
|
+
additively gains an optional `identity { did, pinnedKey?, handle?, pinnedAt? }` durable-identity home;
|
|
578
|
+
both are schemaVersion-1 additive, and a legacy `a2a.did` migrates-on-read into `identity.did`.
|
|
579
|
+
`MissionRecord` additively gains the own-fleet delegation namespaces `delegations` / `importedDelegations`
|
|
580
|
+
(gap-1) and `results` / `importedResults` (gap-2), and `ControlPlaneAuditRecord.action` widens additively
|
|
581
|
+
to `"set_trust" | "connect"`.)
|
|
545
582
|
|
|
546
583
|
**Values:** `TRUSTED_LEVELS`, `IDENTITY_SCOPES`, `isTrustedLevel`, `isIdentityProvider`,
|
|
547
584
|
`isIntegration`, `isShareScope`, `isCoordinationIntent`, `FileFriendStore`, `FileGrantStore`,
|
|
@@ -557,7 +594,8 @@ additive, and a legacy `a2a.did` migrates-on-read into `identity.did`.)
|
|
|
557
594
|
`listShares`, `isGrantEffective`, `setNervesEmitter`, `emitNervesEvent`, `resolveAgentIdentity`,
|
|
558
595
|
`withMigratedIdentity`, `findFriendByDid`, `MemoryAuditSink`, `FileAuditSink`, `auditPathFor`,
|
|
559
596
|
`FileRosterStore`, `rostersDirFor`, `MemoryRosterStore`, `identityRosterVerifier`,
|
|
560
|
-
`DEFAULT_ROSTER_VERIFIER`, `evaluateAccountMembership
|
|
597
|
+
`DEFAULT_ROSTER_VERIFIER`, `evaluateAccountMembership`, `connectAgents`, `authorizeConnect`,
|
|
598
|
+
`prepareMissionResult`, `importMissionResult`.
|
|
561
599
|
|
|
562
600
|
**From `@ouro.bot/friends/mcp`:** `createFriendsMcpServer`, `getToolSchemas`, `runMain` (plus the
|
|
563
601
|
`McpToolSchema`, `FriendsMcpServer`, and `RunMainIo` types).
|
package/changelog.json
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"versions": [
|
|
3
|
+
{
|
|
4
|
+
"version": "0.1.0-alpha.7",
|
|
5
|
+
"changes": [
|
|
6
|
+
"p11 increment 2 — own-fleet delegation: connect_to control plane + the LOCAL delegate->perform->return proof (task-spec + result-return). Brick 8 (greenfield): connectAgents + a NEW management-sense authority predicate (authorizeConnect) — the owner links one of their own agents into the fleet, gated to a management sense (local commits inline; an open sense downgrades to a confirm-prompt; closed is gated by a signed account-roster membership check, NEVER a blanket allow), with disambiguation honesty (a bare name with no resolvable handle/DID returns needs_handle_or_introduction, never fabricated). connect_to is a library fn + an MCP tool, writes an action:\"connect\" control-plane audit (ControlPlaneAuditRecord.action widens additively to \"set_trust\" | \"connect\"), and the MCP boundary maps the stdio origin to a local management senseType (a network transport passes its real senseType + a pre-computed roster membership). gap-1 (the task-spec): CoordinationEnvelope gains an optional task? (MissionTaskSpec with a minted requestId) on the delegation request — recorded first-party under MissionRecord.delegations[requestId], imported quarantined under importedDelegations[agentId][requestId] (trust-capped, non-transitive, first-party-inviolable). gap-2 (the result-return): a NEW prepareMissionResult/importMissionResult envelope (twin of mission-share, NOT a reuse) carries B's actual DELIVERABLE back to A — attributed to B, correlated by missionKey + requestId, consent-gated via the existing \"coordinate\" scope (no new ShareScope); on import it lands quarantined + attributed under importedResults, rejects a result whose requestId matches no prior first-party delegation (no_delegation — correlation honesty), rejects a result whose source is not the agent the work was delegated TO (assignee_mismatch — assignee honesty; the delegation now persists its assignee and the import enforces source === assignee, failing closed on a legacy assignee-less record), never seeds a mission (no_mission), is trust-capped (untrusted_source before correlation), non-transitive, idempotent on replay. The mailbox kind union widens additively to carry kind:\"mission_result\". 3 new MCP tools (connect_to, send_result, import_result) — 29 -> 32; the coordinate tool now threads the task-spec. FileMissionStore.normalize round-trips the four new additive MissionRecord namespaces (delegations/importedDelegations/results/importedResults). A LOCAL hermetic proof (examples/cross-agent-delegation.ts): two own-fleet agents on separate stores, recognized as same-account family via a signed roster (real ed25519) on both sides, linked by the owner via connect_to, then A delegates a task -> B performs it -> B returns the deliverable -> A imports it — every invariant hard-asserted (missionKey-not-UUID, first-party-inviolable, non-transitive, trust cap, orphan-reject, trusted-non-assignee-reject, replay-inert, no third-party leak); exit 0, with a negative control that bites. Security review (inc-2): finding 1 (MEDIUM, cross-delegation result injection) FIXED as above — a trusted peer that is not the assignee can no longer inject a forged result for a requestId delegated to a different agent; findings 2-3 (LOW, dormant networking pre-conditions) DOCUMENTED at the connect_to authority gate + introduce-default site (before any non-local/networked controlContext is wired, the connect commit must add target-side roster verification and validate the caller-supplied trustLevel against the authority decision); findings 4-5 (existence oracle, vestigial envelope.fromAgentId) accepted by-design (finding 5 noted at its site). Core stays transport-free (the core⊥a2a-client + mailbox⊥mcp CI rules unmodified); 100% coverage maintained."
|
|
7
|
+
]
|
|
8
|
+
},
|
|
3
9
|
{
|
|
4
10
|
"version": "0.1.0-alpha.6",
|
|
5
11
|
"changes": [
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { TrustBasis } from "./trust-explanation";
|
|
2
2
|
import type { TrustLevel } from "./types";
|
|
3
|
-
/** One append-only control-plane audit record. Captures a single
|
|
4
|
-
* WHO (`actor`), to WHOM (`targetId` / optional `targetDid`), the
|
|
5
|
-
* `basis` it was granted on, the `originSense` it came through, and WHEN
|
|
3
|
+
/** One append-only control-plane audit record. Captures a single control-plane
|
|
4
|
+
* mutation: WHO (`actor`), to WHOM (`targetId` / optional `targetDid`), the resulting
|
|
5
|
+
* `level`, the `basis` it was granted on, the `originSense` it came through, and WHEN
|
|
6
|
+
* (`ts`). The `action` discriminates the mutation kind: `"set_trust"` (a trust-level
|
|
7
|
+
* change — the `setFriendTrust` mutation + the `onboard_agent` trust seat) or
|
|
8
|
+
* `"connect"` (an owner linking one of their own agents into the fleet via `connect_to`
|
|
9
|
+
* — p11 inc2; additive, the JSONL append is value-agnostic so only the type widened). */
|
|
6
10
|
export interface ControlPlaneAuditRecord {
|
|
7
|
-
action: "set_trust";
|
|
11
|
+
action: "set_trust" | "connect";
|
|
8
12
|
targetId: string;
|
|
9
13
|
targetDid?: string;
|
|
10
14
|
level: TrustLevel;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SenseType } from "./types";
|
|
2
|
+
import type { AccountMembershipResult } from "./account-roster";
|
|
3
|
+
/** Input to the management-sense authority predicate. `senseType` is the sense the
|
|
4
|
+
* `connect_to` arrived through (the MCP boundary supplies `local` for the owner-only
|
|
5
|
+
* stdio path; a network transport passes its real senseType). `membership` is the
|
|
6
|
+
* PRE-COMPUTED account-roster decision for the `closed` branch — the caller runs
|
|
7
|
+
* evaluateAccountMembership and passes its result; absent ⇒ no membership proven. */
|
|
8
|
+
export interface AuthorizeConnectInput {
|
|
9
|
+
senseType: SenseType;
|
|
10
|
+
membership?: AccountMembershipResult;
|
|
11
|
+
}
|
|
12
|
+
/** The authority decision (PINNED discriminated union). `commit` ⇒ the `connect_to`
|
|
13
|
+
* may link inline + audit. `downgrade` ⇒ it must NOT commit; the caller raises a
|
|
14
|
+
* confirm-prompt instead. The `reason` names exactly why the inline commit was
|
|
15
|
+
* withheld, so the prompt copy + the audit/no-audit branch are unambiguous. */
|
|
16
|
+
export type ConnectAuthorization = {
|
|
17
|
+
decision: "commit";
|
|
18
|
+
} | {
|
|
19
|
+
decision: "downgrade";
|
|
20
|
+
reason: "open_sense_needs_confirmation" | "closed_sense_not_member" | "internal_sense_not_management";
|
|
21
|
+
};
|
|
22
|
+
/** Decide whether a `connect_to` may COMMIT inline from the given management sense.
|
|
23
|
+
*
|
|
24
|
+
* `local` is the owner-only management sense (stdio/CLI — the user who launched the
|
|
25
|
+
* process). `closed` is org-gated but NOT inherently the owner, so it commits ONLY
|
|
26
|
+
* when the peer is proven same-account family via the signed roster (the caller's
|
|
27
|
+
* pre-computed `membership`); any other decision — or no membership at all — downgrades
|
|
28
|
+
* (NEVER a blanket allow on `closed`). An `open` sense (anyone can reach it) never
|
|
29
|
+
* commits inline regardless of membership. `internal` is the agent's inner dialog —
|
|
30
|
+
* not a management surface at all. Every non-commit is a structured downgrade RETURN.
|
|
31
|
+
*
|
|
32
|
+
* ┌─ PRE-CONDITION before any non-local / networked `controlContext` is ever wired ──────┐
|
|
33
|
+
* │ (security review inc-2 findings 2-3): this gate authenticates the CALLER's │
|
|
34
|
+
* │ sense/membership but places NO constraint on the TARGET, and connectAgents defaults │
|
|
35
|
+
* │ the introduce trust to `family`. Both are correct + safe ONLY because the path is │
|
|
36
|
+
* │ owner-only stdio today (every wire supplies `senseType: "local"`; no wire constructs │
|
|
37
|
+
* │ a non-`local` controlContext). The `connect` commit MUST add target-side roster │
|
|
38
|
+
* │ verification (the target did must ALSO be roster-checked, not just TOFU-upserted) │
|
|
39
|
+
* │ AND validate the caller-supplied `trustLevel` against the authority decision BEFORE │
|
|
40
|
+
* │ any non-`local`/networked controlContext is wired. The current `family` default + │
|
|
41
|
+
* │ unconstrained target are safe only for the owner-only-stdio path. │
|
|
42
|
+
* └──────────────────────────────────────────────────────────────────────────────────────┘ */
|
|
43
|
+
export declare function authorizeConnect(input: AuthorizeConnectInput): ConnectAuthorization;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.authorizeConnect = authorizeConnect;
|
|
4
|
+
// authorizeConnect — the management-sense authority predicate (brick 8, greenfield).
|
|
5
|
+
//
|
|
6
|
+
// The control-plane gate for `connect_to`: a PURE function that decides whether the
|
|
7
|
+
// owner introducing one of their own agents into the calling agent's fleet may
|
|
8
|
+
// COMMIT inline, or must DOWNGRADE to a confirm-prompt. Brick 8 is genuinely
|
|
9
|
+
// greenfield in friends — there is NO trust-gate.ts / handleStranger to patch (those
|
|
10
|
+
// live in ouroboros); this predicate is the management-sense authority from scratch.
|
|
11
|
+
//
|
|
12
|
+
// CORE-CLEAN by construction: the `closed`-branch membership decision arrives
|
|
13
|
+
// PRE-COMPUTED via the injected seam. The CALLER runs evaluateAccountMembership
|
|
14
|
+
// against the increment-1 roster surface and passes the `AccountMembershipResult` in;
|
|
15
|
+
// this module never calls it, so it imports NO a2a-client / libsodium (the lint
|
|
16
|
+
// enforces the direction). It is a pure value→value map with one observability emit.
|
|
17
|
+
//
|
|
18
|
+
// The contract (every branch — see connect-authority.test.ts):
|
|
19
|
+
// - local → commit (owner-only stdio/CLI — the management sense)
|
|
20
|
+
// - closed + membership family_same_account → commit (org-gated AND a roster-verified same-account peer)
|
|
21
|
+
// - closed + any other / absent membership → downgrade closed_sense_not_member (NEVER a blanket allow)
|
|
22
|
+
// - open (a2a/mail/bluebubbles) → downgrade open_sense_needs_confirmation (regardless of membership)
|
|
23
|
+
// - internal → downgrade internal_sense_not_management
|
|
24
|
+
// A downgrade is ALWAYS a structured RETURN value, never a throw — the caller turns it
|
|
25
|
+
// into a confirm-prompt and writes no audit / makes no link.
|
|
26
|
+
const observability_1 = require("./observability");
|
|
27
|
+
/** Decide whether a `connect_to` may COMMIT inline from the given management sense.
|
|
28
|
+
*
|
|
29
|
+
* `local` is the owner-only management sense (stdio/CLI — the user who launched the
|
|
30
|
+
* process). `closed` is org-gated but NOT inherently the owner, so it commits ONLY
|
|
31
|
+
* when the peer is proven same-account family via the signed roster (the caller's
|
|
32
|
+
* pre-computed `membership`); any other decision — or no membership at all — downgrades
|
|
33
|
+
* (NEVER a blanket allow on `closed`). An `open` sense (anyone can reach it) never
|
|
34
|
+
* commits inline regardless of membership. `internal` is the agent's inner dialog —
|
|
35
|
+
* not a management surface at all. Every non-commit is a structured downgrade RETURN.
|
|
36
|
+
*
|
|
37
|
+
* ┌─ PRE-CONDITION before any non-local / networked `controlContext` is ever wired ──────┐
|
|
38
|
+
* │ (security review inc-2 findings 2-3): this gate authenticates the CALLER's │
|
|
39
|
+
* │ sense/membership but places NO constraint on the TARGET, and connectAgents defaults │
|
|
40
|
+
* │ the introduce trust to `family`. Both are correct + safe ONLY because the path is │
|
|
41
|
+
* │ owner-only stdio today (every wire supplies `senseType: "local"`; no wire constructs │
|
|
42
|
+
* │ a non-`local` controlContext). The `connect` commit MUST add target-side roster │
|
|
43
|
+
* │ verification (the target did must ALSO be roster-checked, not just TOFU-upserted) │
|
|
44
|
+
* │ AND validate the caller-supplied `trustLevel` against the authority decision BEFORE │
|
|
45
|
+
* │ any non-`local`/networked controlContext is wired. The current `family` default + │
|
|
46
|
+
* │ unconstrained target are safe only for the owner-only-stdio path. │
|
|
47
|
+
* └──────────────────────────────────────────────────────────────────────────────────────┘ */
|
|
48
|
+
function authorizeConnect(input) {
|
|
49
|
+
const result = decide(input);
|
|
50
|
+
(0, observability_1.emitNervesEvent)({
|
|
51
|
+
component: "friends",
|
|
52
|
+
event: "friends.connect_authorized",
|
|
53
|
+
message: "evaluated connect_to management-sense authority",
|
|
54
|
+
meta: {
|
|
55
|
+
senseType: input.senseType,
|
|
56
|
+
decision: result.decision,
|
|
57
|
+
...(result.decision === "downgrade" ? { reason: result.reason } : {}),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
/** The pure decision, factored out so the single observability emit wraps every
|
|
63
|
+
* branch (the one return path keeps the emit DRY and fully covered). */
|
|
64
|
+
function decide(input) {
|
|
65
|
+
switch (input.senseType) {
|
|
66
|
+
case "local":
|
|
67
|
+
// Owner-only management sense — the user who launched the process. Commit.
|
|
68
|
+
return { decision: "commit" };
|
|
69
|
+
case "closed":
|
|
70
|
+
// Org-gated but not inherently the owner: commit ONLY for a roster-verified
|
|
71
|
+
// same-account peer. Anything else (incl. absent membership) downgrades — there
|
|
72
|
+
// is NO blanket allow on `closed`.
|
|
73
|
+
return input.membership?.decision === "family_same_account"
|
|
74
|
+
? { decision: "commit" }
|
|
75
|
+
: { decision: "downgrade", reason: "closed_sense_not_member" };
|
|
76
|
+
case "open":
|
|
77
|
+
// Anyone can reach an open sense — it can NEVER commit a control-plane link
|
|
78
|
+
// inline, regardless of any membership claim.
|
|
79
|
+
return { decision: "downgrade", reason: "open_sense_needs_confirmation" };
|
|
80
|
+
case "internal":
|
|
81
|
+
// The agent's inner dialog — not a management surface.
|
|
82
|
+
return { decision: "downgrade", reason: "internal_sense_not_management" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { FriendStore } from "./store";
|
|
2
|
+
import type { AuditSink } from "./audit";
|
|
3
|
+
import type { ConnectAuthorization } from "./connect-authority";
|
|
4
|
+
import type { AccountMembershipResult } from "./account-roster";
|
|
5
|
+
import type { FriendRecord, SenseType, TrustLevel } from "./types";
|
|
6
|
+
/** The named peer to link, by any of the three handles the owner might supply. An
|
|
7
|
+
* `agentId` or `did` is a resolvable handle on its own; a bare `name` resolves ONLY by
|
|
8
|
+
* hitting an existing record (§3.3 — never fabricated). */
|
|
9
|
+
export interface ConnectPeer {
|
|
10
|
+
agentId?: string;
|
|
11
|
+
did?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ConnectAgentsInput {
|
|
15
|
+
peer: ConnectPeer;
|
|
16
|
+
/** The management sense the connect_to arrived through (the MCP boundary supplies
|
|
17
|
+
* `local` for the owner-only stdio path; a network transport its real senseType). */
|
|
18
|
+
senseType: SenseType;
|
|
19
|
+
/** The PRE-COMPUTED account-roster membership for the `closed` branch (the caller
|
|
20
|
+
* runs evaluateAccountMembership). Absent ⇒ no membership proven. */
|
|
21
|
+
membership?: AccountMembershipResult;
|
|
22
|
+
/** The trust to link the peer at. Own-fleet linked agents default to `family`. */
|
|
23
|
+
trustLevel?: TrustLevel;
|
|
24
|
+
}
|
|
25
|
+
export interface ConnectAgentsDeps {
|
|
26
|
+
/** The control-plane audit sink. Absent ⇒ the link is made but no audit is appended
|
|
27
|
+
* (no-sink no-op, mirroring setFriendTrust / the onboard_agent seat). */
|
|
28
|
+
audit?: AuditSink;
|
|
29
|
+
/** WHO performed the connect (e.g. "owner:stdio"). */
|
|
30
|
+
actor: string;
|
|
31
|
+
/** WHENCE — the origin sense string stamped on the audit (e.g. "stdio"). */
|
|
32
|
+
originSense: string;
|
|
33
|
+
}
|
|
34
|
+
export type ConnectStatus = "connected" | "needs_handle_or_introduction" | "downgraded";
|
|
35
|
+
export type ConnectResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
status: "connected";
|
|
38
|
+
record: FriendRecord;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
status: "needs_handle_or_introduction";
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
status: "downgraded";
|
|
45
|
+
downgrade: ConnectAuthorization;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Link a named peer into the calling agent's store from the owner's vantage. Composes:
|
|
49
|
+
* authority gate (authorizeConnect) → disambiguation (resolvePeer, never fabricates) →
|
|
50
|
+
* the introduce effect (upsertAgentPeer at the linked trust, default family) → the
|
|
51
|
+
* action:"connect" control-plane audit (mirroring the onboard_agent seat). A downgrade
|
|
52
|
+
* authorization makes NO link and writes NO audit (mirrors setFriendTrust's no-audit
|
|
53
|
+
* early return); an unresolvable bare name returns needs_handle_or_introduction.
|
|
54
|
+
*/
|
|
55
|
+
export declare function connectAgents(store: FriendStore, input: ConnectAgentsInput, deps: ConnectAgentsDeps): Promise<ConnectResult>;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.connectAgents = connectAgents;
|
|
4
|
+
// connectAgents — the `connect_to` library fn (brick 8, greenfield).
|
|
5
|
+
//
|
|
6
|
+
// The owner's first-class capability to link one of their OWN agents into the calling
|
|
7
|
+
// agent's fleet (introduce a target peer INTO this store). It is the single entry point
|
|
8
|
+
// for "go connect to @peer" (spec §3.2), gated to a management sense (§3.1) and audited
|
|
9
|
+
// (§3.4). Brick 8 is greenfield in friends — there is NO trust-gate.ts to patch (that
|
|
10
|
+
// lives in ouroboros); this composes the new authority predicate (connect-authority.ts)
|
|
11
|
+
// with the increment-1 roster-pre-computed membership + the increment-1 control-plane
|
|
12
|
+
// audit (AuditSink / action:"connect").
|
|
13
|
+
//
|
|
14
|
+
// MENTAL MODEL (resolves "A↔B on separate stores"): A and B live in SEPARATE
|
|
15
|
+
// stores/processes (own-fleet on two machines), so a single connectAgents call operates
|
|
16
|
+
// on ONE store — it upserts the named peer as an agent-peer at the linked trust + audits
|
|
17
|
+
// action:"connect". The bidirectional A↔B link is the owner running the introduction on
|
|
18
|
+
// EACH side (the LOCAL proof drives both). The fn does NOT reach across stores.
|
|
19
|
+
//
|
|
20
|
+
// CORE-CLEAN: the `membership` arrives PRE-COMPUTED via `input` (the caller runs
|
|
21
|
+
// evaluateAccountMembership against the increment-1 roster surface), so this module
|
|
22
|
+
// imports NO a2a-client / libsodium. The lint enforces the direction.
|
|
23
|
+
//
|
|
24
|
+
// DISAMBIGUATION HONESTY (spec §3.3): given a bare name with no resolvable handle/DID
|
|
25
|
+
// and no record hit, connectAgents returns a structured "need a handle or an
|
|
26
|
+
// introduction" result rather than FABRICATING a target. An agentId or a did IS an
|
|
27
|
+
// owner-supplied/resolved handle; a bare name resolves ONLY by matching an existing
|
|
28
|
+
// record (the BUILT FileFriendStore name-fallback scan) — never invented.
|
|
29
|
+
const observability_1 = require("./observability");
|
|
30
|
+
const connect_authority_1 = require("./connect-authority");
|
|
31
|
+
const agent_peer_1 = require("./agent-peer");
|
|
32
|
+
const friend_lookup_1 = require("./friend-lookup");
|
|
33
|
+
const identity_1 = require("./identity");
|
|
34
|
+
/** Read an agent record's join-key agentId — the durable `a2a.agentId`, falling back
|
|
35
|
+
* to the `a2a-agent` externalId. Returns undefined when the record names no agentId
|
|
36
|
+
* (e.g. a human record), so it can never be linked as an agent target. */
|
|
37
|
+
function agentIdOf(record) {
|
|
38
|
+
return record.agentMeta?.a2a?.agentId ?? record.externalIds.find((e) => e.provider === "a2a-agent")?.externalId;
|
|
39
|
+
}
|
|
40
|
+
/** Resolve the peer to a usable handle WITHOUT fabricating one (§3.3):
|
|
41
|
+
* - `agentId` present → it IS the handle; enrich from any existing record by it.
|
|
42
|
+
* - else `did` present → resolve an existing record by did (the did is the handle);
|
|
43
|
+
* a did with no matching record does NOT resolve (needs a handle/introduction).
|
|
44
|
+
* - else `name` present → match an existing record by case-insensitive name (the
|
|
45
|
+
* FileFriendStore name-fallback semantics, made store-agnostic via listAll); a bare
|
|
46
|
+
* name with no hit does NOT resolve.
|
|
47
|
+
* - else (nothing) → does not resolve.
|
|
48
|
+
* Returns null when the peer cannot be resolved to a real handle. */
|
|
49
|
+
async function resolvePeer(store, peer) {
|
|
50
|
+
if (peer.agentId) {
|
|
51
|
+
const existing = await store.findByExternalId("a2a-agent", peer.agentId);
|
|
52
|
+
return { agentId: peer.agentId, existing: existing ?? undefined, name: peer.name ?? existing?.name ?? peer.agentId };
|
|
53
|
+
}
|
|
54
|
+
if (peer.did) {
|
|
55
|
+
const existing = await (0, friend_lookup_1.findFriendByDid)(store, peer.did);
|
|
56
|
+
if (!existing)
|
|
57
|
+
return null;
|
|
58
|
+
const agentId = agentIdOf(existing);
|
|
59
|
+
if (!agentId)
|
|
60
|
+
return null;
|
|
61
|
+
return { agentId, existing, name: peer.name ?? existing.name };
|
|
62
|
+
}
|
|
63
|
+
if (peer.name) {
|
|
64
|
+
const existing = await findByName(store, peer.name);
|
|
65
|
+
if (!existing)
|
|
66
|
+
return null;
|
|
67
|
+
const agentId = agentIdOf(existing);
|
|
68
|
+
if (!agentId)
|
|
69
|
+
return null;
|
|
70
|
+
return { agentId, existing, name: peer.name };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** Case-insensitive name match over the store's records (the §3.3 name-fallback scan,
|
|
75
|
+
* made store-agnostic by using listAll). Returns the first match, or null. A store with
|
|
76
|
+
* no listAll yields null (best-effort, never a throw). */
|
|
77
|
+
async function findByName(store, name) {
|
|
78
|
+
if (typeof store.listAll !== "function")
|
|
79
|
+
return null;
|
|
80
|
+
const all = await store.listAll();
|
|
81
|
+
const lower = name.toLowerCase();
|
|
82
|
+
return all.find((f) => f.name.toLowerCase() === lower) ?? null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Link a named peer into the calling agent's store from the owner's vantage. Composes:
|
|
86
|
+
* authority gate (authorizeConnect) → disambiguation (resolvePeer, never fabricates) →
|
|
87
|
+
* the introduce effect (upsertAgentPeer at the linked trust, default family) → the
|
|
88
|
+
* action:"connect" control-plane audit (mirroring the onboard_agent seat). A downgrade
|
|
89
|
+
* authorization makes NO link and writes NO audit (mirrors setFriendTrust's no-audit
|
|
90
|
+
* early return); an unresolvable bare name returns needs_handle_or_introduction.
|
|
91
|
+
*/
|
|
92
|
+
async function connectAgents(store, input, deps) {
|
|
93
|
+
// 1) Authority FIRST. A downgrade never commits inline (no link, no audit).
|
|
94
|
+
const authorization = (0, connect_authority_1.authorizeConnect)({ senseType: input.senseType, membership: input.membership });
|
|
95
|
+
if (authorization.decision === "downgrade") {
|
|
96
|
+
(0, observability_1.emitNervesEvent)({
|
|
97
|
+
component: "friends",
|
|
98
|
+
event: "friends.connect_downgraded",
|
|
99
|
+
message: "connect_to downgraded to confirm-prompt (not committed inline)",
|
|
100
|
+
meta: { senseType: input.senseType, reason: authorization.reason },
|
|
101
|
+
});
|
|
102
|
+
return { ok: false, status: "downgraded", downgrade: authorization };
|
|
103
|
+
}
|
|
104
|
+
// 2) Disambiguation honesty — resolve the peer to a real handle, never fabricate.
|
|
105
|
+
const resolved = await resolvePeer(store, input.peer);
|
|
106
|
+
if (!resolved) {
|
|
107
|
+
(0, observability_1.emitNervesEvent)({
|
|
108
|
+
component: "friends",
|
|
109
|
+
event: "friends.connect_needs_handle",
|
|
110
|
+
message: "connect_to could not resolve the peer to a handle — needs a handle or an introduction",
|
|
111
|
+
meta: {},
|
|
112
|
+
});
|
|
113
|
+
return { ok: false, status: "needs_handle_or_introduction" };
|
|
114
|
+
}
|
|
115
|
+
// 3) The introduce effect — upsert the peer as an agent-peer at the linked trust.
|
|
116
|
+
// Own-fleet linked agents default to family; the level is overridable. Pass any
|
|
117
|
+
// existing a2a coords through so the upsert preserves them (incl. a legacy a2a.did).
|
|
118
|
+
//
|
|
119
|
+
// ┌─ PRE-CONDITION before any non-local / networked `controlContext` is ever wired ──────┐
|
|
120
|
+
// │ (security review inc-2 findings 2-3): the `family` DEFAULT here, and the fact that │
|
|
121
|
+
// │ the TARGET is upserted with no roster constraint (TOFU — see resolvePeer above), │
|
|
122
|
+
// │ are correct + safe ONLY because the authority gate (authorizeConnect) only COMMITs │
|
|
123
|
+
// │ on the owner-only `local` stdio sense today (no wire constructs a non-`local` │
|
|
124
|
+
// │ controlContext). BEFORE any non-`local`/networked controlContext is ever wired, the │
|
|
125
|
+
// │ `connect` commit MUST add target-side roster verification (the target did must ALSO │
|
|
126
|
+
// │ be roster-checked, not just TOFU-upserted) AND validate the caller-supplied │
|
|
127
|
+
// │ `trustLevel` against the authority decision. The current `family` default + │
|
|
128
|
+
// │ unconstrained target are safe only for the owner-only-stdio path. │
|
|
129
|
+
// └──────────────────────────────────────────────────────────────────────────────────────┘
|
|
130
|
+
const trustLevel = input.trustLevel ?? "family";
|
|
131
|
+
const a2a = resolved.existing?.agentMeta?.a2a;
|
|
132
|
+
const record = await (0, agent_peer_1.upsertAgentPeer)(store, {
|
|
133
|
+
name: resolved.name,
|
|
134
|
+
agentId: resolved.agentId,
|
|
135
|
+
trustLevel,
|
|
136
|
+
...(a2a ? { a2a } : {}),
|
|
137
|
+
});
|
|
138
|
+
// 4) The control-plane audit — ONE action:"connect" record through the wired sink
|
|
139
|
+
// (mirrors the onboard_agent seat writer). No sink ⇒ a clean no-op.
|
|
140
|
+
if (deps.audit) {
|
|
141
|
+
const targetDid = (0, identity_1.resolveAgentIdentity)(record.agentMeta).did;
|
|
142
|
+
const auditRecord = {
|
|
143
|
+
action: "connect",
|
|
144
|
+
targetId: record.id,
|
|
145
|
+
...(targetDid !== undefined ? { targetDid } : {}),
|
|
146
|
+
level: trustLevel,
|
|
147
|
+
actor: deps.actor,
|
|
148
|
+
originSense: deps.originSense,
|
|
149
|
+
ts: record.updatedAt,
|
|
150
|
+
};
|
|
151
|
+
await deps.audit.append(auditRecord);
|
|
152
|
+
}
|
|
153
|
+
(0, observability_1.emitNervesEvent)({
|
|
154
|
+
component: "friends",
|
|
155
|
+
event: "friends.connect_linked",
|
|
156
|
+
message: "connect_to linked an own-fleet agent peer",
|
|
157
|
+
meta: { targetId: record.id, level: trustLevel },
|
|
158
|
+
});
|
|
159
|
+
return { ok: true, status: "connected", record };
|
|
160
|
+
}
|
package/dist/coordination.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MissionStore } from "./mission-store";
|
|
2
2
|
import type { FriendStore } from "./store";
|
|
3
3
|
import type { GrantStore } from "./grant-store";
|
|
4
|
-
import type { AgentAttribution, CoordinationIntent, MissionRecord, TrustLevel } from "./types";
|
|
4
|
+
import type { AgentAttribution, CoordinationIntent, MissionRecord, MissionTaskSpec, TrustLevel } from "./types";
|
|
5
5
|
import type { ConsentPolicy } from "./consent";
|
|
6
6
|
import type { AgentVerifier } from "./verifier";
|
|
7
7
|
/** The cross-agent coordination envelope (brick 5). Names the subject by JOIN KEY
|
|
@@ -25,6 +25,12 @@ export interface CoordinationEnvelope {
|
|
|
25
25
|
* PROPOSES as the new assignee (named by join-key agentId). The receiver's own
|
|
26
26
|
* accept is what actually sets it — a handoff never forces an assignment. */
|
|
27
27
|
proposedAssignee?: AgentAttribution;
|
|
28
|
+
/** The delegation task-spec, meaningful ONLY on intent:"request" (gap-1, p11 inc2):
|
|
29
|
+
* the structured "what B is being asked to do", carrying the minted `requestId` the
|
|
30
|
+
* result-return correlates against. Present only when the producer was given a task on
|
|
31
|
+
* a request; a plain coordination request omits it (back-compat). Mirrors how
|
|
32
|
+
* `proposedAssignee` rides only `handoff`. */
|
|
33
|
+
task?: MissionTaskSpec;
|
|
28
34
|
/** Opaque, verifier-specific proof slot. The TOFU verifier ignores it. */
|
|
29
35
|
proof?: string;
|
|
30
36
|
issuedAt: string;
|
|
@@ -43,6 +49,16 @@ export interface PrepareCoordinationInput {
|
|
|
43
49
|
/** This agent's own join-key agentId — the asserter of the first-party log entry
|
|
44
50
|
* (and, on an `accept`, the assignee it claims for itself). */
|
|
45
51
|
selfAgentId: string;
|
|
52
|
+
/** Optional delegation task-spec (gap-1, p11 inc2), meaningful ONLY on
|
|
53
|
+
* intent:"request". When provided on a request, the producer MINTS a `requestId`,
|
|
54
|
+
* stamps the full `MissionTaskSpec` on the envelope's `task`, and records it first-party
|
|
55
|
+
* under the mission's `delegations[requestId]`. Ignored on any non-request intent.
|
|
56
|
+
* Absent ⇒ a plain coordination request, byte-identical to today (back-compat). */
|
|
57
|
+
task?: {
|
|
58
|
+
summary: string;
|
|
59
|
+
details?: string;
|
|
60
|
+
inputs?: Record<string, string>;
|
|
61
|
+
};
|
|
46
62
|
/** Optional proof to stamp on the envelope (for a non-TOFU recipient verifier). */
|
|
47
63
|
proof?: string;
|
|
48
64
|
}
|