@ouro.bot/cli 0.1.0-alpha.886 → 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 +21 -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 +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,382 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TrustDirectoryError = exports.TRUST_DIR_ENV = exports.OPERATOR_TRUST_ROOT = void 0;
37
+ exports.overrideTrustRootForTests = overrideTrustRootForTests;
38
+ exports.agentNameFromRoot = agentNameFromRoot;
39
+ exports.operatorTrustDir = operatorTrustDir;
40
+ exports.operatorTrustFile = operatorTrustFile;
41
+ exports.readGrantFile = readGrantFile;
42
+ exports.overrideFchownForTests = overrideFchownForTests;
43
+ exports.ensureTrustDirectory = ensureTrustDirectory;
44
+ exports.overrideDirectoryFlushForTests = overrideDirectoryFlushForTests;
45
+ exports.overrideLockTimeoutForTests = overrideLockTimeoutForTests;
46
+ exports.withTrustedWriteLock = withTrustedWriteLock;
47
+ exports.writeTrustedFile = writeTrustedFile;
48
+ exports.describeGrantFile = describeGrantFile;
49
+ const node_crypto_1 = require("node:crypto");
50
+ const fs = __importStar(require("node:fs"));
51
+ const os = __importStar(require("node:os"));
52
+ const path = __importStar(require("node:path"));
53
+ const runtime_1 = require("../nerves/runtime");
54
+ const trusted_files_1 = require("./trusted-files");
55
+ /**
56
+ * Operator-set grants live in a trust directory outside the agent bundle: `/etc/ouro/trust/<agent>/` by default, or
57
+ * `<OURO_OPERATOR_TRUST_DIR>/<agent>/` when that variable replaces the trust root. The agent's own uid cannot write there, and the bundle's recursive
58
+ * `chown` during an in-place upgrade cannot reach it. A file in it counts only when the whole chain above it passes
59
+ * the trusted-files check (real directories, root-owned, closed to group and other writes, up to `/`).
60
+ */
61
+ exports.OPERATOR_TRUST_ROOT = "/etc/ouro/trust";
62
+ exports.TRUST_DIR_ENV = "OURO_OPERATOR_TRUST_DIR";
63
+ let trustRoot = exports.OPERATOR_TRUST_ROOT;
64
+ /** Tests keep trust directories under their private temp root instead of `/etc`. Refused outside a test runner. */
65
+ function overrideTrustRootForTests(root) {
66
+ if (process.env.VITEST === undefined)
67
+ throw new Error("overrideTrustRootForTests is for tests only");
68
+ trustRoot = root ?? exports.OPERATOR_TRUST_ROOT;
69
+ }
70
+ /** The agent a bundle root belongs to: `~/AgentBundles/sanctuary.ouro` is `sanctuary`. */
71
+ function agentNameFromRoot(agentRoot) {
72
+ return path.basename(agentRoot).replace(/\.ouro$/u, "");
73
+ }
74
+ /**
75
+ * The real path of `target`, resolved once. The deepest ancestor that exists is resolved through every symlink and the rest of
76
+ * the (not yet created) tail is appended, so a configured `/etc/ouro/trust` becomes `/private/etc/ouro/trust` on macOS and the
77
+ * ancestor check then walks the directories the grants actually sit in.
78
+ */
79
+ function resolveReal(target) {
80
+ const tail = [];
81
+ for (let current = path.resolve(target);; current = path.dirname(current)) {
82
+ try {
83
+ return path.join(fs.realpathSync(current), ...tail);
84
+ }
85
+ catch {
86
+ /* v8 ignore next -- the filesystem root always resolves, so this loop never runs out of ancestors @preserve */
87
+ if (path.dirname(current) === current)
88
+ return path.resolve(target);
89
+ tail.unshift(path.basename(current));
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Where this agent's operator-set grants live: `<trust root>/<agent>`. `OURO_OPERATOR_TRUST_DIR` replaces the trust root
95
+ * (`/etc/ouro/trust`) and keeps the per-agent subdirectory, so one setting serves every agent on the host.
96
+ */
97
+ function operatorTrustDir(agentRoot) {
98
+ const override = process.env[exports.TRUST_DIR_ENV];
99
+ const root = override && override.trim().length > 0 ? override : trustRoot;
100
+ return path.join(resolveReal(root), agentNameFromRoot(agentRoot));
101
+ }
102
+ function operatorTrustFile(agentRoot, fileName) {
103
+ return path.join(operatorTrustDir(agentRoot), fileName);
104
+ }
105
+ /**
106
+ * Reads `{ schemaVersion: 1, grants: { <friendId>: entry } }` from the trust directory. A file that is not trusted, not
107
+ * JSON or not this shape is `untrusted` and holds no grants; a missing file is `missing`. Within a trusted file, an
108
+ * entry that fails `valid` is dropped and its friend id is listed in `ignored`.
109
+ */
110
+ function readGrantFile(agentRoot, fileName, valid) {
111
+ const raw = (0, trusted_files_1.inspectTrustedJson)(operatorTrustFile(agentRoot, fileName));
112
+ if (raw.state === "missing")
113
+ return { state: "missing", grants: {}, ignored: [] };
114
+ if (raw.state === "untrusted")
115
+ return { state: "untrusted", reason: raw.reason, grants: {}, ignored: [] };
116
+ const file = raw.value;
117
+ const grants = file?.grants;
118
+ if (!file || typeof file !== "object" || file.schemaVersion !== 1 || !grants || typeof grants !== "object" || Array.isArray(grants)) {
119
+ return { state: "untrusted", reason: "file is not { schemaVersion: 1, grants: {...} }", grants: {}, ignored: [] };
120
+ }
121
+ const kept = {};
122
+ const ignored = [];
123
+ for (const [friendId, entry] of Object.entries(grants)) {
124
+ if (valid(entry))
125
+ kept[friendId] = entry;
126
+ else
127
+ ignored.push(friendId);
128
+ }
129
+ return { state: "trusted", grants: kept, ignored };
130
+ }
131
+ /** Why the operator command refused to write. The message says what to fix. */
132
+ class TrustDirectoryError extends Error {
133
+ }
134
+ exports.TrustDirectoryError = TrustDirectoryError;
135
+ const isRoot = () => process.geteuid?.() === 0;
136
+ let fchownHook;
137
+ /** Tests are not root, so they watch ownership changes instead of making them. Refused outside a test runner. */
138
+ function overrideFchownForTests(hook) {
139
+ if (process.env.VITEST === undefined)
140
+ throw new Error("overrideFchownForTests is for tests only");
141
+ fchownHook = hook;
142
+ }
143
+ /* v8 ignore start -- the real fchown only succeeds as root; tests watch the hook instead @preserve */
144
+ function chownToRoot(fd) {
145
+ if (fchownHook)
146
+ fchownHook(fd, 0, 0);
147
+ else
148
+ fs.fchownSync(fd, 0, 0);
149
+ }
150
+ /* v8 ignore stop */
151
+ /**
152
+ * Makes sure the trust directory exists and is trusted. A missing directory is created root-owned 0755, one component
153
+ * at a time, but only below an ancestor that is already trusted. An existing directory (or ancestor) that is not
154
+ * trusted is refused: the command never takes ownership of something the agent could have made.
155
+ */
156
+ function ensureTrustDirectory(dir) {
157
+ const missing = [];
158
+ let base = path.resolve(dir);
159
+ for (;;) {
160
+ const result = (0, trusted_files_1.inspectTrustedDirectory)(base);
161
+ if (result.state === "trusted")
162
+ break;
163
+ if (result.state === "untrusted")
164
+ throw new TrustDirectoryError(`refusing to write: ${base} is not trusted (${result.reason}). Fix its owner and mode as root; this command never takes ownership of an existing directory.`);
165
+ missing.push(base);
166
+ base = path.dirname(base);
167
+ }
168
+ for (const next of missing.reverse()) {
169
+ fs.mkdirSync(next, { mode: 0o755 });
170
+ const fd = fs.openSync(next, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
171
+ try {
172
+ if (isRoot())
173
+ chownToRoot(fd);
174
+ fs.fchmodSync(fd, 0o755);
175
+ }
176
+ finally {
177
+ fs.closeSync(fd);
178
+ }
179
+ }
180
+ }
181
+ /** Flushes a directory's entries to disk, so a rename or a new file in it survives a crash. */
182
+ function fsyncDirectory(dir) {
183
+ const fd = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
184
+ try {
185
+ fs.fsyncSync(fd);
186
+ directoryFlushHook?.(dir);
187
+ }
188
+ finally {
189
+ fs.closeSync(fd);
190
+ }
191
+ }
192
+ let directoryFlushHook;
193
+ /** Tests watch directory flushes (the ESM fs namespace cannot be spied on). Refused outside a test runner. */
194
+ function overrideDirectoryFlushForTests(hook) {
195
+ if (process.env.VITEST === undefined)
196
+ throw new Error("overrideDirectoryFlushForTests is for tests only");
197
+ directoryFlushHook = hook;
198
+ }
199
+ const LOCK_FILE = ".lock";
200
+ const LOCK_STALE_MS = 2 * 60_000;
201
+ /** A lock written by another host or pid namespace (a different hostname in its body) cannot be checked for a live holder, so it is only taken over after this long. */
202
+ const LOCK_FOREIGN_STALE_MS = 60 * 60_000;
203
+ let lockTimeoutMs = 5000;
204
+ let lockWaitHook;
205
+ let beforeReclaimHook;
206
+ /** Tests shorten the wait for a held lock, can act while it waits, and can act between judging a lock stale and reclaiming it. Refused outside a test runner. */
207
+ function overrideLockTimeoutForTests(timeoutMs, onWait, onBeforeReclaim) {
208
+ if (process.env.VITEST === undefined)
209
+ throw new Error("overrideLockTimeoutForTests is for tests only");
210
+ lockTimeoutMs = timeoutMs ?? 5000;
211
+ lockWaitHook = onWait;
212
+ beforeReclaimHook = onBeforeReclaim;
213
+ }
214
+ /** How long ago `file` was last written; a file that is gone counts as brand new, so nobody treats it as stale. */
215
+ function ageMs(file) {
216
+ try {
217
+ return Date.now() - fs.lstatSync(file).mtimeMs;
218
+ }
219
+ catch {
220
+ return 0;
221
+ }
222
+ }
223
+ const lockBody = () => `pid ${process.pid} host ${os.hostname()} at ${new Date().toISOString()}\n`;
224
+ /** True when a process with this pid exists on this host. EPERM means it exists but is someone else's. */
225
+ function processAlive(pid) {
226
+ try {
227
+ process.kill(pid, 0);
228
+ return true;
229
+ }
230
+ catch (error) {
231
+ return error.code !== "ESRCH";
232
+ }
233
+ }
234
+ /**
235
+ * Whether `lock` was left by a writer that is gone. Age alone is not enough: a slow writer can hold the lock for longer than two
236
+ * minutes. So a lock older than that is stale only when the pid in its body is no longer running on this host. A body that cannot be
237
+ * read as `pid N host H at T` names no holder at all, and a lock from another host or pid namespace cannot be checked, so it waits
238
+ * out a much longer age.
239
+ */
240
+ function lockIsStale(lock) {
241
+ const age = ageMs(lock);
242
+ if (age <= LOCK_STALE_MS)
243
+ return false;
244
+ let held = null;
245
+ try {
246
+ held = /^pid (\d+) host (\S+) at /.exec(fs.readFileSync(lock, "utf8"));
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ if (!held)
252
+ return true;
253
+ if (held[2] !== os.hostname())
254
+ return age > LOCK_FOREIGN_STALE_MS;
255
+ return !processAlive(Number(held[1]));
256
+ }
257
+ /**
258
+ * Removes a lock that looked stale, but only while holding a second exclusive file (`.lock.reclaim`) and only after looking again:
259
+ * two writers that both judged the same lock stale cannot then each delete it, which would let the second delete the fresh lock
260
+ * the first had just taken. A reclaim file that is itself stale belongs to a writer that died and is removed.
261
+ */
262
+ function reclaimStaleLock(lock) {
263
+ const guard = `${lock}.reclaim`;
264
+ try {
265
+ createExclusive(guard, lockBody());
266
+ }
267
+ catch {
268
+ if (ageMs(guard) > LOCK_STALE_MS)
269
+ fs.rmSync(guard, { force: true });
270
+ return;
271
+ }
272
+ try {
273
+ if (lockIsStale(lock))
274
+ fs.rmSync(lock, { force: true });
275
+ }
276
+ finally {
277
+ fs.rmSync(guard, { force: true });
278
+ }
279
+ }
280
+ function sleepSync(ms) {
281
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
282
+ }
283
+ /**
284
+ * Runs a read-modify-write of the trust files under an exclusive lock file in the trust directory, so two grant commands
285
+ * cannot both read the old file and then overwrite each other. The lock is created with O_EXCL and records the writer's pid and host;
286
+ * a lock older than two minutes whose writer is no longer running is taken over. A lock that stays held past the wait names the file to remove.
287
+ */
288
+ function withTrustedWriteLock(agentRoot, fn) {
289
+ const dir = operatorTrustDir(agentRoot);
290
+ ensureTrustDirectory(dir);
291
+ const lock = path.join(dir, LOCK_FILE);
292
+ const deadline = Date.now() + lockTimeoutMs;
293
+ for (;;) {
294
+ try {
295
+ createExclusive(lock, lockBody());
296
+ break;
297
+ }
298
+ catch (error) {
299
+ if (error.code !== "EEXIST")
300
+ throw error;
301
+ if (lockIsStale(lock)) {
302
+ beforeReclaimHook?.();
303
+ reclaimStaleLock(lock);
304
+ }
305
+ if (Date.now() >= deadline)
306
+ throw new TrustDirectoryError(`refusing to write: another grant command is writing to ${dir} (lock file ${lock}). If none is running, remove that file and retry. Nothing was written.`);
307
+ lockWaitHook?.();
308
+ sleepSync(25);
309
+ }
310
+ }
311
+ try {
312
+ return fn();
313
+ }
314
+ finally {
315
+ fs.rmSync(lock, { force: true });
316
+ }
317
+ }
318
+ /** Creates `file` exclusively (never through a link or over anything), owned by root with mode 0644 when run as root, and fsyncs it. */
319
+ function createExclusive(file, body) {
320
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
321
+ try {
322
+ if (isRoot())
323
+ chownToRoot(fd);
324
+ fs.fchmodSync(fd, 0o644);
325
+ fs.writeFileSync(fd, body);
326
+ fs.fsyncSync(fd);
327
+ }
328
+ finally {
329
+ fs.closeSync(fd);
330
+ }
331
+ }
332
+ function rawBytes(file) {
333
+ let fd;
334
+ try {
335
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
336
+ return fs.fstatSync(fd).isFile() ? fs.readFileSync(fd) : null;
337
+ }
338
+ catch {
339
+ return null;
340
+ }
341
+ finally {
342
+ if (fd !== undefined)
343
+ fs.closeSync(fd);
344
+ }
345
+ }
346
+ /**
347
+ * Replaces one file in the trust directory. The previous bytes are kept beside it as a timestamped backup, created the
348
+ * same safe way. The new file is written to an exclusive random temporary name, owned and moded through its descriptor,
349
+ * fsynced and renamed into place, so a reader never sees a half-written file and nothing already on disk is followed.
350
+ */
351
+ function writeTrustedFile(agentRoot, fileName, body, now) {
352
+ const dir = operatorTrustDir(agentRoot);
353
+ ensureTrustDirectory(dir);
354
+ const file = path.join(dir, fileName);
355
+ const previous = rawBytes(file);
356
+ let backup = null;
357
+ if (previous) {
358
+ backup = `${file}.bak-${now.toISOString().replace(/[:.]/gu, "-")}`;
359
+ createExclusive(backup, previous);
360
+ fsyncDirectory(dir);
361
+ }
362
+ const tmp = path.join(dir, `.${fileName}.${(0, node_crypto_1.randomBytes)(8).toString("hex")}.tmp`);
363
+ try {
364
+ createExclusive(tmp, body);
365
+ fs.renameSync(tmp, file);
366
+ fsyncDirectory(dir);
367
+ }
368
+ catch (error) {
369
+ fs.rmSync(tmp, { force: true });
370
+ throw error;
371
+ }
372
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_trust_file_written", message: "wrote an operator-set trust file", meta: { file, backedUp: backup !== null } });
373
+ return { backup };
374
+ }
375
+ /** A short human line about the file's state for `list` output and warnings. */
376
+ function describeGrantFile(view, file) {
377
+ if (view.state === "trusted")
378
+ return `trusted: ${file}`;
379
+ if (view.state === "missing")
380
+ return `not present: ${file}`;
381
+ return `NOT TRUSTED: ${file} (${view.reason}); no grant in it is honoured`;
382
+ }
@@ -74,7 +74,7 @@ async function handleOutboxCommand(input) {
74
74
  return invalid(`ids must be 1 to ${MAX_ACK_IDS} strings`);
75
75
  return { ok: true, result: { ...outbox.ack(friend.id, ids) } };
76
76
  }
77
- if (!(0, escalation_grants_1.holdsEscalation)(agentRoot, friend)) {
77
+ if (!(0, escalation_grants_1.holdsEscalation)(agentRoot, friend, input.now)) {
78
78
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_report_resolve_refused", message: "refused report/resolve from a peer without the escalation grant", meta: { friendId: friend.id } });
79
79
  return refuse("report/resolve needs the escalation grant");
80
80
  }
@@ -82,7 +82,7 @@ async function handleOutboxCommand(input) {
82
82
  if (typeof id !== "string" || typeof version !== "string" || typeof note !== "string" || typeof resolvedAt !== "string")
83
83
  return invalid("id, version, note and resolvedAt must be strings");
84
84
  // The key is the DID the operator pinned in the root-owned grant, never the friend record; and the A2A-verified caller must be that same DID.
85
- const holderDid = (0, escalation_grants_1.pinnedHolderDid)(agentRoot, friend.id);
85
+ const holderDid = (0, escalation_grants_1.pinnedHolderDid)(agentRoot, friend.id, input.now);
86
86
  if (!holderDid || input.verifiedDid !== holderDid) {
87
87
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_report_resolve_refused", message: "refused report/resolve from a caller whose verified DID is not the pinned holder DID", meta: { friendId: friend.id } });
88
88
  return refuse("report not resolved: the caller is not the pinned escalation holder");
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REPLAY_GRANT_HOLDERS_FILE = void 0;
4
+ exports.isReplayGrantHolder = isReplayGrantHolder;
5
+ const runtime_1 = require("../nerves/runtime");
6
+ const operator_trust_1 = require("./operator-trust");
7
+ /**
8
+ * The friends the replay gate provisioned as replay peers, kept by the gate itself as root inside the operator trust directory
9
+ * (`replay-identities.json`, `{ schemaVersion: 1, grants: { <friend id>: { who, name, did } } }`). A grant for one of them must
10
+ * carry an expiry (and so must every grant when this file is present but cannot be trusted), so a permanent grant can never be left behind by a replay run. This list is the authority for that rule: the
11
+ * bundle's own replay registry and a grant's free-text source are the agent's to edit and decide nothing here.
12
+ */
13
+ exports.REPLAY_GRANT_HOLDERS_FILE = "replay-identities.json";
14
+ function validHolder(value) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value))
16
+ return false;
17
+ const holder = value;
18
+ return typeof holder.who === "string" && typeof holder.name === "string" && typeof holder.did === "string";
19
+ }
20
+ /**
21
+ * True when a grant with no expiry for this friend must be refused. That is the case for a friend the list names, for an entry in the list
22
+ * that is malformed (its id is read from the file but its content is not trusted), and for every friend when the list is present but
23
+ * untrusted or malformed: a list the Butler cannot rely on must never let a permanent replay grant through. A list that does not exist
24
+ * yet (no replay gate has run) names nobody.
25
+ */
26
+ function isReplayGrantHolder(agentRoot, friendId) {
27
+ const view = (0, operator_trust_1.readGrantFile)(agentRoot, exports.REPLAY_GRANT_HOLDERS_FILE, validHolder);
28
+ if (view.state === "untrusted") {
29
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_replay_list_untrusted", message: "the replay list is present but cannot be trusted, so no grant without an expiry is honoured", meta: { file: exports.REPLAY_GRANT_HOLDERS_FILE } });
30
+ return true;
31
+ }
32
+ return Object.prototype.hasOwnProperty.call(view.grants, friendId) || view.ignored.includes(friendId);
33
+ }
@@ -73,8 +73,8 @@ class A2ARequestError extends Error {
73
73
  function jsonResponse(id, result) {
74
74
  return { jsonrpc: "2.0", id: id ?? null, result };
75
75
  }
76
- function errorResponse(id, code, message) {
77
- return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
76
+ function errorResponse(id, code, message, data) {
77
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } };
78
78
  }
79
79
  async function readBody(req) {
80
80
  const chunks = [];
@@ -593,10 +593,10 @@ async function startA2AServer(options) {
593
593
  : undefined;
594
594
  let turnText = text;
595
595
  let delegatedCommand;
596
- // Only the server writes the delegated banner; a peer typing it gets it marked as its own words.
597
- if (verifiedChat && verifiedChat.onBehalfOf !== "principal" && delegated_command_1.DELEGATED_BANNER.test(text)) {
598
- turnText = `[unverified: the sender typed this banner itself; it is not a delegated command] ${text}`;
599
- }
596
+ // Only the server writes the delegated banner. Any turn that is not about to be admitted as a delegated command
597
+ // (verified or not, including a plain unauthenticated text turn) gets a typed banner marked as the sender's own words.
598
+ if (verifiedChat?.onBehalfOf !== "principal")
599
+ turnText = (0, delegated_command_1.shieldDelegatedBanner)(text);
600
600
  if (verifiedChat?.onBehalfOf === "principal") {
601
601
  // A delegated command runs with the principal's authority only after the
602
602
  // server, never the model, has checked the grant and told the principal.
@@ -612,7 +612,12 @@ async function startA2AServer(options) {
612
612
  throw new Error("verified chat has no friend record");
613
613
  const admission = await (0, delegated_command_1.admitDelegatedCommand)({ friend: verifiedChat.friend, did: verifiedChat.did, text, commandId: (0, node_crypto_1.randomUUID)(), store: inboundShareDeps.store, registry, options: options.delegation });
614
614
  if (!admission.ok) {
615
- writeJson(res, 200, errorResponse(rpc.id, -32004, `delegated command refused: ${admission.reason}`));
615
+ // The code and the "delegated command refused: <reason>" prefix stay: the replay gate and older CLIs match them.
616
+ // Only an active family peer is told why: anyone else gets no_grant whatever the real reason was (a broken trust file, a key mismatch), which stays in the local log and nerves.
617
+ const activeFamily = verifiedChat.friend.trustLevel === "family" && verifiedChat.friend.admissionState === "active";
618
+ const shown = activeFamily ? admission.reason : "no_grant";
619
+ const guidance = (0, delegated_command_1.delegationRefusalGuidance)(shown, { legacyRecordGrant: activeFamily && verifiedChat.friend.delegationGrant !== undefined });
620
+ writeJson(res, 200, errorResponse(rpc.id, -32004, `delegated command refused: ${shown} (${guidance.message}). Nothing ran. Next: ${guidance.next}`, { reason: shown, nothingRan: true, retry: guidance.retry, next: guidance.next }));
616
621
  return;
617
622
  }
618
623
  relationshipAuthorization = admission.relationship;