@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.
- 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 +473 -74
- 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 +60 -33
- 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
|
@@ -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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
77
|
-
* descriptor itself is checked (regular file, trusted owner, not group- or
|
|
78
|
-
* cannot swap a different file in between the check and the read.
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/heart/core.js
CHANGED
|
@@ -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
|
-
|
|
1907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2473
|
-
|
|
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)
|