@ouro.bot/cli 0.1.0-alpha.886 → 0.1.0-alpha.888

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 +473 -74
  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 +60 -33
  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,
@@ -1206,6 +1208,38 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1206
1208
  rejectedAttempt = [structuredClone(assistant)];
1207
1209
  nextAttemptControls = controls;
1208
1210
  };
1211
+ // Answer gates run only for agents that opt in (ToolContext.answerGates), on every reply the person would receive: a settle answer
1212
+ // and a plain-text reply with no tool call alike (alpha.886 replay: a plain-text reply skipped them and an invented cast went out).
1213
+ // A request-specific contract (options.requiredToolCalls) already validates its own terminal answer. Each gate has its own bounded
1214
+ // retry, and none spends the last provider iteration.
1215
+ const answerGateVerdict = (answer, intent) => {
1216
+ const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1217
+ const retriesLeft = providerIterations < stepBudget - 1;
1218
+ let groundingError = null;
1219
+ let unverifiedNames = null;
1220
+ const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer, userText: userTexts(messages).join("\n"), latestUserText: latestUserMessageText(messages.slice(0, turnStartLength)), knownText: priorTurnsText(messages.slice(0, turnStartLength)), tools: turnToolRecords }) : null;
1221
+ if (grounding) {
1222
+ if (groundingRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft) {
1223
+ groundingRejections += 1;
1224
+ groundingError = grounding.message;
1225
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer named things from a work that the turn did not look up; sending the model back", meta: { gate: "source_grounding", rejection: groundingRejections, cap: ANSWER_GATE_MAX_REJECTIONS, lookupsRun: grounding.lookups } });
1226
+ }
1227
+ else {
1228
+ unverifiedNames = grounding.names;
1229
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1230
+ }
1231
+ }
1232
+ let brevityError = null;
1233
+ if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1234
+ && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
1235
+ brevityError = (0, reply_style_1.briefStyleViolation)(answer, intent);
1236
+ if (brevityError) {
1237
+ brevityRejections += 1;
1238
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
1239
+ }
1240
+ }
1241
+ return { error: groundingError ?? brevityError, unverifiedNames };
1242
+ };
1209
1243
  const rejectToolBatch = (assistant, reasons) => {
1210
1244
  rejectAttempt(assistant, assistant.tool_calls.map((call, index) => ({
1211
1245
  role: "tool", tool_call_id: call.id, content: reasons[index],
@@ -1772,6 +1806,24 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1772
1806
  }]);
1773
1807
  continue;
1774
1808
  }
1809
+ // A plain-text reply reaches the person just as a settle answer does, so the answer gates hold it too.
1810
+ const textAnswer = isPrivateRuntimeChannel ? "" : stripThinkBlocksForViolationCheck(result.content).trim();
1811
+ const textVerdict = textAnswer ? answerGateVerdict(textAnswer) : null;
1812
+ if (textVerdict?.error) {
1813
+ streamCallbackBuffer?.discard();
1814
+ callbacks.onClearText?.();
1815
+ rejectAttempt(msg, [{ role: "user", content: textVerdict.error }]);
1816
+ continue;
1817
+ }
1818
+ if (textVerdict?.unverifiedNames) {
1819
+ const disclosed = (0, source_grounding_1.withUnverifiedDisclosure)(textAnswer, textVerdict.unverifiedNames);
1820
+ streamCallbackBuffer?.discard();
1821
+ callbacks.onClearText?.();
1822
+ callbacks.onTextChunk(disclosed);
1823
+ pushGenerated({ ...msg, content: disclosed });
1824
+ done = true;
1825
+ continue;
1826
+ }
1775
1827
  // Legitimate text-only response, or cap reached — accept as-is.
1776
1828
  await streamCallbackBuffer?.flush();
1777
1829
  pushGenerated(msg);
@@ -1903,8 +1955,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1903
1955
  }
1904
1956
  callbacks.onToolEnd(soleTerminalCall.name, (0, tools_1.summarizeArgs)(soleTerminalCall.name, terminalArgs, toolSelection), terminalSucceeded);
1905
1957
  pushGenerated(msg);
1906
- pushGenerated({ role: "tool", tool_call_id: soleTerminalCall.id, content: terminalResult });
1907
- providerRuntime.appendToolOutput(soleTerminalCall.id, terminalResult);
1958
+ const shieldedTerminalResult = (0, delegated_command_1.shieldDelegatedBanner)(terminalResult);
1959
+ pushGenerated({ role: "tool", tool_call_id: soleTerminalCall.id, content: shieldedTerminalResult });
1960
+ providerRuntime.appendToolOutput(soleTerminalCall.id, shieldedTerminalResult);
1908
1961
  callbacks.onTextChunk(terminalResult);
1909
1962
  completion = { answer: terminalResult, intent: terminalSucceeded ? "complete" : "blocked" };
1910
1963
  outcome = terminalSucceeded ? "settled" : "blocked";
@@ -1973,34 +2026,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1973
2026
  sawSettleContentMismatch = true;
1974
2027
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
1975
2028
  }
1976
- // Answer gates run only for agents that opt in (ToolContext.answerGates). A request-specific contract (options.requiredToolCalls)
1977
- // already validates its own terminal answer. Each gate has its own bounded retry, and none spends the last provider iteration.
1978
- const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1979
- const retriesLeft = providerIterations < stepBudget - 1;
1980
- let groundingError = null;
1981
- let unverifiedNames = null;
1982
- const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer: deliveredAnswer, userText: userTexts(messages).join("\n"), latestUserText: latestUserMessageText(messages.slice(0, turnStartLength)), knownText: priorTurnsText(messages.slice(0, turnStartLength)), tools: turnToolRecords }) : null;
1983
- if (grounding) {
1984
- if (groundingRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft) {
1985
- groundingRejections += 1;
1986
- groundingError = grounding.message;
1987
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "settle answer named things from a work that the turn did not look up; sending the model back", meta: { gate: "source_grounding", rejection: groundingRejections, cap: ANSWER_GATE_MAX_REJECTIONS, lookupsRun: grounding.lookups } });
1988
- }
1989
- else {
1990
- unverifiedNames = grounding.names;
1991
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1992
- }
1993
- }
1994
- let brevityError = null;
1995
- if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1996
- && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
1997
- brevityError = (0, reply_style_1.briefStyleViolation)(deliveredAnswer, intent);
1998
- if (brevityError) {
1999
- brevityRejections += 1;
2000
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "settle answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
2001
- }
2002
- }
2003
- const answerGateError = groundingError ?? brevityError;
2029
+ const { error: answerGateError, unverifiedNames } = answerGateVerdict(deliveredAnswer, intent);
2004
2030
  const retryError = contentMismatch
2005
2031
  ?? answerGateError
2006
2032
  ?? privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
@@ -2469,8 +2495,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2469
2495
  toolResult = error instanceof Error ? error.message : String(error);
2470
2496
  }
2471
2497
  callbacks.onToolEnd(tc.name, argSummary, success);
2472
- pushGenerated({ role: "tool", tool_call_id: tc.id, content: toolResult });
2473
- providerRuntime.appendToolOutput(tc.id, toolResult);
2498
+ const shieldedToolResult = (0, delegated_command_1.shieldDelegatedBanner)(toolResult);
2499
+ pushGenerated({ role: "tool", tool_call_id: tc.id, content: shieldedToolResult });
2500
+ providerRuntime.appendToolOutput(tc.id, shieldedToolResult);
2474
2501
  continue;
2475
2502
  }
2476
2503
  /* v8 ignore next -- flag tested via truth-check integration tests @preserve */
@@ -2508,7 +2535,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2508
2535
  if (invoked && requiredToolCallNames.includes(tc.name) && !options?.requiredToolCalls?.requireSuccessfulResults)
2509
2536
  dispatchedRequiredToolCalls.add(tc.name);
2510
2537
  turnToolRecords.push({ name: tc.name, args, result: toolResult });
2511
- const modelResult = (0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger);
2538
+ const modelResult = (0, delegated_command_1.shieldDelegatedBanner)((0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger));
2512
2539
  pushGenerated({ role: "tool", tool_call_id: tc.id, content: modelResult });
2513
2540
  providerRuntime.appendToolOutput(tc.id, modelResult);
2514
2541
  if (!success)