@ouro.bot/cli 0.1.0-alpha.884 → 0.1.0-alpha.887
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/changelog.json +27 -0
- package/deploy/unraid/README.txt +7 -5
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +87 -30
- package/deploy/unraid/sanctuary-replay-gate.mjs +483 -75
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/a2a/client.js +16 -4
- package/dist/a2a/delegated-command-grants.js +93 -0
- package/dist/a2a/delegated-command.js +57 -11
- package/dist/a2a/escalation-grants.js +37 -75
- package/dist/a2a/identity.js +54 -0
- package/dist/a2a/operator-trust.js +382 -0
- package/dist/a2a/outbox-wire.js +2 -2
- package/dist/a2a/replay-grant-holders.js +33 -0
- package/dist/a2a/server.js +12 -7
- package/dist/a2a/trust-commands.js +203 -0
- package/dist/a2a/trusted-files.js +113 -26
- package/dist/heart/core.js +9 -5
- package/dist/heart/daemon/cli-exec.js +38 -37
- package/dist/heart/daemon/cli-parse.js +45 -5
- package/dist/heart/daemon/container-spec-auditor.js +3 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +4 -47
- package/dist/heart/failure-reports.js +5 -5
- package/dist/mind/prompt.js +31 -1
- package/dist/repertoire/guardrails.js +89 -7
- package/dist/repertoire/house-sweep.js +4 -1
- package/dist/senses/a2a-entry.js +2 -1
- package/dist/senses/private-runtime.js +11 -4
- package/dist/senses/shared-turn.js +5 -1
- package/dist/senses/telegram.js +5 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -506,6 +506,7 @@ async function containerSnapshot(expectedImage) {
|
|
|
506
506
|
{ destination: "/run/ouro-events", source: PRODUCTION_EVENT_SPOOL_SOURCE, propagation: "rprivate", rw: false, type: "bind" },
|
|
507
507
|
{ destination: "/run/ouro-authority", source: "/run/ouro-authority", propagation: "rprivate", rw: false, type: "bind" },
|
|
508
508
|
{ destination: "/home/ouro/AgentBundles/sanctuary.ouro/psyche", source: `${PRODUCTION_BUNDLE_SOURCE}/psyche`, propagation: "rprivate", rw: false, type: "bind" },
|
|
509
|
+
{ destination: "/etc/ouro/trust/sanctuary", source: "/mnt/user/appdata/ouro-butler/trust/sanctuary", propagation: "rprivate", rw: false, type: "bind" },
|
|
509
510
|
]
|
|
510
511
|
const mountsExact = mounts.length === expectedMounts.length && expectedMounts.every((expected) => mounts.some((mount) => mount.destination === expected.destination && mount.source === expected.source && mount.propagation === expected.propagation && mount.rw === expected.rw && mount.type === expected.type))
|
|
511
512
|
const securityExact = value.privileged === false && (value.capAdd === null || (Array.isArray(value.capAdd) && value.capAdd.length === 0))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.887</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -26,4 +26,5 @@
|
|
|
26
26
|
<Config Name="Privileged event spool" Target="/run/ouro-events" Default="/boot/config/custom/ouro-events/spool" Mode="ro" Description="Root-owned detector envelopes, read-only inside the Butler" Type="Path" Display="always" Required="true" Mask="false">/boot/config/custom/ouro-events/spool</Config>
|
|
27
27
|
<Config Name="Root authority socket" Target="/run/ouro-authority" Default="/run/ouro-authority" Mode="ro" Description="Root-owned gateway socket and public issuer pins only" Type="Path" Display="always" Required="true" Mask="false">/run/ouro-authority</Config>
|
|
28
28
|
<Config Name="Psyche (read-only)" Target="/home/ouro/AgentBundles/sanctuary.ouro/psyche" Default="/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche" Mode="ro" Description="The Butler's psyche files, read-only inside the Butler so they can be neither edited nor replaced" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche</Config>
|
|
29
|
+
<Config Name="Operator trust (read-only)" Target="/etc/ouro/trust/sanctuary" Default="/mnt/user/appdata/ouro-butler/trust/sanctuary" Mode="ro" Description="Root-owned grants the Butler honours (delegated commands, escalation). Read-only inside the Butler; only the host operator can change it" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/trust/sanctuary</Config>
|
|
29
30
|
</Container>
|
package/dist/a2a/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.A2ARpcError = void 0;
|
|
3
4
|
exports.cardDid = cardDid;
|
|
4
5
|
exports.endpointForCard = endpointForCard;
|
|
5
6
|
exports.fetchA2AAgentCard = fetchA2AAgentCard;
|
|
@@ -50,6 +51,17 @@ async function fetchA2AAgentCard(cardUrl, fetchImpl = fetch) {
|
|
|
50
51
|
});
|
|
51
52
|
return parsed;
|
|
52
53
|
}
|
|
54
|
+
/** A JSON-RPC error answer from the peer. `data` carries the peer's structured detail (for a refused delegated command: reason, nothingRan, retry, next). */
|
|
55
|
+
class A2ARpcError extends Error {
|
|
56
|
+
code;
|
|
57
|
+
data;
|
|
58
|
+
constructor(error) {
|
|
59
|
+
super(`A2A error ${error.code}: ${error.message}`);
|
|
60
|
+
this.code = error.code;
|
|
61
|
+
this.data = error.data;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.A2ARpcError = A2ARpcError;
|
|
53
65
|
async function postJsonRpc(endpointUrl, request, fetchImpl, protocolVersion = "1.0") {
|
|
54
66
|
const response = await fetchImpl(endpointUrl, {
|
|
55
67
|
method: "POST",
|
|
@@ -112,7 +124,7 @@ async function sendA2AMessage(input) {
|
|
|
112
124
|
}, fetchImpl);
|
|
113
125
|
}
|
|
114
126
|
if ("error" in rpc) {
|
|
115
|
-
throw new
|
|
127
|
+
throw new A2ARpcError(rpc.error);
|
|
116
128
|
}
|
|
117
129
|
const result = rpc.result;
|
|
118
130
|
const task = "task" in result && result.task ? result.task : result;
|
|
@@ -151,7 +163,7 @@ async function postA2AMessageEnvelope(input) {
|
|
|
151
163
|
rpc = await postJsonRpc(input.endpointUrl, { ...request, method: "SendMessage" }, fetchImpl);
|
|
152
164
|
}
|
|
153
165
|
if ("error" in rpc) {
|
|
154
|
-
throw new
|
|
166
|
+
throw new A2ARpcError(rpc.error);
|
|
155
167
|
}
|
|
156
168
|
(0, runtime_1.emitNervesEvent)({
|
|
157
169
|
component: "channels",
|
|
@@ -184,7 +196,7 @@ async function getA2ATask(input) {
|
|
|
184
196
|
rpc = await postJsonRpc(input.endpointUrl, { ...request, method: "GetTask" }, fetchImpl);
|
|
185
197
|
}
|
|
186
198
|
if ("error" in rpc) {
|
|
187
|
-
throw new
|
|
199
|
+
throw new A2ARpcError(rpc.error);
|
|
188
200
|
}
|
|
189
201
|
return rpc.result;
|
|
190
202
|
}
|
|
@@ -225,7 +237,7 @@ async function sendSealedA2AChat(input) {
|
|
|
225
237
|
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_chat_send_start", message: "sending sealed A2A chat message", meta: { endpointUrl, peerDid } });
|
|
226
238
|
const rpc = await postJsonRpc(endpointUrl, request, fetchImpl);
|
|
227
239
|
if ("error" in rpc)
|
|
228
|
-
throw new
|
|
240
|
+
throw new A2ARpcError(rpc.error);
|
|
229
241
|
const task = rpc.result;
|
|
230
242
|
const replyMessage = task?.status?.message;
|
|
231
243
|
if (!replyMessage)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DELEGATED_COMMAND_GRANTS_FILE = void 0;
|
|
4
|
+
exports.delegatedCommandGrantsPath = delegatedCommandGrantsPath;
|
|
5
|
+
exports.viewDelegatedCommandGrants = viewDelegatedCommandGrants;
|
|
6
|
+
exports.readDelegatedCommandGrants = readDelegatedCommandGrants;
|
|
7
|
+
exports.checkDelegatedCommandGrant = checkDelegatedCommandGrant;
|
|
8
|
+
exports.setDelegatedCommandGrant = setDelegatedCommandGrant;
|
|
9
|
+
const runtime_1 = require("../nerves/runtime");
|
|
10
|
+
const operator_trust_1 = require("./operator-trust");
|
|
11
|
+
const replay_grant_holders_1 = require("./replay-grant-holders");
|
|
12
|
+
const replay_harness_1 = require("./replay-harness");
|
|
13
|
+
exports.DELEGATED_COMMAND_GRANTS_FILE = "delegated-command-grants.json";
|
|
14
|
+
function delegatedCommandGrantsPath(agentRoot) {
|
|
15
|
+
return (0, operator_trust_1.operatorTrustFile)(agentRoot, exports.DELEGATED_COMMAND_GRANTS_FILE);
|
|
16
|
+
}
|
|
17
|
+
function validGrant(value) {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
return false;
|
|
20
|
+
const grant = value;
|
|
21
|
+
const keys = Object.keys(grant).sort().join(",");
|
|
22
|
+
return (keys === "did,grantedAt,scope,source" || keys === "did,expiresAt,grantedAt,scope,source")
|
|
23
|
+
&& grant.scope === "principal_commands"
|
|
24
|
+
&& typeof grant.did === "string" && grant.did.startsWith("did:") && grant.did.length <= 512
|
|
25
|
+
&& typeof grant.grantedAt === "string" && !Number.isNaN(Date.parse(grant.grantedAt))
|
|
26
|
+
&& typeof grant.source === "string" && grant.source.trim().length > 0
|
|
27
|
+
&& (grant.expiresAt === undefined || typeof grant.expiresAt === "string");
|
|
28
|
+
}
|
|
29
|
+
/** The file's trust state, every well-formed grant in it keyed by friend id, and the friend ids whose entries were dropped. */
|
|
30
|
+
function viewDelegatedCommandGrants(agentRoot) {
|
|
31
|
+
return (0, operator_trust_1.readGrantFile)(agentRoot, exports.DELEGATED_COMMAND_GRANTS_FILE, validGrant);
|
|
32
|
+
}
|
|
33
|
+
function readDelegatedCommandGrants(agentRoot) {
|
|
34
|
+
return viewDelegatedCommandGrants(agentRoot).grants;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The one answer to "may this friend relay the principal's commands right now?". Admission, the follow-up path and
|
|
38
|
+
* `list` all ask it, so they cannot disagree. `signerDid` is the DID the message was verified as: it must equal the
|
|
39
|
+
* DID the operator pinned. Order: file trusted, grant exists, DID matches, not expired (an expiry that is not a finite
|
|
40
|
+
* time counts as expired, and so does a replay identity whose root-owned window is closed), then family and active.
|
|
41
|
+
*/
|
|
42
|
+
function checkDelegatedCommandGrant(agentRoot, friend, signerDid, now = Date.now()) {
|
|
43
|
+
const view = viewDelegatedCommandGrants(agentRoot);
|
|
44
|
+
if (view.state === "untrusted")
|
|
45
|
+
return { ok: false, reason: "grants_untrusted" };
|
|
46
|
+
const grant = view.grants[friend.id];
|
|
47
|
+
if (!grant)
|
|
48
|
+
return { ok: false, reason: "no_grant" };
|
|
49
|
+
if (grant.did !== signerDid)
|
|
50
|
+
return { ok: false, reason: "grant_did_mismatch" };
|
|
51
|
+
if (grant.expiresAt !== undefined) {
|
|
52
|
+
const expiresAt = Date.parse(grant.expiresAt);
|
|
53
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now)
|
|
54
|
+
return { ok: false, reason: "grant_expired" };
|
|
55
|
+
}
|
|
56
|
+
// A grant for a friend on the root-owned replay list must be time-bounded by the trusted file itself. The bundle's replay registry is the agent's to move aside, so it only ever adds the window check below.
|
|
57
|
+
if (grant.expiresAt === undefined && (0, replay_grant_holders_1.isReplayGrantHolder)(agentRoot, friend.id))
|
|
58
|
+
return { ok: false, reason: "grant_expired" };
|
|
59
|
+
// A replay identity is a test peer of the host's replay gate: its grant means something only while the root-owned window is open.
|
|
60
|
+
if ((0, replay_harness_1.isReplayIdentity)(agentRoot, friend.id) && !(0, replay_harness_1.isReplayWindowOpen)(agentRoot, friend.id, now))
|
|
61
|
+
return { ok: false, reason: "grant_expired" };
|
|
62
|
+
if (friend.trustLevel !== "family" || friend.admissionState !== "active")
|
|
63
|
+
return { ok: false, reason: "not_family" };
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
/** Writes or removes one grant. The previous file is kept beside it as a timestamped backup before the change. */
|
|
67
|
+
function setDelegatedCommandGrant(agentRoot, friendId, change, now = new Date()) {
|
|
68
|
+
return (0, operator_trust_1.withTrustedWriteLock)(agentRoot, () => setDelegatedCommandGrantLocked(agentRoot, friendId, change, now));
|
|
69
|
+
}
|
|
70
|
+
function setDelegatedCommandGrantLocked(agentRoot, friendId, change, now) {
|
|
71
|
+
const current = readDelegatedCommandGrants(agentRoot);
|
|
72
|
+
const had = current[friendId];
|
|
73
|
+
const next = { ...current };
|
|
74
|
+
if (change.grant) {
|
|
75
|
+
const entry = { scope: "principal_commands", did: change.did, grantedAt: now.toISOString(), source: change.source, ...(change.expiresAt ? { expiresAt: change.expiresAt } : {}) };
|
|
76
|
+
if (had && had.did === entry.did && had.source === entry.source && had.expiresAt === entry.expiresAt)
|
|
77
|
+
return { changed: false, backup: null };
|
|
78
|
+
next[friendId] = entry;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
if (had === undefined)
|
|
82
|
+
return { changed: false, backup: null };
|
|
83
|
+
delete next[friendId];
|
|
84
|
+
}
|
|
85
|
+
const { backup } = (0, operator_trust_1.writeTrustedFile)(agentRoot, exports.DELEGATED_COMMAND_GRANTS_FILE, `${JSON.stringify({ schemaVersion: 1, grants: next }, null, 2)}\n`, now);
|
|
86
|
+
(0, runtime_1.emitNervesEvent)({
|
|
87
|
+
component: "senses",
|
|
88
|
+
event: "senses.a2a_delegated_command_grant_changed",
|
|
89
|
+
message: change.grant ? "granted delegated commands to an A2A peer" : "revoked an A2A peer's delegated-command grant",
|
|
90
|
+
meta: { friendId, granted: change.grant },
|
|
91
|
+
});
|
|
92
|
+
return { changed: true, backup };
|
|
93
|
+
}
|
|
@@ -35,12 +35,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.DELEGATED_BANNER = exports.A2A_PRINCIPAL_PROFILE_ID = void 0;
|
|
37
37
|
exports.delegatedCommandWasNoticed = delegatedCommandWasNoticed;
|
|
38
|
+
exports.delegationRefusalGuidance = delegationRefusalGuidance;
|
|
39
|
+
exports.shieldDelegatedBanner = shieldDelegatedBanner;
|
|
38
40
|
exports.delegatedCommandNotice = delegatedCommandNotice;
|
|
39
41
|
exports.admitDelegatedCommand = admitDelegatedCommand;
|
|
40
42
|
const crypto_1 = require("crypto");
|
|
41
43
|
const fs = __importStar(require("fs"));
|
|
42
44
|
const path = __importStar(require("path"));
|
|
43
45
|
const runtime_1 = require("../nerves/runtime");
|
|
46
|
+
const delegated_command_grants_1 = require("./delegated-command-grants");
|
|
44
47
|
const replay_harness_1 = require("./replay-harness");
|
|
45
48
|
const relationship_authorization_1 = require("../repertoire/relationship-authorization");
|
|
46
49
|
const NOTICE_EXCERPT_CHARS = 200;
|
|
@@ -67,16 +70,60 @@ function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId, del
|
|
|
67
70
|
const artifact = JSON.parse(raw);
|
|
68
71
|
return artifact.idempotencyKey === idempotencyKey && artifact.authorClass === "butler" && artifact.target?.friendId === principalFriendId;
|
|
69
72
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* What a refused sender is told. Every refusal says that nothing ran and names the next step; the reason code stays
|
|
75
|
+
* first on the wire (`delegated command refused: <reason>`) because the replay gate and older CLIs match it.
|
|
76
|
+
*/
|
|
77
|
+
function delegationRefusalGuidance(reason, hints) {
|
|
78
|
+
switch (reason) {
|
|
79
|
+
case "not_enabled":
|
|
80
|
+
return { message: "this agent does not accept delegated commands", next: "Send the message without --delegated, or ask the agent's operator to enable delegated commands.", retry: false };
|
|
81
|
+
case "no_grant":
|
|
82
|
+
return {
|
|
83
|
+
message: hints.legacyRecordGrant
|
|
84
|
+
? "the operator has not granted you delegated commands; a grant on your friend record is not honoured, because the agent could have written it"
|
|
85
|
+
: "the operator has not granted you delegated commands",
|
|
86
|
+
next: `Ask the operator to run, as root on the agent's host: ouro a2a delegated-commands grant --friend <your friend id> --did <your DID> (your DID is in: ouro a2a identity --json).${hints.legacyRecordGrant ? " Grants live in the operator trust directory now, not on the friend record." : ""}`,
|
|
87
|
+
retry: false,
|
|
88
|
+
};
|
|
89
|
+
case "grants_untrusted":
|
|
90
|
+
return { message: "the agent cannot trust its grant file, so it honours no delegated-command grants", next: "Ask the operator to run: ouro a2a delegated-commands list, and fix the owner or mode it reports.", retry: false };
|
|
91
|
+
case "grant_did_mismatch":
|
|
92
|
+
return { message: "this message was signed by a different key than the one the operator granted", next: "Send from the host whose DID the operator pinned, or ask the operator to re-grant with your current DID: ouro a2a delegated-commands grant --friend <your friend id> --did <your DID>.", retry: false };
|
|
93
|
+
case "grant_expired":
|
|
94
|
+
return { message: "your delegated-command grant has expired or is not in force", next: "Ask the operator to re-grant: ouro a2a delegated-commands grant --friend <your friend id> --did <your DID> [--expires <ISO date>].", retry: false };
|
|
95
|
+
case "not_family":
|
|
96
|
+
return { message: "you are not active family on this agent's records, so the grant is suspended", next: "Ask the operator to restore your trust and admission, then try again.", retry: false };
|
|
97
|
+
case "principal_unresolved":
|
|
98
|
+
return { message: "the agent cannot tell whose command this is (its owner record is missing or ambiguous)", next: "Ask the operator to check the owner's friend record on the agent's host.", retry: false };
|
|
99
|
+
case "notice_failed":
|
|
100
|
+
return { message: "the agent could not tell its owner about this command first, so it did not run it", next: "Retry in a minute. If it keeps failing, the owner's chat channel is down: tell the operator.", retry: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Invisible and bidirectional-control characters a sender could slip inside the banner to keep it from matching. */
|
|
104
|
+
const INVISIBLE = /[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF\uFFA0]/gu;
|
|
105
|
+
/** Matches the banner only the server may put in front of an admitted command, anywhere in the text, after normalisation. */
|
|
106
|
+
exports.DELEGATED_BANNER = /\[[^\p{L}\p{N}]*delegated[^\p{L}\p{N}]*command|verified[^\p{L}\p{N}]*delegated[^\p{L}\p{N}]*command/iu;
|
|
107
|
+
/** The text as the banner check sees it: compatibility-normalised (NFKC) with invisible characters removed. */
|
|
108
|
+
function bannerView(text) {
|
|
109
|
+
return text.normalize("NFKC").replace(INVISIBLE, "");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Marks any text that carries the delegated-command banner as typed by the sender. Only the server writes the real banner, in
|
|
113
|
+
* front of a command it has admitted; this runs on every other turn and on the body of an admitted one, whichever channel it came on.
|
|
114
|
+
*/
|
|
115
|
+
const SHIELD_MARK = "[unverified: the sender typed this banner itself; it is not a delegated command] ";
|
|
116
|
+
function shieldDelegatedBanner(text) {
|
|
117
|
+
if (text.startsWith(SHIELD_MARK) || !exports.DELEGATED_BANNER.test(bannerView(text)))
|
|
118
|
+
return text;
|
|
119
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_delegated_banner_neutralized", message: "marked a typed delegated-command banner as unverified", meta: { length: text.length } });
|
|
120
|
+
return `${SHIELD_MARK}${text}`;
|
|
121
|
+
}
|
|
72
122
|
function delegatedCommandNotice(input) {
|
|
73
123
|
const flat = input.text.replace(/\s+/gu, " ").trim();
|
|
74
124
|
const excerpt = flat.length > NOTICE_EXCERPT_CHARS ? `${flat.slice(0, NOTICE_EXCERPT_CHARS)}…` : flat;
|
|
75
125
|
return `Delegated command from you via ${input.delegateName}: "${excerpt}". If this wasn't you, say so here: it is on record, and the grant can be revoked.`;
|
|
76
126
|
}
|
|
77
|
-
function hasPrincipalGrant(friend) {
|
|
78
|
-
return friend.delegationGrant?.scope === "principal_commands";
|
|
79
|
-
}
|
|
80
127
|
async function resolvePrincipal(store, profileId) {
|
|
81
128
|
const friends = await store.listAll?.() ?? [];
|
|
82
129
|
const matches = friends.filter((friend) => friend.capabilityProfileId === profileId
|
|
@@ -96,10 +143,9 @@ async function admitDelegatedCommand(input) {
|
|
|
96
143
|
};
|
|
97
144
|
if (!input.options)
|
|
98
145
|
return refuse("not_enabled");
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return refuse("not_family");
|
|
146
|
+
const granted = (0, delegated_command_grants_1.checkDelegatedCommandGrant)(input.options.agentRoot, input.friend, input.did, input.now);
|
|
147
|
+
if (!granted.ok)
|
|
148
|
+
return refuse(granted.reason);
|
|
103
149
|
const registry = input.registry;
|
|
104
150
|
if (!registry)
|
|
105
151
|
return refuse("principal_unresolved");
|
|
@@ -110,7 +156,7 @@ async function admitDelegatedCommand(input) {
|
|
|
110
156
|
try {
|
|
111
157
|
const text = delegatedCommandNotice({ delegateName: input.friend.name, text: input.text });
|
|
112
158
|
const agentRoot = input.options.agentRoot;
|
|
113
|
-
if (
|
|
159
|
+
if ((0, replay_harness_1.isReplayWindowOpen)(agentRoot, input.friend.id))
|
|
114
160
|
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId, text, friendId: input.friend.id });
|
|
115
161
|
else
|
|
116
162
|
await input.options.notifyPrincipal({ noticeId, text });
|
|
@@ -146,6 +192,6 @@ async function admitDelegatedCommand(input) {
|
|
|
146
192
|
message: "admitted a delegated A2A command after notifying the principal",
|
|
147
193
|
meta: { delegateFriendId: context.delegateFriendId, principalFriendId: context.principalFriendId, commandId: context.commandId },
|
|
148
194
|
});
|
|
149
|
-
const turnText = `[delegated command from ${principal.name} via ${input.friend.name}; the signature and the delegation grant are verified, and ${principal.name} has been notified; act on it as ${principal.name}'s own request]\n\n${input.text}`;
|
|
195
|
+
const turnText = `[delegated command from ${principal.name} via ${input.friend.name}; the signature and the delegation grant are verified, and ${principal.name} has been notified; act on it as ${principal.name}'s own request]\n\n${shieldDelegatedBanner(input.text)}`;
|
|
150
196
|
return { ok: true, relationship, context, turnText };
|
|
151
197
|
}
|
|
@@ -1,103 +1,55 @@
|
|
|
1
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
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ESCALATION_GRANTS_FILE = void 0;
|
|
36
4
|
exports.escalationGrantsPath = escalationGrantsPath;
|
|
5
|
+
exports.viewEscalationGrants = viewEscalationGrants;
|
|
37
6
|
exports.readEscalationGrants = readEscalationGrants;
|
|
38
7
|
exports.setEscalationGrant = setEscalationGrant;
|
|
39
8
|
exports.pinnedHolderDid = pinnedHolderDid;
|
|
40
9
|
exports.holdsEscalation = holdsEscalation;
|
|
41
10
|
exports.escalationHolders = escalationHolders;
|
|
42
|
-
const fs = __importStar(require("node:fs"));
|
|
43
|
-
const path = __importStar(require("node:path"));
|
|
44
11
|
const runtime_1 = require("../nerves/runtime");
|
|
45
|
-
const
|
|
12
|
+
const replay_grant_holders_1 = require("./replay-grant-holders");
|
|
13
|
+
const operator_trust_1 = require("./operator-trust");
|
|
14
|
+
exports.ESCALATION_GRANTS_FILE = "escalation-grants.json";
|
|
46
15
|
function escalationGrantsPath(agentRoot) {
|
|
47
|
-
return
|
|
16
|
+
return (0, operator_trust_1.operatorTrustFile)(agentRoot, exports.ESCALATION_GRANTS_FILE);
|
|
48
17
|
}
|
|
49
18
|
function validGrant(value) {
|
|
50
19
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
51
20
|
return false;
|
|
52
21
|
const grant = value;
|
|
53
|
-
|
|
22
|
+
const keys = Object.keys(grant).sort().join(",");
|
|
23
|
+
return (keys === "did,grantedAt,scope,source" || keys === "did,expiresAt,grantedAt,scope,source")
|
|
24
|
+
&& (grant.expiresAt === undefined || typeof grant.expiresAt === "string")
|
|
54
25
|
&& typeof grant.did === "string" && grant.did.startsWith("did:") && grant.did.length <= 512
|
|
55
26
|
&& grant.scope === "escalation"
|
|
56
27
|
&& typeof grant.grantedAt === "string" && !Number.isNaN(Date.parse(grant.grantedAt))
|
|
57
28
|
&& typeof grant.source === "string" && grant.source.trim().length > 0;
|
|
58
29
|
}
|
|
30
|
+
/** The file's trust state and every well-formed grant in it, keyed by friend id. */
|
|
31
|
+
function viewEscalationGrants(agentRoot) {
|
|
32
|
+
return (0, operator_trust_1.readGrantFile)(agentRoot, exports.ESCALATION_GRANTS_FILE, validGrant);
|
|
33
|
+
}
|
|
59
34
|
/** Every well-formed grant in the file, keyed by friend id. Anything else in the file is ignored. */
|
|
60
35
|
function readEscalationGrants(agentRoot) {
|
|
61
|
-
|
|
62
|
-
if (!(0, trusted_files_1.isTrustedDirectory)(path.dirname(file)))
|
|
63
|
-
return {};
|
|
64
|
-
const parsed = (0, trusted_files_1.readTrustedJson)(file);
|
|
65
|
-
const grants = parsed?.grants;
|
|
66
|
-
if (!grants || typeof grants !== "object" || Array.isArray(grants))
|
|
67
|
-
return {};
|
|
68
|
-
return Object.fromEntries(Object.entries(grants).filter((entry) => validGrant(entry[1])));
|
|
69
|
-
}
|
|
70
|
-
/** Run as root, the operator's grant leaves the file and its directory root-owned and closed to everyone else, even if an earlier recursive chown had handed them to the resident. */
|
|
71
|
-
function ownAsRoot(target, mode) {
|
|
72
|
-
fs.chownSync(target, 0, 0);
|
|
73
|
-
fs.chmodSync(target, mode);
|
|
36
|
+
return viewEscalationGrants(agentRoot).grants;
|
|
74
37
|
}
|
|
75
38
|
/** Writes or removes one grant. The previous file is kept beside it as a timestamped backup before the change. */
|
|
76
39
|
function setEscalationGrant(agentRoot, friendId, change, now = new Date()) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (process.geteuid?.() === 0)
|
|
81
|
-
ownAsRoot(path.dirname(file), 0o755);
|
|
40
|
+
return (0, operator_trust_1.withTrustedWriteLock)(agentRoot, () => setEscalationGrantLocked(agentRoot, friendId, change, now));
|
|
41
|
+
}
|
|
42
|
+
function setEscalationGrantLocked(agentRoot, friendId, change, now) {
|
|
82
43
|
const current = readEscalationGrants(agentRoot);
|
|
83
44
|
const had = current[friendId];
|
|
84
|
-
if (change.grant ? had?.did === change.did : had === undefined)
|
|
45
|
+
if (change.grant ? had?.did === change.did && had.expiresAt === change.expiresAt : had === undefined)
|
|
85
46
|
return { changed: false, backup: null };
|
|
86
|
-
let backup = null;
|
|
87
|
-
if (fs.existsSync(file)) {
|
|
88
|
-
backup = `${file}.bak-${now.toISOString().replace(/[:.]/gu, "-")}`;
|
|
89
|
-
fs.copyFileSync(file, backup);
|
|
90
|
-
}
|
|
91
47
|
const next = { ...current };
|
|
92
48
|
if (change.grant)
|
|
93
|
-
next[friendId] = { scope: "escalation", grantedAt: now.toISOString(), source: change.source, did: change.did };
|
|
49
|
+
next[friendId] = { scope: "escalation", grantedAt: now.toISOString(), source: change.source, did: change.did, ...(change.expiresAt ? { expiresAt: change.expiresAt } : {}) };
|
|
94
50
|
else
|
|
95
51
|
delete next[friendId];
|
|
96
|
-
const
|
|
97
|
-
fs.writeFileSync(tmp, `${JSON.stringify({ schemaVersion: 1, grants: next }, null, 2)}\n`, { mode: 0o644 });
|
|
98
|
-
fs.renameSync(tmp, file);
|
|
99
|
-
if (process.geteuid?.() === 0)
|
|
100
|
-
ownAsRoot(file, 0o644);
|
|
52
|
+
const { backup } = (0, operator_trust_1.writeTrustedFile)(agentRoot, exports.ESCALATION_GRANTS_FILE, `${JSON.stringify({ schemaVersion: 1, grants: next }, null, 2)}\n`, now);
|
|
101
53
|
(0, runtime_1.emitNervesEvent)({
|
|
102
54
|
component: "senses",
|
|
103
55
|
event: "senses.a2a_escalation_grant_changed",
|
|
@@ -106,17 +58,27 @@ function setEscalationGrant(agentRoot, friendId, change, now = new Date()) {
|
|
|
106
58
|
});
|
|
107
59
|
return { changed: true, backup };
|
|
108
60
|
}
|
|
109
|
-
/**
|
|
110
|
-
function
|
|
111
|
-
|
|
61
|
+
/** A grant is in force when its trusted expiry has not passed; a grant with no expiry for a friend on the root-owned replay list is never in force. */
|
|
62
|
+
function inForce(agentRoot, friendId, grant, now) {
|
|
63
|
+
if (!grant)
|
|
64
|
+
return false;
|
|
65
|
+
if (grant.expiresAt === undefined)
|
|
66
|
+
return !(0, replay_grant_holders_1.isReplayGrantHolder)(agentRoot, friendId);
|
|
67
|
+
const expiresAt = Date.parse(grant.expiresAt);
|
|
68
|
+
return Number.isFinite(expiresAt) && expiresAt > now;
|
|
69
|
+
}
|
|
70
|
+
/** The DID the operator pinned for this holder, or null when it holds no (complete, unexpired) grant. */
|
|
71
|
+
function pinnedHolderDid(agentRoot, friendId, now = Date.now()) {
|
|
72
|
+
const grant = readEscalationGrants(agentRoot)[friendId];
|
|
73
|
+
return inForce(agentRoot, friendId, grant, now) ? grant.did : null;
|
|
112
74
|
}
|
|
113
75
|
/** True only for an active family friend that the operator listed in the grants file, with a pinned DID. */
|
|
114
|
-
function holdsEscalation(agentRoot, friend) {
|
|
115
|
-
return readEscalationGrants(agentRoot)[friend.id]
|
|
76
|
+
function holdsEscalation(agentRoot, friend, now = Date.now()) {
|
|
77
|
+
return inForce(agentRoot, friend.id, readEscalationGrants(agentRoot)[friend.id], now)
|
|
116
78
|
&& friend.trustLevel === "family"
|
|
117
79
|
&& friend.admissionState === "active";
|
|
118
80
|
}
|
|
119
|
-
async function escalationHolders(agentRoot, store) {
|
|
81
|
+
async function escalationHolders(agentRoot, store, now = Date.now()) {
|
|
120
82
|
const friends = await store.listAll?.() ?? [];
|
|
121
|
-
return friends.filter((friend) => holdsEscalation(agentRoot, friend));
|
|
83
|
+
return friends.filter((friend) => holdsEscalation(agentRoot, friend, now));
|
|
122
84
|
}
|
package/dist/a2a/identity.js
CHANGED
|
@@ -33,9 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.PUBLIC_A2A_IDENTITY_FILE = void 0;
|
|
36
37
|
exports.readStoredA2ASeed = readStoredA2ASeed;
|
|
37
38
|
exports.loadOrMintA2AIdentity = loadOrMintA2AIdentity;
|
|
38
39
|
exports.loadSelfA2AIdentity = loadSelfA2AIdentity;
|
|
40
|
+
exports.publishOwnA2ADid = publishOwnA2ADid;
|
|
41
|
+
exports.readPublishedA2ADid = readPublishedA2ADid;
|
|
42
|
+
exports.readOwnA2ADid = readOwnA2ADid;
|
|
39
43
|
exports.loadOrMintA2AIdentityFile = loadOrMintA2AIdentityFile;
|
|
40
44
|
const crypto = __importStar(require("node:crypto"));
|
|
41
45
|
const fs = __importStar(require("node:fs"));
|
|
@@ -171,6 +175,56 @@ async function loadSelfA2AIdentity(input) {
|
|
|
171
175
|
},
|
|
172
176
|
});
|
|
173
177
|
}
|
|
178
|
+
/** Where the agent publishes its own DID inside its bundle: a public value (a DID is a public key), never the seed. */
|
|
179
|
+
exports.PUBLIC_A2A_IDENTITY_FILE = path.join("a2a", "public-identity.json");
|
|
180
|
+
/**
|
|
181
|
+
* Publish this agent's own DID into its bundle so a one-off operator command (which has no vault access) can compare against it
|
|
182
|
+
* through the read-only bundle mount. Best effort: a read-only bundle just means the fallback stays unavailable.
|
|
183
|
+
*/
|
|
184
|
+
function publishOwnA2ADid(agentRoot, did) {
|
|
185
|
+
try {
|
|
186
|
+
const file = path.join(agentRoot, exports.PUBLIC_A2A_IDENTITY_FILE);
|
|
187
|
+
if (readPublishedA2ADid(agentRoot) === did)
|
|
188
|
+
return;
|
|
189
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
190
|
+
fs.writeFileSync(file, `${JSON.stringify({ did }, null, 2)}\n`);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// The bundle may be mounted read-only; the DID then simply is not published.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The DID published in the bundle's public identity file, or null when it is absent or malformed. Read-only and ADVISORY: the agent can
|
|
198
|
+
* write its own bundle, so this value is never proof of which key is the agent's. Operator checks may report it, never rely on it alone.
|
|
199
|
+
*/
|
|
200
|
+
function readPublishedA2ADid(agentRoot) {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(agentRoot, exports.PUBLIC_A2A_IDENTITY_FILE), "utf8"));
|
|
203
|
+
return typeof parsed.did === "string" && parsed.did.startsWith("did:key:") ? parsed.did : null;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* This agent's own A2A DID if its seed is already in this process's cached machine config, else null. Strictly read-only: it
|
|
211
|
+
* never mints a seed, never creates the machine identity file and never refreshes the vault, because an operator command only
|
|
212
|
+
* wants to compare, not to create or fetch anything. A one-off root command has no cached config, so it gets null and the caller
|
|
213
|
+
* falls back to the published DID (advisory only, see `readPublishedA2ADid`) and to comparing the DID against the friend records.
|
|
214
|
+
*/
|
|
215
|
+
async function readOwnA2ADid(agentName) {
|
|
216
|
+
try {
|
|
217
|
+
const cached = (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agentName);
|
|
218
|
+
if (!cached.ok || !readStoredA2ASeed(cached.config))
|
|
219
|
+
return null;
|
|
220
|
+
/* v8 ignore next -- the seed is already stored here, so the load never reaches upsert; it exists only to make minting impossible @preserve */
|
|
221
|
+
const identity = await loadOrMintA2AIdentity({ agentName, sodium: await (0, a2a_client_1.ready)(), config: cached.config, upsert: async () => { throw new Error("readOwnA2ADid never mints"); } });
|
|
222
|
+
return identity.did;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
174
228
|
/**
|
|
175
229
|
* A file-backed A2A identity for a client that is not an ouro agent (for example a
|
|
176
230
|
* coding harness talking to an agent over A2A). The file holds the Ed25519 seed, so
|