@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.
Files changed (35) hide show
  1. package/changelog.json +27 -0
  2. package/deploy/unraid/README.txt +7 -5
  3. package/deploy/unraid/sanctuary-butler-upgrade.mjs +87 -30
  4. package/deploy/unraid/sanctuary-replay-gate.mjs +483 -75
  5. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1 -0
  6. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  7. package/deploy/unraid/sanctuary.xml +2 -1
  8. package/dist/a2a/client.js +16 -4
  9. package/dist/a2a/delegated-command-grants.js +93 -0
  10. package/dist/a2a/delegated-command.js +57 -11
  11. package/dist/a2a/escalation-grants.js +37 -75
  12. package/dist/a2a/identity.js +54 -0
  13. package/dist/a2a/operator-trust.js +382 -0
  14. package/dist/a2a/outbox-wire.js +2 -2
  15. package/dist/a2a/replay-grant-holders.js +33 -0
  16. package/dist/a2a/server.js +12 -7
  17. package/dist/a2a/trust-commands.js +203 -0
  18. package/dist/a2a/trusted-files.js +113 -26
  19. package/dist/heart/core.js +9 -5
  20. package/dist/heart/daemon/cli-exec.js +38 -37
  21. package/dist/heart/daemon/cli-parse.js +45 -5
  22. package/dist/heart/daemon/container-spec-auditor.js +3 -0
  23. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  24. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +1 -1
  25. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +4 -47
  26. package/dist/heart/failure-reports.js +5 -5
  27. package/dist/mind/prompt.js +31 -1
  28. package/dist/repertoire/guardrails.js +89 -7
  29. package/dist/repertoire/house-sweep.js +4 -1
  30. package/dist/senses/a2a-entry.js +2 -1
  31. package/dist/senses/private-runtime.js +11 -4
  32. package/dist/senses/shared-turn.js +5 -1
  33. package/dist/senses/telegram.js +5 -3
  34. package/npm-shrinkwrap.json +2 -2
  35. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeDelegatedCommandsCommand = executeDelegatedCommandsCommand;
4
+ exports.executeEscalationCommand = executeEscalationCommand;
5
+ const runtime_1 = require("../nerves/runtime");
6
+ const delegated_command_grants_1 = require("./delegated-command-grants");
7
+ const escalation_grants_1 = require("./escalation-grants");
8
+ const replay_grant_holders_1 = require("./replay-grant-holders");
9
+ const operator_trust_1 = require("./operator-trust");
10
+ const resolution_proof_1 = require("./resolution-proof");
11
+ const NEEDS_ROOT = "refusing: this command writes root-owned files the agent cannot forge, so it must run as root on the agent's host (for example docker exec as root, or sudo). Nothing was written.";
12
+ function requireRoot(ctx) {
13
+ if (!ctx.isRoot)
14
+ throw new Error(NEEDS_ROOT);
15
+ }
16
+ async function requireFriend(ctx, friendId) {
17
+ const friend = await ctx.store.get(friendId);
18
+ if (!friend)
19
+ throw new Error(`friend not found: ${friendId}`);
20
+ return friend;
21
+ }
22
+ /** The --did rules shared by both grant commands. Returns the DID to pin (equal to the record's). */
23
+ function checkedDid(friend, did) {
24
+ if (!did || !did.startsWith("did:"))
25
+ throw new Error(`refusing: --did ${did ?? "(missing)"} is not a DID (it starts with did:). Copy it from the sender's own host with: ouro a2a identity --json. Nothing was written.`);
26
+ const recorded = (0, resolution_proof_1.friendDid)(friend);
27
+ if (!recorded)
28
+ throw new Error(`refusing: ${friend.name} has no DID on record, so there is nothing to check --did against. Onboard it with its DID first (ouro a2a onboard --did <did> --name "${friend.name}"). Nothing was written.`);
29
+ if (recorded !== did)
30
+ throw new Error(`refusing: --did ${did} does not match the DID on ${friend.name}'s record (${recorded}). If the sender's key really changed, onboard it again; otherwise you copied the wrong DID. Nothing was written.`);
31
+ return did;
32
+ }
33
+ /**
34
+ * The checks both grant commands share before they pin a DID. It may not be this agent's own, and it may not already be pinned by
35
+ * another holder's grant in the same file. When this agent's own DID cannot be read (a one-off root container has no vault) the
36
+ * own-DID check cannot run, so the command instead refuses any DID that a second friend record also carries (a sender's key is on
37
+ * exactly one record), and says so in its output.
38
+ */
39
+ async function vetPinnedDid(ctx, friend, did, held) {
40
+ const own = await ctx.ownDid();
41
+ if (own === did)
42
+ throw new Error("refusing: that is this agent's own DID. A grant must pin the sender's key, not this agent's. Nothing was written.");
43
+ const holder = Object.entries(held).find(([id, existing]) => id !== friend.id && existing.did === did);
44
+ if (holder)
45
+ throw new Error(`refusing: that DID is already pinned by ${holder[0]}'s grant. Revoke it first if the sender really moved to a different friend record. Nothing was written.`);
46
+ if (own !== null)
47
+ return [];
48
+ // The own DID is not available from the machine config here. A DID the agent published in its bundle is only a hint (the agent can write
49
+ // that file), so the friend-record comparison always runs as well, and the published value is reported with its source.
50
+ const published = await ctx.publishedDid?.() ?? null;
51
+ const records = await ctx.store.listAll?.() ?? [];
52
+ const twin = records.find((record) => record.id !== friend.id && (0, resolution_proof_1.friendDid)(record) === did);
53
+ if (twin)
54
+ throw new Error(`refusing: this agent's own DID could not be read from its machine config here, and another friend record (${twin.name}, ${twin.id}) carries that same DID, so it may be this agent's own key. Check it with: ouro a2a identity --agent ${ctx.agentName} --json. Nothing was written.`);
55
+ const notes = ["note: could not read this agent's own DID from its machine config here, so the own-DID check was skipped; compared --did with every DID in the friend records instead, and no other record carries it"];
56
+ if (published === did)
57
+ notes.push("warning: --did equals the DID published in the agent's bundle (source: bundle-published, which the agent itself can write, so this is advisory). Granting it would pin the agent's own key; check with ouro a2a identity --agent " + ctx.agentName + " --json before relying on this grant");
58
+ else if (published !== null)
59
+ notes.push(`note: the DID published in the agent's bundle (source: bundle-published, advisory) is ${published}, which differs from --did`);
60
+ return notes;
61
+ }
62
+ function notActiveFamilyNote(friend, what) {
63
+ return friend.trustLevel === "family" && friend.admissionState === "active"
64
+ ? []
65
+ : [`note: ${friend.name} is ${friend.trustLevel}, ${friend.admissionState}, so the grant stays suspended until it is active family${what}`];
66
+ }
67
+ function parseExpiry(expires, now) {
68
+ if (expires === undefined)
69
+ return undefined;
70
+ const at = Date.parse(expires);
71
+ if (!Number.isFinite(at) || at <= now.getTime())
72
+ throw new Error(`refusing: --expires ${expires} is not a time in the future (use an ISO date such as 2027-01-01T00:00:00Z). Nothing was written.`);
73
+ return new Date(at).toISOString();
74
+ }
75
+ async function executeDelegatedCommandsCommand(command, ctx) {
76
+ if (command.action === "list")
77
+ return listDelegatedCommands(ctx, command.json === true);
78
+ requireRoot(ctx);
79
+ if (command.action === "revoke")
80
+ return revokeDelegatedCommands(command.friendId, ctx);
81
+ const expiresAt = parseExpiry(command.expires, ctx.now);
82
+ const friend = await requireFriend(ctx, command.friendId);
83
+ if (expiresAt === undefined && (0, replay_grant_holders_1.isReplayGrantHolder)(ctx.agentRoot, friend.id))
84
+ throw new Error(`refusing: ${friend.name} is a replay peer (it is on the replay gate's list in ${(0, operator_trust_1.operatorTrustDir)(ctx.agentRoot)}), or that list is present but not trustworthy, and a replay peer's grant must expire. Pass --expires <ISO date>. Nothing was written.`);
85
+ const did = checkedDid(friend, command.did);
86
+ const selfNotes = await vetPinnedDid(ctx, friend, did, (0, delegated_command_grants_1.readDelegatedCommandGrants)(ctx.agentRoot));
87
+ const change = (0, delegated_command_grants_1.setDelegatedCommandGrant)(ctx.agentRoot, friend.id, { grant: true, did, source: command.source ?? `ouro a2a delegated-commands grant, ${ctx.now.toISOString()}`, ...(expiresAt ? { expiresAt } : {}) }, ctx.now);
88
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_trust_grant_changed", message: "operator changed a trust grant", meta: { action: "grant", scope: "delegated_commands" } });
89
+ return [
90
+ `granted delegated commands: ${friend.name} (${friend.id})${change.changed ? "" : " (no change)"}`,
91
+ `pinned DID: ${did}`,
92
+ `expires: ${expiresAt ?? "never"}`,
93
+ `trust directory: ${(0, operator_trust_1.operatorTrustDir)(ctx.agentRoot)}`,
94
+ ...(change.backup ? [`backup: ${change.backup}`] : []),
95
+ ...selfNotes,
96
+ ...((0, delegated_command_grants_1.readDelegatedCommandGrants)(ctx.agentRoot)[friend.id] === undefined ? [`WARNING: the grant is written but will not be honoured: ${(0, delegated_command_grants_1.delegatedCommandGrantsPath)(ctx.agentRoot)} and every directory above it must be owned by root and writable by no one else. Run \`ouro a2a delegated-commands list\` for what the harness sees.`] : []),
97
+ ...notActiveFamilyNote(friend, ""),
98
+ ].join("\n");
99
+ }
100
+ /** The follow-up that clears the legacy record field as the resident user: the record is the Butler's own file, and this command runs as root, so it never writes it. */
101
+ function clearLegacyGrantCommand(agentRoot, friendId) {
102
+ const file = `${agentRoot}/friends/${friendId}.json`;
103
+ const script = `const fs=require("fs");const f=${JSON.stringify(file)};const r=JSON.parse(fs.readFileSync(f,"utf8"));delete r.delegationGrant;fs.writeFileSync(f,JSON.stringify(r,null,2)+"\\n")`;
104
+ return [
105
+ "REQUIRED FOLLOW-UP: the trusted grant is removed, but the legacy delegationGrant on the friend record is still there (friend records are never written as root). An older harness would honour it after a rollback. Clear it as the resident user:",
106
+ ` docker exec -u 10001 ouro-butler node -e '${script}'`,
107
+ ].join("\n");
108
+ }
109
+ async function revokeDelegatedCommands(friendId, ctx) {
110
+ const friend = await ctx.store.get(friendId);
111
+ const change = (0, delegated_command_grants_1.setDelegatedCommandGrant)(ctx.agentRoot, friendId, { grant: false }, ctx.now);
112
+ // A rollback to an older harness reads the legacy record grant. This command runs as root and must not write the Butler's friend record, so it prints the resident-user follow-up instead.
113
+ const legacyFollowUp = friend?.delegationGrant !== undefined ? clearLegacyGrantCommand(ctx.agentRoot, friend.id) : null;
114
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_trust_grant_changed", message: "operator changed a trust grant", meta: { action: "revoke", scope: "delegated_commands" } });
115
+ return [
116
+ `revoked delegated commands: ${friend ? `${friend.name} (${friend.id})` : `${friendId} (no friend record)`}${change.changed || legacyFollowUp ? "" : " (no change)"}`,
117
+ ...(legacyFollowUp ? [legacyFollowUp] : []),
118
+ ...(change.backup ? [`backup: ${change.backup}`] : []),
119
+ ].join("\n");
120
+ }
121
+ function statusOf(friendId, grant, friend, ctx) {
122
+ if (!friend)
123
+ return { status: "not_honoured", reason: "no_friend_record", text: `no friend record for ${friendId}` };
124
+ const check = (0, delegated_command_grants_1.checkDelegatedCommandGrant)(ctx.agentRoot, friend, (0, resolution_proof_1.friendDid)(friend), ctx.now.getTime());
125
+ if (check.ok)
126
+ return { status: "honoured" };
127
+ switch (check.reason) {
128
+ case "not_family":
129
+ return { status: "suspended", reason: check.reason, text: `${friend.name} is trust ${friend.trustLevel}, admission ${friend.admissionState}; the grant is kept and applies again once it is family and active` };
130
+ case "grant_did_mismatch":
131
+ return { status: "not_honoured", reason: check.reason, text: `the friend record names ${(0, resolution_proof_1.friendDid)(friend) ?? "no DID"}, but the grant pins ${grant.did}` };
132
+ default:
133
+ return { status: "not_honoured", reason: check.reason, text: "the grant is not in force: it expired, this is a replay identity outside its window, or the grant file stopped being trusted" };
134
+ }
135
+ }
136
+ async function listDelegatedCommands(ctx, json) {
137
+ const file = (0, delegated_command_grants_1.delegatedCommandGrantsPath)(ctx.agentRoot);
138
+ const view = (0, delegated_command_grants_1.viewDelegatedCommandGrants)(ctx.agentRoot);
139
+ const friends = await ctx.store.listAll?.() ?? [];
140
+ const byId = new Map(friends.map((friend) => [friend.id, friend]));
141
+ const entries = Object.entries(view.grants).map(([friendId, grant]) => {
142
+ const friend = byId.get(friendId) ?? null;
143
+ return { friendId, name: friend?.name ?? null, grant, ...statusOf(friendId, grant, friend, ctx) };
144
+ });
145
+ const legacy = friends.filter((friend) => friend.delegationGrant !== undefined);
146
+ if (json) {
147
+ return JSON.stringify({
148
+ agent: ctx.agentName,
149
+ file: { path: file, state: view.state, ...(view.state === "untrusted" ? { reason: view.reason } : {}) },
150
+ grants: entries.map(({ friendId, name, grant, ...status }) => ({ friendId, name, did: grant.did, grantedAt: grant.grantedAt, source: grant.source, expiresAt: grant.expiresAt ?? null, ...status })),
151
+ ignoredEntries: view.ignored,
152
+ legacyRecordGrants: legacy.map((friend) => ({ friendId: friend.id, name: friend.name })),
153
+ }, null, 2);
154
+ }
155
+ const header = view.state === "trusted" ? `trusted: ${file}` : view.state === "missing" ? `not present: ${file}` : `NOT TRUSTED: ${file} (${view.reason}); no grant in it is honoured`;
156
+ const lines = [`delegated-command grants for ${ctx.agentName}`, header];
157
+ if (entries.length === 0)
158
+ lines.push("no delegated-command grants");
159
+ for (const entry of entries) {
160
+ lines.push(`${entry.friendId} ${entry.name ?? "(no friend record)"}`, ` DID ${entry.grant.did}; granted ${entry.grant.grantedAt} (${entry.grant.source}); expires ${entry.grant.expiresAt ?? "never"}`, entry.status === "honoured" ? " HONOURED" : entry.status === "suspended" ? ` SUSPENDED: ${entry.text}` : ` NOT HONOURED: ${entry.text}`);
161
+ }
162
+ if (view.ignored.length > 0)
163
+ lines.push(`ignored (malformed) entries: ${view.ignored.join(", ")}`);
164
+ if (legacy.length > 0)
165
+ lines.push(`legacy friend-record grants (not honoured): ${legacy.map((friend) => `${friend.name} (${friend.id})`).join(", ")}`);
166
+ return lines.join("\n");
167
+ }
168
+ async function executeEscalationCommand(command, ctx) {
169
+ if (command.action === "list") {
170
+ const grants = Object.entries((0, escalation_grants_1.readEscalationGrants)(ctx.agentRoot));
171
+ const view = (0, escalation_grants_1.viewEscalationGrants)(ctx.agentRoot);
172
+ const note = view.state === "untrusted" ? `\nNOT TRUSTED: ${(0, escalation_grants_1.escalationGrantsPath)(ctx.agentRoot)} (${view.reason}); no grant in it is honoured` : "";
173
+ return (grants.length === 0 ? "no escalation grants" : grants.map(([id, grant]) => `${id} granted ${grant.grantedAt} ${grant.did} ${grant.source}`).join("\n")) + note;
174
+ }
175
+ requireRoot(ctx);
176
+ if (command.action === "revoke")
177
+ return revokeEscalation(command.friendId, ctx);
178
+ const friend = await requireFriend(ctx, command.friendId);
179
+ if ((0, replay_grant_holders_1.isReplayGrantHolder)(ctx.agentRoot, friend.id))
180
+ throw new Error(`refusing: ${friend.name} is a replay peer (it is on the replay gate's list in ${(0, operator_trust_1.operatorTrustDir)(ctx.agentRoot)}). Only the replay gate grants it escalation, and only while a run is open. Nothing was written.`);
181
+ const did = checkedDid(friend, command.did);
182
+ const selfNotes = await vetPinnedDid(ctx, friend, did, (0, escalation_grants_1.readEscalationGrants)(ctx.agentRoot));
183
+ const change = (0, escalation_grants_1.setEscalationGrant)(ctx.agentRoot, friend.id, { grant: true, source: command.source ?? `ouro a2a escalation grant, ${ctx.now.toISOString()}`, did }, ctx.now);
184
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_trust_grant_changed", message: "operator changed a trust grant", meta: { action: "grant", scope: "escalation" } });
185
+ return [
186
+ `granted escalation: ${friend.name} (${friend.id})${change.changed ? "" : " (no change)"}`,
187
+ `pinned DID: ${did}`,
188
+ ...(change.backup ? [`backup: ${change.backup}`] : []),
189
+ ...selfNotes,
190
+ ...((0, escalation_grants_1.readEscalationGrants)(ctx.agentRoot)[friend.id] === undefined ? [`WARNING: the grant is written but will not be honoured: ${(0, escalation_grants_1.escalationGrantsPath)(ctx.agentRoot)} and every directory above it must be owned by root and writable by no one else.`] : []),
191
+ ...(friend.trustLevel !== "family" || friend.admissionState !== "active" ? [`note: ${friend.name} only holds the grant while it is active family (now ${friend.trustLevel}, ${friend.admissionState})`] : []),
192
+ ].join("\n");
193
+ }
194
+ /** Revoke needs no friend record: a grant for a friend that was deleted must still be removable by its id. */
195
+ async function revokeEscalation(friendId, ctx) {
196
+ const friend = await ctx.store.get(friendId);
197
+ const change = (0, escalation_grants_1.setEscalationGrant)(ctx.agentRoot, friendId, { grant: false }, ctx.now);
198
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_trust_grant_changed", message: "operator changed a trust grant", meta: { action: "revoke", scope: "escalation" } });
199
+ return [
200
+ `revoked escalation: ${friend ? `${friend.name} (${friend.id})` : `${friendId} (no friend record)`}${change.changed ? "" : " (no change)"}`,
201
+ ...(change.backup ? [`backup: ${change.backup}`] : []),
202
+ ].join("\n");
203
+ }
@@ -35,17 +35,28 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.TRUSTED_UID = void 0;
37
37
  exports.overrideTrustedUidForTests = overrideTrustedUidForTests;
38
+ exports.overrideTrustChainRootForTests = overrideTrustChainRootForTests;
39
+ exports.overrideOwnerForTests = overrideOwnerForTests;
40
+ exports.__resetRejectionRateLimitForTests = __resetRejectionRateLimitForTests;
38
41
  exports.isTrustedDirectory = isTrustedDirectory;
42
+ exports.inspectTrustedDirectory = inspectTrustedDirectory;
43
+ exports.inspectTrustedJson = inspectTrustedJson;
39
44
  exports.readTrustedJson = readTrustedJson;
40
45
  const fs = __importStar(require("node:fs"));
46
+ const path = __importStar(require("node:path"));
41
47
  const runtime_1 = require("../nerves/runtime");
42
48
  /**
43
- * Operator-set state lives in the agent bundle, which the agent's own process (and anything a prompt-injected model can
44
- * run through its shell) can write. Such state only counts when root owns it and nobody else can write it, the same
45
- * rule the replay window follows. Anything else reads as "not there", so every consumer fails closed.
49
+ * Operator-set state is only honoured when the agent's own process (and anything a prompt-injected model can run
50
+ * through its shell) cannot have written it. The rule is the one sshd calls StrictModes: the file and EVERY directory
51
+ * above it, up to `/`, must be real (no symlinks), owned by the trusted uid (root), and closed to group and other
52
+ * writes. A check on the file's own directory alone is not enough: an agent that owns the parent can rename the
53
+ * root-owned directory away and put one of its own making in its place (rename-then-rebless). Anything that fails reads
54
+ * as "not there", so every consumer fails closed.
46
55
  */
47
56
  exports.TRUSTED_UID = 0;
48
57
  let uidOverride;
58
+ let chainRootOverride;
59
+ let ownerOverride;
49
60
  /** Only a test runner sets VITEST; the running Butler (and its prompt-injected shell) cannot reach this process's environment. */
50
61
  const underTestRunner = () => process.env.VITEST !== undefined;
51
62
  /** Tests run as an ordinary user, so they declare that user trusted. Refused outside a test runner, and ignored there too. */
@@ -54,45 +65,121 @@ function overrideTrustedUidForTests(uid) {
54
65
  throw new Error("overrideTrustedUidForTests is for tests only");
55
66
  uidOverride = uid;
56
67
  }
68
+ /**
69
+ * Tests keep their files under a private temp root whose ancestors (`/tmp`, `/private`) are not the test user's. A test
70
+ * names that root here, and the ancestor walk stops there (the root itself and everything above it are skipped).
71
+ */
72
+ function overrideTrustChainRootForTests(root) {
73
+ if (!underTestRunner())
74
+ throw new Error("overrideTrustChainRootForTests is for tests only");
75
+ chainRootOverride = root;
76
+ }
77
+ /** Tests are not root, so they cannot hand a path to another owner: this reports the owner a test wants for a path (`undefined` keeps the real one). */
78
+ function overrideOwnerForTests(lookup) {
79
+ if (!underTestRunner())
80
+ throw new Error("overrideOwnerForTests is for tests only");
81
+ ownerOverride = lookup;
82
+ }
83
+ const ownerOf = (target, stat) => (underTestRunner() ? ownerOverride?.(target) : undefined) ?? stat.uid;
57
84
  const effectiveTrustedUid = () => (underTestRunner() ? uidOverride : undefined) ?? exports.TRUSTED_UID;
58
- /** A real directory (no symlink), owned by the trusted uid, writable by neither group nor other. */
59
- function isTrustedDirectory(target, trustedUid = effectiveTrustedUid()) {
60
- let stat;
61
- try {
62
- stat = fs.lstatSync(target);
63
- }
64
- catch {
65
- return false;
66
- }
67
- const trusted = stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === trustedUid && (stat.mode & 0o022) === 0;
68
- if (!trusted)
69
- rejected(target, "directory is not a real directory owned by the trusted uid and closed to group and other writes");
70
- return trusted;
85
+ const effectiveChainRoot = () => (underTestRunner() ? chainRootOverride : undefined);
86
+ const REJECTION_WINDOW_MS = 60_000;
87
+ const lastRejection = new Map();
88
+ /** Test hook: the rate limiter is module state. */
89
+ function __resetRejectionRateLimitForTests() {
90
+ lastRejection.clear();
71
91
  }
92
+ /** One warn per target and reason per minute: a rejected file is re-read on every inbound message. */
72
93
  function rejected(target, reason) {
94
+ const key = `${target}\u0000${reason}`;
95
+ const now = Date.now();
96
+ const last = lastRejection.get(key);
97
+ if (last !== undefined && now - last < REJECTION_WINDOW_MS)
98
+ return;
99
+ lastRejection.set(key, now);
73
100
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_trusted_file_rejected", message: "ignored operator-set state the agent could have written", meta: { target, reason } });
74
101
  }
102
+ /** Every directory from `target` up to `/` (or the test chain root): a real directory, trusted owner, closed to group and other writes. */
103
+ function inspectChain(target, trustedUid) {
104
+ const stop = effectiveChainRoot();
105
+ let current = path.resolve(target);
106
+ for (;;) {
107
+ if (stop !== undefined && current === stop)
108
+ return { state: "trusted" };
109
+ let stat;
110
+ try {
111
+ stat = fs.lstatSync(current);
112
+ }
113
+ catch (error) {
114
+ return error.code === "ENOENT" || error.code === "ENOTDIR"
115
+ ? { state: "missing" }
116
+ : { state: "untrusted", reason: `cannot inspect ${current}` };
117
+ }
118
+ if (stat.isSymbolicLink())
119
+ return { state: "untrusted", reason: `${current} is a symlink` };
120
+ if (!stat.isDirectory())
121
+ return { state: "untrusted", reason: `${current} is not a directory` };
122
+ if (ownerOf(current, stat) !== trustedUid)
123
+ return { state: "untrusted", reason: `${current} is not owned by the trusted uid` };
124
+ if ((stat.mode & 0o022) !== 0)
125
+ return { state: "untrusted", reason: `${current} is writable by group or other` };
126
+ const parent = path.dirname(current);
127
+ if (parent === current)
128
+ return { state: "trusted" };
129
+ current = parent;
130
+ }
131
+ }
132
+ /** A real directory (no symlink) owned by the trusted uid and closed to group and other writes, with every ancestor up to `/` the same. */
133
+ function isTrustedDirectory(target, trustedUid = effectiveTrustedUid()) {
134
+ const result = inspectChain(target, trustedUid);
135
+ if (result.state === "trusted")
136
+ return true;
137
+ rejected(target, result.state === "missing" ? "directory does not exist" : result.reason);
138
+ return false;
139
+ }
140
+ /** Same chain check, without the warning: for callers that probe before they decide whether the state is wrong. */
141
+ function inspectTrustedDirectory(target, trustedUid = effectiveTrustedUid()) {
142
+ return inspectChain(target, trustedUid);
143
+ }
75
144
  /**
76
- * Parses a JSON file only if the file actually opened is trusted. The path is opened once with O_NOFOLLOW, then the
77
- * descriptor itself is checked (regular file, trusted owner, not group- or other-writable) and read, so a rename loop
78
- * cannot swap a different file in between the check and the read. Any error is `undefined`.
145
+ * Reads a JSON file only if the whole chain above it is trusted and the file actually opened is trusted. The path is
146
+ * opened once with O_NOFOLLOW, then the descriptor itself is checked (regular file, trusted owner, not group- or
147
+ * other-writable) and read, so a rename loop cannot swap a different file in between the check and the read.
148
+ * `missing` means the file or a directory above it does not exist; everything else that fails is `untrusted`.
79
149
  */
80
- function readTrustedJson(file, trustedUid = effectiveTrustedUid()) {
150
+ function inspectTrustedJson(file, trustedUid = effectiveTrustedUid()) {
151
+ const chain = inspectChain(path.dirname(file), trustedUid);
152
+ if (chain.state === "missing")
153
+ return { state: "missing" };
154
+ if (chain.state === "untrusted") {
155
+ rejected(file, chain.reason);
156
+ return chain;
157
+ }
81
158
  let fd;
82
159
  try {
83
160
  fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
84
161
  const stat = fs.fstatSync(fd);
85
- if (!stat.isFile() || stat.uid !== trustedUid || (stat.mode & 0o022) !== 0) {
86
- rejected(file, "file is not a regular file owned by the trusted uid and closed to group and other writes");
87
- return undefined;
162
+ if (!stat.isFile() || ownerOf(file, stat) !== trustedUid || (stat.mode & 0o022) !== 0) {
163
+ const reason = "file is not a regular file owned by the trusted uid and closed to group and other writes";
164
+ rejected(file, reason);
165
+ return { state: "untrusted", reason };
88
166
  }
89
- return JSON.parse(fs.readFileSync(fd, "utf8"));
167
+ return { state: "trusted", value: JSON.parse(fs.readFileSync(fd, "utf8")) };
90
168
  }
91
- catch {
92
- return undefined;
169
+ catch (error) {
170
+ if (error.code === "ENOENT")
171
+ return { state: "missing" };
172
+ const reason = error instanceof SyntaxError ? "file is not valid JSON" : "file could not be opened without following links";
173
+ rejected(file, reason);
174
+ return { state: "untrusted", reason };
93
175
  }
94
176
  finally {
95
177
  if (fd !== undefined)
96
178
  fs.closeSync(fd);
97
179
  }
98
180
  }
181
+ /** The parsed JSON of a trusted file, or `undefined` for anything else. */
182
+ function readTrustedJson(file, trustedUid = effectiveTrustedUid()) {
183
+ const result = inspectTrustedJson(file, trustedUid);
184
+ return result.state === "trusted" ? result.value : undefined;
185
+ }
@@ -62,6 +62,7 @@ const obligations_1 = require("../arc/obligations");
62
62
  const tool_loop_1 = require("./tool-loop");
63
63
  const packets_1 = require("../arc/packets");
64
64
  const tool_friction_1 = require("./tool-friction");
65
+ const delegated_command_1 = require("../a2a/delegated-command");
65
66
  const provider_models_1 = require("./provider-models");
66
67
  const provider_credentials_1 = require("./provider-credentials");
67
68
  const habit_session_1 = require("./habits/habit-session");
@@ -1101,6 +1102,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1101
1102
  relationshipProfileId: options?.toolContext?.relationshipAuthorization?.profileId,
1102
1103
  relationshipToolNames: options?.toolContext?.relationshipAuthorization?.advertisedToolNames,
1103
1104
  orientationFrame: turnOrientationFrame,
1105
+ ...(options?.toolContext?.delegatedCommand ? { delegatedCommand: options.toolContext.delegatedCommand } : {}),
1104
1106
  resumePriorWork: options?.resumePriorWork === true,
1105
1107
  providerCapabilities: providerRuntime.capabilities,
1106
1108
  supportedReasoningEfforts: providerRuntime.supportedReasoningEfforts,
@@ -1903,8 +1905,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1903
1905
  }
1904
1906
  callbacks.onToolEnd(soleTerminalCall.name, (0, tools_1.summarizeArgs)(soleTerminalCall.name, terminalArgs, toolSelection), terminalSucceeded);
1905
1907
  pushGenerated(msg);
1906
- pushGenerated({ role: "tool", tool_call_id: soleTerminalCall.id, content: terminalResult });
1907
- providerRuntime.appendToolOutput(soleTerminalCall.id, terminalResult);
1908
+ const shieldedTerminalResult = (0, delegated_command_1.shieldDelegatedBanner)(terminalResult);
1909
+ pushGenerated({ role: "tool", tool_call_id: soleTerminalCall.id, content: shieldedTerminalResult });
1910
+ providerRuntime.appendToolOutput(soleTerminalCall.id, shieldedTerminalResult);
1908
1911
  callbacks.onTextChunk(terminalResult);
1909
1912
  completion = { answer: terminalResult, intent: terminalSucceeded ? "complete" : "blocked" };
1910
1913
  outcome = terminalSucceeded ? "settled" : "blocked";
@@ -2469,8 +2472,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2469
2472
  toolResult = error instanceof Error ? error.message : String(error);
2470
2473
  }
2471
2474
  callbacks.onToolEnd(tc.name, argSummary, success);
2472
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: toolResult });
2473
- providerRuntime.appendToolOutput(tc.id, toolResult);
2475
+ const shieldedToolResult = (0, delegated_command_1.shieldDelegatedBanner)(toolResult);
2476
+ pushGenerated({ role: "tool", tool_call_id: tc.id, content: shieldedToolResult });
2477
+ providerRuntime.appendToolOutput(tc.id, shieldedToolResult);
2474
2478
  continue;
2475
2479
  }
2476
2480
  /* v8 ignore next -- flag tested via truth-check integration tests @preserve */
@@ -2508,7 +2512,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2508
2512
  if (invoked && requiredToolCallNames.includes(tc.name) && !options?.requiredToolCalls?.requireSuccessfulResults)
2509
2513
  dispatchedRequiredToolCalls.add(tc.name);
2510
2514
  turnToolRecords.push({ name: tc.name, args, result: toolResult });
2511
- const modelResult = (0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger);
2515
+ const modelResult = (0, delegated_command_1.shieldDelegatedBanner)((0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger));
2512
2516
  pushGenerated({ role: "tool", tool_call_id: tc.id, content: modelResult });
2513
2517
  providerRuntime.appendToolOutput(tc.id, modelResult);
2514
2518
  if (!success)
@@ -526,6 +526,7 @@ function agentResolutionFailureMode(command) {
526
526
  case "a2a.onboard":
527
527
  case "a2a.serve":
528
528
  case "a2a.escalation":
529
+ case "a2a.delegatedCommands":
529
530
  return "throw";
530
531
  case "provider.use":
531
532
  case "provider.check":
@@ -6119,16 +6120,28 @@ async function executeA2AClientCommand(command, deps) {
6119
6120
  deps.writeStdout(message);
6120
6121
  return message;
6121
6122
  }
6122
- const { sendSealedA2AChat } = await Promise.resolve().then(() => __importStar(require("../../a2a/client")));
6123
- const reply = await sendSealedA2AChat({
6124
- cardUrl: command.to,
6125
- text: command.text,
6126
- ...(command.conversationId ? { conversationId: command.conversationId } : {}),
6127
- ...(command.delegated ? { onBehalfOf: "principal" } : {}),
6128
- identity,
6129
- /* v8 ignore next -- production CLI uses global fetch; tests inject fetch for a hermetic peer @preserve */
6130
- ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
6131
- });
6123
+ const { sendSealedA2AChat, A2ARpcError } = await Promise.resolve().then(() => __importStar(require("../../a2a/client")));
6124
+ let reply;
6125
+ try {
6126
+ reply = await sendSealedA2AChat({
6127
+ cardUrl: command.to,
6128
+ text: command.text,
6129
+ ...(command.conversationId ? { conversationId: command.conversationId } : {}),
6130
+ ...(command.delegated ? { onBehalfOf: "principal" } : {}),
6131
+ identity,
6132
+ /* v8 ignore next -- production CLI uses global fetch; tests inject fetch for a hermetic peer @preserve */
6133
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
6134
+ });
6135
+ }
6136
+ catch (error) {
6137
+ // A refused delegated command is an answer, not a crash: with --json the caller gets the reason and the next step.
6138
+ const data = error instanceof A2ARpcError ? error.data : undefined;
6139
+ if (!command.json || typeof data?.reason !== "string")
6140
+ throw error;
6141
+ return returnCliFailure(deps, JSON.stringify({
6142
+ ok: false, reason: data.reason, nothingRan: data.nothingRan, message: error.message, retry: data.retry, next: data.next,
6143
+ }));
6144
+ }
6132
6145
  const message = command.json ? JSON.stringify(reply) : `${reply.peerName}: ${reply.text}\n[conversation: ${reply.conversationId}]`;
6133
6146
  deps.writeStdout(message);
6134
6147
  return message;
@@ -6153,34 +6166,22 @@ async function executeA2ACommand(command, deps) {
6153
6166
  deps.writeStdout(message);
6154
6167
  return message;
6155
6168
  }
6156
- if (command.kind === "a2a.escalation") {
6157
- const { readEscalationGrants, setEscalationGrant } = await Promise.resolve().then(() => __importStar(require("../../a2a/escalation-grants")));
6169
+ if (command.kind === "a2a.escalation" || command.kind === "a2a.delegatedCommands") {
6170
+ const { executeDelegatedCommandsCommand, executeEscalationCommand } = await Promise.resolve().then(() => __importStar(require("../../a2a/trust-commands")));
6158
6171
  /* v8 ignore next -- production default bundles root; tests inject bundlesRoot @preserve */
6159
6172
  const agentRoot = path.join(deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(), `${command.agent}.ouro`);
6160
- if (command.action === "list") {
6161
- const grants = Object.entries(readEscalationGrants(agentRoot));
6162
- const message = grants.length === 0 ? "no escalation grants" : grants.map(([id, grant]) => `${id} granted ${grant.grantedAt} ${grant.did} ${grant.source}`).join("\n");
6163
- deps.writeStdout(message);
6164
- return message;
6165
- }
6166
- const friend = await new friends_1.FileFriendStore(path.join(agentRoot, "friends")).get(command.friendId);
6167
- if (!friend)
6168
- throw new Error(`friend not found: ${command.friendId}`);
6169
- const at = new Date(deps.now?.() ?? Date.now());
6170
- const { friendDid } = await Promise.resolve().then(() => __importStar(require("../../a2a/resolution-proof")));
6171
- const holderDid = command.action === "grant" ? friendDid(friend) : null;
6172
- if (command.action === "grant" && !holderDid)
6173
- throw new Error(`${friend.name} has no pinned DID yet; onboard it with a DID before granting escalation`);
6174
- const change = command.action === "grant"
6175
- ? setEscalationGrant(agentRoot, friend.id, { grant: true, source: command.source ?? `ouro a2a escalation grant, ${at.toISOString()}`, did: holderDid }, at)
6176
- : setEscalationGrant(agentRoot, friend.id, { grant: false }, at);
6177
- const message = [
6178
- `${command.action === "grant" ? "granted" : "revoked"} escalation: ${friend.name} (${friend.id})${change.changed ? "" : " (no change)"}`,
6179
- ...(holderDid ? [`pinned DID: ${holderDid} (compare it with the holder's real DID)`] : []),
6180
- ...(change.backup ? [`backup: ${change.backup}`] : []),
6181
- ...(command.action === "grant" && readEscalationGrants(agentRoot)[friend.id] === undefined ? [`WARNING: the grant is written but will not be honoured: ${path.join(agentRoot, "state", "a2a")} and escalation-grants.json must be owned by root and writable by no one else. Run this command as root (docker exec as root) or chown root:root and chmod 755/644 them.`] : []),
6182
- ...(command.action === "grant" && (friend.trustLevel !== "family" || friend.admissionState !== "active") ? [`note: ${friend.name} only holds the grant while it is active family (now ${friend.trustLevel}, ${friend.admissionState})`] : []),
6183
- ].join("\n");
6173
+ const context = {
6174
+ agentName: command.agent,
6175
+ agentRoot,
6176
+ store: new friends_1.FileFriendStore(path.join(agentRoot, "friends")),
6177
+ now: new Date(deps.now?.() ?? Date.now()),
6178
+ /* v8 ignore next -- production reads the real euid; tests inject isRoot @preserve */
6179
+ isRoot: deps.isRoot ? deps.isRoot() : process.geteuid?.() === 0,
6180
+ /* v8 ignore next -- production reads the stored identity; tests inject ownA2ADid @preserve */
6181
+ ownDid: async () => deps.ownA2ADid ? deps.ownA2ADid(command.agent) : (await Promise.resolve().then(() => __importStar(require("../../a2a/identity")))).readOwnA2ADid(command.agent),
6182
+ publishedDid: async () => (await Promise.resolve().then(() => __importStar(require("../../a2a/identity")))).readPublishedA2ADid(agentRoot),
6183
+ };
6184
+ const message = command.kind === "a2a.escalation" ? await executeEscalationCommand(command, context) : await executeDelegatedCommandsCommand(command, context);
6184
6185
  deps.writeStdout(message);
6185
6186
  return message;
6186
6187
  }
@@ -7962,7 +7963,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7962
7963
  deps.writeStdout(message);
7963
7964
  return message;
7964
7965
  }
7965
- if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve" || command.kind === "a2a.escalation") {
7966
+ if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve" || command.kind === "a2a.escalation" || command.kind === "a2a.delegatedCommands") {
7966
7967
  return executeA2ACommand(command, deps);
7967
7968
  }
7968
7969
  // ── provider commands (local, no daemon socket needed) ──
@@ -157,8 +157,12 @@ function usage() {
157
157
  " ouro a2a outbox list --to <card-url> [--since <cursor>] [--identity-file <path>] [--json]",
158
158
  " ouro a2a outbox ack --to <card-url> --ids <id,id,...> [--identity-file <path>] [--json]",
159
159
  " ouro a2a outbox resolve --to <card-url> --report <id> --version <version> --note <text> [--identity-file <path>] [--json]",
160
- " ouro a2a escalation <grant|revoke> --friend <id> [--source <text>] [--agent <name>]",
160
+ " ouro a2a escalation grant --friend <id> --did <did:key> [--source <text>] [--agent <name>] (run as root)",
161
+ " ouro a2a escalation revoke --friend <id> [--agent <name>] (run as root)",
161
162
  " ouro a2a escalation list [--agent <name>]",
163
+ " ouro a2a delegated-commands grant --friend <id> --did <did:key> [--expires <ISO date>] [--source <text>] [--agent <name>] (run as root)",
164
+ " ouro a2a delegated-commands revoke --friend <id> [--agent <name>] (run as root)",
165
+ " ouro a2a delegated-commands list [--json] [--agent <name>]",
162
166
  " ouro whoami [--agent <name>]",
163
167
  " ouro session list [--agent <name>]",
164
168
  " ouro mcp list",
@@ -1593,12 +1597,13 @@ function parseA2ACommand(args) {
1593
1597
  };
1594
1598
  }
1595
1599
  if (sub === "escalation") {
1596
- const usageText = "Usage: ouro a2a escalation <grant|revoke> --friend <id> [--source <text>] [--agent <name>] | ouro a2a escalation list [--agent <name>]";
1600
+ const usageText = "Usage: ouro a2a escalation grant --friend <id> --did <did:key> [--source <text>] [--agent <name>] | ouro a2a escalation revoke --friend <id> [--agent <name>] | ouro a2a escalation list [--agent <name>]";
1597
1601
  const action = rest[0];
1598
1602
  if (action !== "grant" && action !== "revoke" && action !== "list")
1599
1603
  throw new Error(usageText);
1600
1604
  let friendId;
1601
1605
  let source;
1606
+ let did;
1602
1607
  for (let i = 1; i < rest.length; i += 1) {
1603
1608
  if (rest[i] === "--friend" && rest[i + 1]) {
1604
1609
  friendId = rest[++i];
@@ -1608,11 +1613,46 @@ function parseA2ACommand(args) {
1608
1613
  source = rest[++i];
1609
1614
  continue;
1610
1615
  }
1616
+ if (rest[i] === "--did" && rest[i + 1]) {
1617
+ did = rest[++i];
1618
+ continue;
1619
+ }
1620
+ throw new Error(usageText);
1621
+ }
1622
+ if ((action === "list") === Boolean(friendId) || ((source || did) && action !== "grant") || (action === "grant" && !did))
1623
+ throw new Error(usageText);
1624
+ return { kind: "a2a.escalation", action, ...(agent ? { agent } : {}), ...(friendId ? { friendId } : {}), ...(did ? { did } : {}), ...(source ? { source } : {}) };
1625
+ }
1626
+ if (sub === "delegated-commands") {
1627
+ const usageText = "Usage: ouro a2a delegated-commands grant --friend <id> --did <did:key> [--expires <ISO date>] [--source <text>] [--agent <name>] | ouro a2a delegated-commands revoke --friend <id> [--agent <name>] | ouro a2a delegated-commands list [--json] [--agent <name>]";
1628
+ const action = rest[0];
1629
+ if (action !== "grant" && action !== "revoke" && action !== "list")
1630
+ throw new Error(usageText);
1631
+ const values = {};
1632
+ let json = false;
1633
+ for (let i = 1; i < rest.length; i += 1) {
1634
+ if (rest[i] === "--json") {
1635
+ json = true;
1636
+ continue;
1637
+ }
1638
+ if (["--friend", "--did", "--expires", "--source"].includes(rest[i]) && rest[i + 1]) {
1639
+ values[rest[i]] = rest[++i];
1640
+ continue;
1641
+ }
1611
1642
  throw new Error(usageText);
1612
1643
  }
1613
- if ((action === "list") === Boolean(friendId) || (source && action !== "grant"))
1644
+ const allowed = { grant: ["--friend", "--did", "--expires", "--source"], revoke: ["--friend"], list: [] };
1645
+ const required = { grant: ["--friend", "--did"], revoke: ["--friend"], list: [] };
1646
+ if (Object.keys(values).some((flag) => !allowed[action].includes(flag)) || required[action].some((flag) => !values[flag]) || (json && action !== "list"))
1614
1647
  throw new Error(usageText);
1615
- return { kind: "a2a.escalation", action, ...(agent ? { agent } : {}), ...(friendId ? { friendId } : {}), ...(source ? { source } : {}) };
1648
+ return {
1649
+ kind: "a2a.delegatedCommands", action, ...(agent ? { agent } : {}),
1650
+ ...(values["--friend"] ? { friendId: values["--friend"] } : {}),
1651
+ ...(values["--did"] ? { did: values["--did"] } : {}),
1652
+ ...(values["--expires"] ? { expires: values["--expires"] } : {}),
1653
+ ...(values["--source"] ? { source: values["--source"] } : {}),
1654
+ ...(json ? { json: true } : {}),
1655
+ };
1616
1656
  }
1617
1657
  if (sub === "onboard") {
1618
1658
  let cardUrl;
@@ -1676,7 +1716,7 @@ function parseA2ACommand(args) {
1676
1716
  }
1677
1717
  return { kind: "a2a.serve", ...(agent ? { agent } : {}), ...(host ? { host } : {}), ...(port ? { port } : {}), ...(baseUrl ? { baseUrl } : {}), ...(path ? { path } : {}) };
1678
1718
  }
1679
- throw new Error("Usage: ouro a2a card|onboard|serve|message|identity|outbox|escalation ...");
1719
+ throw new Error("Usage: ouro a2a card|onboard|serve|message|identity|outbox|escalation|delegated-commands ...");
1680
1720
  }
1681
1721
  function parseConfigCommand(args) {
1682
1722
  const { agent, rest: afterAgent } = extractAgentFlag(args);