@ouro.bot/cli 0.1.0-alpha.873 → 0.1.0-alpha.876

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.
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXEMPT_WORDS = exports.HOUSE_VOCABULARY = exports.LABEL_WORDS = exports.COMMON_STARTERS = void 0;
4
+ exports.isLookupToolCall = isLookupToolCall;
5
+ exports.mentions = mentions;
6
+ exports.candidateNames = candidateNames;
7
+ exports.lowercaseListNames = lowercaseListNames;
8
+ exports.sourceGroundingFinding = sourceGroundingFinding;
9
+ exports.sourceGroundingError = sourceGroundingError;
10
+ exports.withUnverifiedDisclosure = withUnverifiedDisclosure;
11
+ /**
12
+ * Primary-source grounding for claims about external works (books, shows, films, their characters).
13
+ *
14
+ * A model asked about a work it half-remembers will invent plausible names and present them as fact; a principle in
15
+ * the prompt did not stop that. This check runs where an answer is about to be delivered and judges the answer against
16
+ * what the turn actually looked up: a name that appears in no lookup result, and is not house vocabulary or the
17
+ * person's own words, is a name the model made up. Only a lookup (a web search or page fetch, a catalog lookup, a
18
+ * curl, the books tool) can clear a name that is claimed to come from a work. Memory, notes, the diary and ordinary
19
+ * shell output cannot, because they may hold an earlier fabrication. Pure functions; the runtime calls them at settle
20
+ * for agents that opt in (see ToolContext.answerGates).
21
+ */
22
+ const runtime_1 = require("../nerves/runtime");
23
+ const LOOKUP_TOOL_NAMES = new Set(["web_search", "media_search", "media_episodes"]);
24
+ const LOOKUP_TOOL_PATTERN = /^(web_fetch|fetch_url|fetch_page|read_url|read_page|browse\w*)$/;
25
+ /** A lookup is a command whose command word is curl or wget, or the books tool; echoing a URL, a here-string or a grep over text is not one. */
26
+ const SHELL_LOOKUP_SEGMENT = /^\s*(?:\w+=\S*\s+)*(?:(?:sudo|env|command|time|nohup)\s+)*(?:(?:curl|wget)\b|books\s+(?:get|search|series|library\s+(?:find|search))\b)/;
27
+ const SHELL_SEGMENT_SEPARATOR = /&&|\|\||[;|\n]/;
28
+ const STRONG_CUE = /\b(books?|novels?|movies?|films?|anime|saga|trilogy|albums?|episodes?|protagonists?)\b/i;
29
+ const WORK_CUE = /\b(books?|novels?|series|saga|trilogy|shows?|movies?|films?|anime|characters?|protagonists?|authors?|episodes?|seasons?|albums?|bands?|lore|cast)\b/i;
30
+ const COPULA_AFTER_NAME = /^(?:['’]s\b|\s+(?:is|was|are|were|isn['’]t|wasn['’]t|has|had|does|did)\b)/i;
31
+ /** Words that start a sentence or a bullet without being a name. */
32
+ exports.COMMON_STARTERS = new Set([
33
+ "the", "this", "that", "these", "those", "it", "its", "i", "so", "and", "but", "or", "yes", "no", "not", "sure", "okay", "ok", "here", "there",
34
+ "what", "when", "where", "why", "how", "who", "which", "if", "then", "now", "also", "both", "one", "two", "my", "your", "our", "we", "you", "he", "she", "they",
35
+ "got", "thanks", "thank", "done", "understood", "noted", "good", "great", "fine", "sorry", "will", "can", "could", "would", "should", "let", "yeah", "yep", "nope",
36
+ "right", "well", "anyway", "still", "just", "all", "any", "some", "each", "every", "for", "from", "with", "without", "to", "in", "on", "at", "by", "as", "is",
37
+ "are", "was", "were", "do", "does", "did", "have", "has", "had", "maybe", "probably", "honestly", "unfortunately", "looks", "seems", "see", "check", "try",
38
+ "use", "make", "keep", "stop", "start", "once", "first", "next", "last", "before", "after", "because", "since", "while", "though", "although", "however",
39
+ "otherwise", "instead", "meanwhile", "today", "tonight", "tomorrow", "yesterday", "more", "most", "many", "other", "another", "such", "only", "even",
40
+ "about", "over", "under", "between", "among", "like", "per", "plus", "both", "either", "neither", "whether", "yet", "nothing", "something", "everything",
41
+ "anything", "everyone", "someone", "anyone", "nobody", "overall", "basically", "actually", "generally", "usually", "often", "sometimes", "never", "always",
42
+ "note", "tip", "warning", "heads", "update", "summary", "answer", "plan", "status", "result", "results", "source", "sources", "todo",
43
+ ]);
44
+ /** A sentence-opening "Word:" is a label when the word is one of these; any other "Name:" is treated as a name. */
45
+ exports.LABEL_WORDS = new Set([
46
+ "note", "notes", "tip", "warning", "update", "summary", "answer", "plan", "status", "result", "results", "source", "sources", "todo", "next", "why", "how", "what",
47
+ "done", "reply", "title", "healthy", "snoozed", "down", "up", "broken", "failed", "pending", "running", "stopped", "paused", "queued", "cast", "characters", "authors", "books", "series", "shows", "movies", "episodes", "tldr", "caveat", "caveats", "options", "option",
48
+ ]);
49
+ /** Words that read as names but are not about a work: the house, the tools it uses, months, weekdays, common brands and services. */
50
+ exports.HOUSE_VOCABULARY = new Set([
51
+ "ari", "butler", "claude", "code", "sanctuary", "jellyfin", "sonarr", "radarr", "prowlarr", "jellyseerr", "calibre", "books", "telegram", "unraid", "docker",
52
+ "mendelow", "cloud", "pocketbook", "astraweb", "usenet", "sabnzbd", "bazarr", "tmdb", "tvdb", "libgen", "plex", "github", "google", "apple", "amazon", "netflix",
53
+ "hulu", "disney", "youtube", "spotify", "kindle", "kobo", "goodreads", "audible", "python", "javascript", "typescript", "node", "lodash", "vitest", "react",
54
+ "linux", "windows", "macos", "android", "chrome", "firefox", "safari", "slack", "discord", "wikipedia", "english", "ouro", "ouroboros", "a2a", "radarr", "http", "https",
55
+ "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december",
56
+ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
57
+ ]);
58
+ /** Status verbs and media nouns that open or fill ordinary replies about media work ("Added the show Silo. Season 1 is grabbing now."): never names, wherever they sit. */
59
+ exports.EXEMPT_WORDS = new Set([
60
+ "added", "adding", "found", "finding", "sent", "sending", "searching", "searched", "grabbing", "grabbed", "grab", "season", "seasons", "episode", "episodes", "queued", "queueing",
61
+ "monitoring", "monitored", "imported", "importing", "downloading", "downloaded", "download", "downloads", "requested", "requesting", "removed", "removing", "deleted", "updated", "updating",
62
+ "checking", "checked", "looking", "looked", "started", "starting", "finished", "finishing", "waiting", "stalled", "retrying", "retried", "marked", "set", "setting", "assigned", "assigning",
63
+ "movie", "movies", "show", "shows", "book", "books", "series", "film", "films", "less", "tell", "ready", "missing", "available", "unavailable", "specials", "special",
64
+ ]);
65
+ /** Contractions are never names: I'll, I'm, They're. */
66
+ const CONTRACTION = /^\p{L}+['’](?:ll|m|re|ve|d|t)$/iu;
67
+ const MIN_NAME_LENGTH = 3;
68
+ const MAX_ACRONYM_LENGTH = 5;
69
+ const LOWERCASE_LIST_INTRO = /\b(?:characters?|cast|names?|authors?|protagonists?|bands?|members?)\b[ \t]*(?::|—|-|are|includes?|including|like|such as)[ \t]+([^.!?\n]+)/gi;
70
+ const LOWERCASE_LIST_ITEM = /^[a-z][a-z'’-]{3,}$/;
71
+ const LOWERCASE_LIST_STOP = new Set(["others", "etc", "more", "many", "several", "various", "everyone", "anyone", "someone", "people", "these", "those", "them", "others", "similar", "same", "like"]);
72
+ /** True when the call read a primary source: a web search or page fetch, a catalog lookup, a curl, or the books tool. */
73
+ function isLookupToolCall(record) {
74
+ if (LOOKUP_TOOL_NAMES.has(record.name) || LOOKUP_TOOL_PATTERN.test(record.name))
75
+ return true;
76
+ return record.name === "shell" && (record.args.command ?? "").split(SHELL_SEGMENT_SEPARATOR).some((segment) => SHELL_LOOKUP_SEGMENT.test(segment));
77
+ }
78
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ /** Whole-word, case-insensitive, Unicode-aware: "Dross" is not found inside "Drossel" or "Gross". */
80
+ function mentions(haystack, name) {
81
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(name)}(?![\\p{L}\\p{N}])`, "iu").test(haystack);
82
+ }
83
+ const TOKEN = /\p{Lu}[\p{L}\p{M}'’]*/gu;
84
+ /** Capitalised words that read as names, in order of first appearance, case-insensitively de-duplicated. House vocabulary is not a name. */
85
+ function candidateNames(text) {
86
+ const seen = new Set();
87
+ const names = [];
88
+ for (const match of text.matchAll(TOKEN)) {
89
+ const word = match[0].replace(/['’]s$/i, "").replace(/['’]$/, "");
90
+ if (word.length < MIN_NAME_LENGTH)
91
+ continue;
92
+ if (word.length <= MAX_ACRONYM_LENGTH && word === word.toUpperCase())
93
+ continue;
94
+ const lower = word.toLowerCase();
95
+ if (lower === "the" || CONTRACTION.test(word) || exports.HOUSE_VOCABULARY.has(lower) || exports.EXEMPT_WORDS.has(lower) || seen.has(lower))
96
+ continue;
97
+ const before = text.slice(0, match.index).replace(/[ \t"'“‘(\[*_#>-]+$/u, "");
98
+ const startsSentence = before === "" || /[.!?:\n]$/.test(before);
99
+ const after = text.slice(match.index + match[0].length);
100
+ if (startsSentence && exports.COMMON_STARTERS.has(lower))
101
+ continue;
102
+ if (startsSentence && exports.LABEL_WORDS.has(lower) && after.startsWith(":"))
103
+ continue;
104
+ if (startsSentence && !startsLikeName(text, match.index, after, lower))
105
+ continue;
106
+ seen.add(lower);
107
+ names.push(word);
108
+ }
109
+ return names;
110
+ }
111
+ /**
112
+ * An ordinary sentence opens with a capital whether or not the word is a name ("Silo is in Sonarr", "Remember to..."). A sentence-opening word
113
+ * therefore counts as a name only with a sign it is one: it is capitalised elsewhere mid-sentence, it sits in a quote or emphasis or opens a bullet,
114
+ * it is followed by a colon, a copula, or a list continuation ("Lindon and Yerin").
115
+ */
116
+ function startsLikeName(text, index, after, lower) {
117
+ const rawBefore = index > 0 ? text[index - 1] : "";
118
+ if (/["“‘'*_]/.test(rawBefore))
119
+ return true;
120
+ const line = text.slice(text.lastIndexOf("\n", index - 1) + 1, index);
121
+ if (/^\s*(?:[-*•>]|\d+[.)])\s+$/.test(line))
122
+ return true;
123
+ if (after.startsWith(":") || COPULA_AFTER_NAME.test(after) || /^(?:,|\s+and|\s*&)\s*\p{Lu}/u.test(after))
124
+ return true;
125
+ return midSentenceCapitalised(text, lower);
126
+ }
127
+ function midSentenceCapitalised(text, lower) {
128
+ for (const match of text.matchAll(TOKEN)) {
129
+ const word = match[0].replace(/['’]s$/i, "").replace(/['’]$/, "");
130
+ if (word.toLowerCase() !== lower)
131
+ continue;
132
+ const before = text.slice(0, match.index).replace(/[ \t"'“‘(\[*_#>-]+$/u, "");
133
+ if (!(before === "" || /[.!?:\n]$/.test(before)))
134
+ return true;
135
+ }
136
+ return false;
137
+ }
138
+ /** Lowercase items in a list a work cue introduces ("characters: lindon, yerin and eithan"): two or more are treated as names. */
139
+ function lowercaseListNames(text) {
140
+ const names = [];
141
+ for (const intro of text.matchAll(LOWERCASE_LIST_INTRO)) {
142
+ const items = intro[1].split(/,|;|\band\b|\bor\b/i).map((item) => item.trim().split(/\s+/).slice(0, 2).join(" ")).map((item) => (/\s/.test(item) ? item.split(" ")[0] : item));
143
+ const words = items.filter((item) => LOWERCASE_LIST_ITEM.test(item) && !exports.COMMON_STARTERS.has(item) && !LOWERCASE_LIST_STOP.has(item) && !exports.HOUSE_VOCABULARY.has(item));
144
+ if (words.length >= 2)
145
+ names.push(...words);
146
+ }
147
+ return [...new Set(names)];
148
+ }
149
+ function claimsSomethingAbout(answer, name) {
150
+ const lower = answer.toLowerCase();
151
+ const needle = name.toLowerCase();
152
+ for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, at + needle.length)) {
153
+ if (COPULA_AFTER_NAME.test(answer.slice(at + needle.length)))
154
+ return true;
155
+ }
156
+ return false;
157
+ }
158
+ /**
159
+ * What is wrong with a reply about an external work, or null: it names things no lookup this turn contains (invented),
160
+ * or states something about a work the person only named, with no lookup at all (unsourced_claim).
161
+ */
162
+ function sourceGroundingFinding(input) {
163
+ if (!WORK_CUE.test(input.answer))
164
+ return null;
165
+ const lookups = input.tools.filter(isLookupToolCall);
166
+ const lookedUp = lookups.map((record) => record.result).join("\n");
167
+ const names = [...candidateNames(input.answer), ...lowercaseListNames(input.answer)];
168
+ const invented = names.filter((name) => !mentions(lookedUp, name) && !mentions(input.userText, name));
169
+ if (invented.length > 0 && (invented.length >= 2 || STRONG_CUE.test(input.answer))) {
170
+ const named = invented.join(", ");
171
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.unsourced_work_claim", message: "a reply named things from a work that the turn did not look up", meta: { names: invented.length, lookups: lookups.length } });
172
+ return {
173
+ kind: "invented", names: invented, lookups: lookups.length,
174
+ message: lookups.length === 0
175
+ ? `you named ${named} as part of a book, show, film or its cast, and you looked up nothing this turn. don't state facts about a work from memory. read a primary source first (web_search, or fetch the page), then answer only from what it says. if you can't find it, say so plainly.`
176
+ : `${named} do not appear in anything you looked up this turn, so they may be made up. check them against the source you read, drop the ones it does not support, or look them up, then settle again.`,
177
+ };
178
+ }
179
+ const unsourced = names.filter((name) => mentions(input.userText, name) && !mentions(lookedUp, name) && claimsSomethingAbout(input.answer, name));
180
+ if (lookups.length === 0 && unsourced.length > 0) {
181
+ return {
182
+ kind: "unsourced_claim", names: unsourced, lookups: 0,
183
+ message: `you stated something about ${unsourced.join(", ")} and looked up nothing this turn. read a primary source first (web_search, or fetch the page), then answer only from what it says. if you can't find it, say so plainly.`,
184
+ };
185
+ }
186
+ return null;
187
+ }
188
+ function sourceGroundingError(input) {
189
+ return sourceGroundingFinding(input)?.message ?? null;
190
+ }
191
+ /** When the model keeps naming unsourced things after the retries are spent, say so rather than deliver them as fact. */
192
+ function withUnverifiedDisclosure(answer, names) {
193
+ return `I couldn't verify ${names.join(", ")} against a source, so treat ${names.length === 1 ? "that" : "those"} as unconfirmed.\n\n${answer}`;
194
+ }
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readStewardPolicy = readStewardPolicy;
37
+ exports.assertNotReplayIdentity = assertNotReplayIdentity;
37
38
  exports.updateStewardPolicy = updateStewardPolicy;
38
39
  exports.readRoutineActionReceipts = readRoutineActionReceipts;
39
40
  exports.inspectRoutineActionGrant = inspectRoutineActionGrant;
@@ -47,6 +48,7 @@ const path = __importStar(require("node:path"));
47
48
  const node_crypto_1 = require("node:crypto");
48
49
  const node_util_1 = require("node:util");
49
50
  const runtime_1 = require("../nerves/runtime");
51
+ const replay_harness_1 = require("../a2a/replay-harness");
50
52
  const session_transaction_1 = require("../mind/session-transaction");
51
53
  const EMPTY_POLICY = { schemaVersion: 1, version: 0, desiredStates: {}, routineActionGrants: {}, updatedAt: null };
52
54
  const MAX_POLICY_BYTES = 1024 * 1024;
@@ -82,8 +84,8 @@ function delegation(value) {
82
84
  && text(value.delegateFriendId) && text(value.delegateDid) && text(value.noticeId);
83
85
  }
84
86
  function desiredEntry(value, version) {
85
- return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["expiresAt"])
86
- && text(value.value) && text(value.source) && positiveInteger(value.version) && value.version <= version
87
+ return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["correction", "expiresAt"])
88
+ && text(value.value) && text(value.source) && (value.correction === undefined || text(value.correction)) && positiveInteger(value.version) && value.version <= version
87
89
  && (value.provenance === "stated" || value.provenance === "observed" || value.provenance === "default")
88
90
  && (value.expiresAt === undefined || canonicalTime(value.expiresAt));
89
91
  }
@@ -131,14 +133,14 @@ function operationIdentity(issuer, event, requestId, kind, key) {
131
133
  }
132
134
  function auditRow(value) {
133
135
  if (!record(value) || !exactKeys(value, ["schemaVersion", "transactionId", "precedingBytesSha256", "mutationKind", "key", "mutationFingerprint", "affectedKeyResult", "affectedKeyResultSha256", "issuer", "authorizingSessionEvent", "authorization", "preimage", "preimageVersion", "preimageSha256", "postimage", "postimageVersion", "postimageSha256", "at"], ["delegatedVia"])
134
- || value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action")
136
+ || value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action" && value.mutationKind !== "correct_provenance")
135
137
  || !text(value.key) || !text(value.issuer) || !text(value.authorizingSessionEvent) || !ownerAuthorization(value.authorization)
136
138
  || (value.delegatedVia !== undefined && !delegation(value.delegatedVia))
137
139
  || typeof value.preimage !== "string" || typeof value.postimage !== "string" || !canonicalTime(value.at))
138
140
  return false;
139
141
  const before = policyImage(value.preimage);
140
142
  const after = policyImage(value.postimage);
141
- const result = value.mutationKind === "set_desired_state" ? after.desiredStates[value.key] : after.routineActionGrants[value.key];
143
+ const result = value.mutationKind === "grant_routine_action" ? after.routineActionGrants[value.key] : after.desiredStates[value.key];
142
144
  if (!result || value.preimageVersion !== before.version || value.postimageVersion !== after.version
143
145
  || after.version !== before.version + 1 || result.version !== after.version || after.updatedAt !== value.at
144
146
  || value.preimageSha256 !== sha256(value.preimage) || value.postimageSha256 !== sha256(value.postimage)
@@ -149,9 +151,17 @@ function auditRow(value) {
149
151
  return false;
150
152
  if ("action" in result && (result.issuer !== value.issuer || result.authorizingSessionEvent !== value.authorizingSessionEvent || result.authorizedAt !== value.at))
151
153
  return false;
154
+ if (value.mutationKind === "correct_provenance") {
155
+ // A correction changes the source and records its note on one existing entry, and nothing else about it.
156
+ // A missing prior entry spreads to nothing, so the comparison fails without a separate branch.
157
+ const prior = before.desiredStates[value.key];
158
+ const corrected = result;
159
+ if (!(0, node_util_1.isDeepStrictEqual)(result, { ...prior, source: corrected.source, correction: corrected.correction, version: after.version }))
160
+ return false;
161
+ }
152
162
  const expected = {
153
163
  ...before, version: after.version, updatedAt: value.at,
154
- desiredStates: value.mutationKind === "set_desired_state" ? { ...before.desiredStates, [value.key]: result } : before.desiredStates,
164
+ desiredStates: value.mutationKind !== "grant_routine_action" ? { ...before.desiredStates, [value.key]: result } : before.desiredStates,
155
165
  routineActionGrants: value.mutationKind === "grant_routine_action" ? { ...before.routineActionGrants, [value.key]: result } : before.routineActionGrants,
156
166
  };
157
167
  return (0, node_util_1.isDeepStrictEqual)(after, expected);
@@ -312,7 +322,13 @@ function readPolicyState(agentRoot) {
312
322
  function readStewardPolicy(agentRoot) {
313
323
  return readPolicyState(agentRoot).policy;
314
324
  }
325
+ /** Replay identities (the gate's test peers) can never write owner policy: the marker comes from the friend ids, never from model text. */
326
+ function assertNotReplayIdentity(agentRoot, friendIds) {
327
+ if (friendIds.some((friendId) => friendId !== undefined && (0, replay_harness_1.isReplayIdentity)(agentRoot, friendId)))
328
+ throw new Error("replay identities cannot write owner policy");
329
+ }
315
330
  function updateStewardPolicy(agentRoot, input) {
331
+ assertNotReplayIdentity(agentRoot, [input.actor.friendId, input.actor.delegatedVia?.delegateFriendId]);
316
332
  if (input.actor.trustLevel !== "family")
317
333
  throw new Error("steward policy mutation requires family authority");
318
334
  const authorizingSessionEvent = requireText(input.actor.sessionEventId, "authorizing session event");
@@ -322,7 +338,7 @@ function updateStewardPolicy(agentRoot, input) {
322
338
  throw new Error("steward policy mutation requires current owner authorization");
323
339
  if (input.actor.delegatedVia !== undefined && !delegation(input.actor.delegatedVia))
324
340
  throw new Error("steward policy delegation is invalid");
325
- if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action")
341
+ if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action" && input.mutation.kind !== "correct_provenance")
326
342
  throw new Error("steward policy mutation kind is invalid");
327
343
  return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), (lease) => {
328
344
  const snapshot = readAuditedPolicy(agentRoot, lease);
@@ -330,14 +346,21 @@ function updateStewardPolicy(agentRoot, input) {
330
346
  const now = input.now ?? new Date().toISOString();
331
347
  if (!canonicalTime(now))
332
348
  throw new Error("policy update time must be canonical");
333
- const expiresAt = optionalExpiry(input.mutation.expiresAt);
349
+ const expiresAt = input.mutation.kind === "correct_provenance" ? undefined : optionalExpiry(input.mutation.expiresAt);
334
350
  const version = current.version + 1;
335
351
  if (!positiveInteger(version))
336
352
  throw new Error("steward policy version is exhausted");
337
353
  const next = { ...current, version, desiredStates: { ...current.desiredStates }, routineActionGrants: { ...current.routineActionGrants }, updatedAt: now };
338
- const key = requireText(input.mutation.key, input.mutation.kind === "set_desired_state" ? "desired state key" : "routine action key");
354
+ const key = requireText(input.mutation.key, input.mutation.kind === "grant_routine_action" ? "routine action key" : "desired state key");
339
355
  let affectedKeyResult;
340
- if (input.mutation.kind === "set_desired_state") {
356
+ if (input.mutation.kind === "correct_provenance") {
357
+ const existing = current.desiredStates[key];
358
+ if (!existing)
359
+ throw new Error("provenance correction requires an existing desired state key");
360
+ affectedKeyResult = { ...existing, source: requireText(input.mutation.source, "corrected source"), correction: requireText(input.mutation.correction, "correction"), version };
361
+ next.desiredStates = { ...current.desiredStates, [key]: affectedKeyResult };
362
+ }
363
+ else if (input.mutation.kind === "set_desired_state") {
341
364
  if (input.mutation.provenance !== "stated" && input.mutation.provenance !== "observed" && input.mutation.provenance !== "default")
342
365
  throw new Error("desired state provenance is invalid");
343
366
  affectedKeyResult = {
@@ -373,7 +396,7 @@ function updateStewardPolicy(agentRoot, input) {
373
396
  const previousIndex = snapshot.rows.findIndex((row) => operationIdentity(row.issuer, row.authorizingSessionEvent, row.authorization.requestId, row.mutationKind, row.key) === identity);
374
397
  if (previousIndex >= 0) {
375
398
  const previous = snapshot.rows[previousIndex];
376
- const currentResult = previous.mutationKind === "set_desired_state" ? current.desiredStates[key] : current.routineActionGrants[key];
399
+ const currentResult = previous.mutationKind === "grant_routine_action" ? current.routineActionGrants[key] : current.desiredStates[key];
377
400
  if (previous.mutationFingerprint !== fingerprint || snapshot.rows.slice(previousIndex + 1).some((row) => row.mutationKind === previous.mutationKind && row.key === key)
378
401
  || !(0, node_util_1.isDeepStrictEqual)(currentResult, previous.affectedKeyResult))
379
402
  throw new Error("steward policy transaction replay changed");
@@ -442,8 +465,8 @@ function currentRequester(value, target) {
442
465
  && text(value.event.recordPath) && text(value.event.agent) && positiveInteger(value.event.generation)
443
466
  && text(value.event.observationRevision) && text(value.event.claimOwner);
444
467
  }
445
- function appliedEntry(rows, kind, key, entry) {
446
- return rows.some((row) => row.mutationKind === kind && row.key === key && row.postimageVersion === entry.version && (0, node_util_1.isDeepStrictEqual)(row.affectedKeyResult, entry));
468
+ function appliedEntry(rows, kinds, key, entry) {
469
+ return rows.some((row) => kinds.includes(row.mutationKind) && row.key === key && row.postimageVersion === entry.version && (0, node_util_1.isDeepStrictEqual)(row.affectedKeyResult, entry));
447
470
  }
448
471
  const UNRESOLVED_ACTION_STATES = new Set(["reserved", "attempting", "effect_acknowledged", "recovery_pending", "indeterminate"]);
449
472
  function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
@@ -472,7 +495,7 @@ function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
472
495
  return { allowed: false, reason: "routine action grant is missing", ...fallback };
473
496
  if (grant.provenance !== "stated")
474
497
  return { allowed: false, reason: "routine action grant must be owner-stated" };
475
- if (!appliedEntry(rows, "grant_routine_action", input.key, grant))
498
+ if (!appliedEntry(rows, ["grant_routine_action"], input.key, grant))
476
499
  return { allowed: false, reason: "routine action grant has no applied owner authorization" };
477
500
  if (grant.action !== input.action)
478
501
  return { allowed: false, reason: "routine action does not match the grant" };
@@ -480,7 +503,7 @@ function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
480
503
  return { allowed: false, reason: "routine action target is not authorized" };
481
504
  if (grant.expiresAt && Date.parse(grant.expiresAt) <= Date.parse(now))
482
505
  return { allowed: false, reason: "routine action grant expired", ...fallback };
483
- if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, "set_desired_state", desiredKey, desired))
506
+ if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, ["set_desired_state", "correct_provenance"], desiredKey, desired))
484
507
  return { allowed: false, reason: "container has no active applied owner-stated desired state" };
485
508
  if (!["on", "always_on", "expected_on"].includes(desiredValue) && !(desiredValue === "on_demand" && input.requester.kind !== "owner_event"))
486
509
  return { allowed: false, reason: "container desired state does not authorize this request" };
@@ -49,6 +49,7 @@ const REASONS = {
49
49
  readBeforeEdit: "i need to read that file first before i can edit it.",
50
50
  readBeforeOverwrite: "i need to read that file first before i can overwrite it.",
51
51
  protectedPath: "that path is protected — i can read it but not modify it.",
52
+ psychePath: "my psyche files ship from the repository through a pull request and the replay gate; i can read them but not change them. i file the change request with Claude Code instead.",
52
53
  destructiveCommand: "that command is too dangerous to run — it could cause irreversible damage.",
53
54
  // Trust reasons (vary by relationship)
54
55
  needsTrust: "i'd need a closer friend to vouch for you before i can do that.",
@@ -64,6 +65,14 @@ const PROTECTED_PATH_SEGMENTS = [
64
65
  "state/policy/",
65
66
  ];
66
67
  const PROTECTED_FILENAMES = ["agent.json", "tool-profiles.json"];
68
+ /** The agent's psyche folder ships only from the repository (a pull request and the replay gate), so no tool writes into it. */
69
+ function isPsychePath(filePath, agentRoot) {
70
+ if (!agentRoot || !filePath)
71
+ return false;
72
+ const psyche = path.resolve(agentRoot, "psyche");
73
+ const target = path.resolve(agentRoot, filePath);
74
+ return target === psyche || target.startsWith(`${psyche}${path.sep}`);
75
+ }
67
76
  function isProtectedPath(filePath) {
68
77
  for (const segment of PROTECTED_PATH_SEGMENTS) {
69
78
  if (filePath.includes(`/${segment}`) || filePath.startsWith(segment))
@@ -104,6 +113,50 @@ function shellWritesToProtectedPath(command) {
104
113
  return true;
105
114
  return false;
106
115
  }
116
+ // --- the psyche folder in shell commands ---
117
+ // The psyche folder is mounted read-only into the resident at the OS level (the upgrade recreates the container that way). This is the
118
+ // defence in depth in front of that: a shell command that mentions the psyche folder at all runs only if every part of it is
119
+ // a read-only command, because enumerating the ways to write a file (>, >>, tee, cp, mv, sed -i, node -e, python, cd then
120
+ // redirect) is a list the model can always step around.
121
+ const PSYCHE_READ_ONLY_COMMANDS = new Set(["cat", "ls", "head", "tail", "grep", "wc", "sha256sum", "stat", "cd", "pwd"]);
122
+ const SEGMENT_OPERATORS = new Set(["&&", "||", ";", "|"]);
123
+ function commandMentionsPsyche(command, words, agentRoot) {
124
+ const psyche = path.resolve(agentRoot, "psyche");
125
+ if (command.includes(psyche) || words.some((word) => word.includes(psyche)))
126
+ return true;
127
+ if (words.some((word) => word === "psyche" || isPsychePath(word, agentRoot)))
128
+ return true;
129
+ // A relative "psyche/..." inside code or a quoted argument, when this agent has such a folder.
130
+ return /(?:^|[\s'"=(])psyche\//.test(command) && fs.existsSync(psyche);
131
+ }
132
+ function psycheShellWriteRefused(command, agentRoot) {
133
+ if (!agentRoot)
134
+ return false;
135
+ const entries = (0, shell_quote_1.parse)(command);
136
+ const words = entries.filter((entry) => typeof entry === "string");
137
+ if (!commandMentionsPsyche(command, words, agentRoot))
138
+ return false;
139
+ // Substitution, heredocs and any $VAR or ${VAR} expansion: the shell could build a psyche path or a command word we cannot see.
140
+ if (/\$[({A-Za-z_]|`|<\(|<</.test(command))
141
+ return true;
142
+ let expectCommandWord = true;
143
+ for (const entry of entries) {
144
+ if (typeof entry === "string") {
145
+ if (expectCommandWord && !/^\w+=/.test(entry)) {
146
+ if (!PSYCHE_READ_ONLY_COMMANDS.has(entry))
147
+ return true;
148
+ expectCommandWord = false;
149
+ }
150
+ continue;
151
+ }
152
+ const op = "op" in entry ? entry.op : "comment";
153
+ if (SEGMENT_OPERATORS.has(op))
154
+ expectCommandWord = true;
155
+ else if (op !== "glob")
156
+ return true;
157
+ }
158
+ return false;
159
+ }
107
160
  // --- structural guardrail checks (always on, all trust levels) ---
108
161
  function checkReadBeforeWrite(toolName, args, context) {
109
162
  if (toolName === "edit_file") {
@@ -132,21 +185,25 @@ function checkDestructiveShellPatterns(toolName, args) {
132
185
  }
133
186
  return allow;
134
187
  }
135
- function checkProtectedPaths(toolName, args) {
188
+ function checkProtectedPaths(toolName, args, context) {
136
189
  if (toolName === "write_file" || toolName === "edit_file") {
137
190
  const filePath = args.path || "";
191
+ if (isPsychePath(filePath, context.agentRoot))
192
+ return deny(REASONS.psychePath);
138
193
  if (isProtectedPath(filePath))
139
194
  return deny(REASONS.protectedPath);
140
195
  }
141
196
  if (toolName === "shell") {
142
197
  const command = args.command || "";
198
+ if (psycheShellWriteRefused(command, context.agentRoot))
199
+ return deny(REASONS.psychePath);
143
200
  if (shellWritesToProtectedPath(command))
144
201
  return deny(REASONS.protectedPath);
145
202
  }
146
203
  return allow;
147
204
  }
148
205
  function checkStructuralGuardrails(toolName, args, context) {
149
- const protectedResult = checkProtectedPaths(toolName, args);
206
+ const protectedResult = checkProtectedPaths(toolName, args, context);
150
207
  if (!protectedResult.allowed)
151
208
  return protectedResult;
152
209
  const destructiveResult = checkDestructiveShellPatterns(toolName, args);
@@ -9,6 +9,7 @@ exports.mcpToolSchema = mcpToolSchema;
9
9
  exports.mcpToolsAsDefinitions = mcpToolsAsDefinitions;
10
10
  const runtime_1 = require("../nerves/runtime");
11
11
  const tool_arguments_1 = require("./tool-arguments");
12
+ const mcp_write_guard_1 = require("./mcp-write-guard");
12
13
  class McpCallRejectedError extends Error {
13
14
  constructor(message) {
14
15
  super(message);
@@ -90,8 +91,11 @@ function mcpToolsAsDefinitions(view) {
90
91
  message: `calling MCP tool ${entry.server}/${tool.name}`,
91
92
  meta: { server: entry.server, tool: tool.name },
92
93
  });
94
+ const guarded = (0, mcp_write_guard_1.guardMcpArgs)({ toolName: tool.name, declaresDryRun: Object.hasOwn((tool.inputSchema?.properties ?? {}), "dry_run"), args, ctx });
95
+ if (guarded.rejected !== undefined)
96
+ throw new McpCallRejectedError(guarded.rejected);
93
97
  try {
94
- const result = await view.manager.callTool(binding, args, { agentName: ctx.agentName, agentRoot: ctx.agentRoot });
98
+ const result = await view.manager.callTool(binding, guarded.args, { agentName: ctx.agentName, agentRoot: ctx.agentRoot });
95
99
  const text = result.content
96
100
  .filter((c) => c.type === "text" && c.text)
97
101
  .map((c) => c.text)
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REPLAY_REFUSED_WRITE_TOOLS = exports.OWNER_ONLY_WRITE_TOOLS = void 0;
4
+ exports.guardMcpArgs = guardMcpArgs;
5
+ /**
6
+ * Runtime guards on MCP tool arguments that the MCP server itself cannot enforce, because only the runtime knows who is calling.
7
+ *
8
+ * - A replay identity (the host's replay gate, src/a2a/replay-harness.ts) never applies a change: every tool that declares a
9
+ * `dry_run` argument is forced to a dry run for it, so a gate case can never alter the live house.
10
+ * - A replay identity may not call a write tool that has no dry run at all (see REPLAY_REFUSED_WRITE_TOOLS).
11
+ * - Some tools change settings only the owner may change: applying (dry_run false) is refused for any other caller, and the
12
+ * caller is stamped into the arguments so the tool's audit line names who asked.
13
+ */
14
+ const replay_harness_1 = require("../a2a/replay-harness");
15
+ const runtime_1 = require("../nerves/runtime");
16
+ /** MCP tools whose applied writes are owner-only. */
17
+ exports.OWNER_ONLY_WRITE_TOOLS = new Set(["media_quality_profile"]);
18
+ /** MCP tools that change the house and have no dry run: a replay identity may not call them at all. media_manual_import is a write only in `import` mode. */
19
+ exports.REPLAY_REFUSED_WRITE_TOOLS = new Set(["media_request", "media_search_now", "media_blocklist", "media_blocklist_stalled", "media_fill_missing", "media_release_grab", "media_manual_import"]);
20
+ const isWrite = (args) => (args.dry_run === false || args.dry_run === "false") && args.action !== "read";
21
+ function callerFriend(ctx) {
22
+ const friend = ctx?.context?.friend;
23
+ return { id: friend?.id ?? ctx?.relationshipAuthorization?.actor?.friendId, name: friend?.name };
24
+ }
25
+ function guardMcpArgs(input) {
26
+ const { toolName, declaresDryRun, ctx } = input;
27
+ let args = input.args;
28
+ const caller = callerFriend(ctx);
29
+ if (exports.OWNER_ONLY_WRITE_TOOLS.has(toolName)) {
30
+ if (isWrite(args) && ctx?.relationshipAuthorization?.profileId !== "sanctuary-owner") {
31
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "repertoire", event: "mcp.write_refused_non_owner", message: "an owner-only MCP write was refused", meta: { tool: toolName } });
32
+ return { rejected: `${toolName} changes settings only the owner may change. Show the owner the dry run and ask them to confirm; do not apply it for anyone else.` };
33
+ }
34
+ args = { ...args, caller: caller.id ? `${caller.id}${caller.name ? ` (${caller.name})` : ""}` : "unknown" };
35
+ }
36
+ const replay = Boolean(caller.id && ctx?.agentRoot && (0, replay_harness_1.isReplayIdentity)(ctx.agentRoot, caller.id));
37
+ if (replay && !declaresDryRun && exports.REPLAY_REFUSED_WRITE_TOOLS.has(toolName) && (toolName !== "media_manual_import" || args.mode === "import")) {
38
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "repertoire", event: "mcp.replay_write_refused", message: "a replay identity's MCP write with no dry run was refused", meta: { tool: toolName } });
39
+ return { rejected: `${toolName} changes the live house and has no dry run, so a replay identity may not call it. Describe what you would do instead.` };
40
+ }
41
+ if (replay && declaresDryRun && args.dry_run !== true) {
42
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "repertoire", event: "mcp.replay_forced_dry_run", message: "a replay identity's MCP call was forced to a dry run", meta: { tool: toolName } });
43
+ args = { ...args, dry_run: true };
44
+ }
45
+ return { args };
46
+ }
@@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.notesToolDefinitions = void 0;
36
+ exports.notesToolDefinitions = exports.OWN_PERSONA_NOTE_REFUSAL = void 0;
37
+ exports.isOwnPersonaNote = isOwnPersonaNote;
37
38
  const child_process_1 = require("child_process");
38
39
  const path = __importStar(require("path"));
39
40
  const skills_1 = require("./skills");
@@ -54,6 +55,14 @@ function bundleRelativeLocator(filePath, ctx) {
54
55
  }
55
56
  return filePath.split(path.sep).join("/");
56
57
  }
58
+ // A friend note is a fact or preference about a person. The agent's own personality, voice and psyche are not facts
59
+ // about the person: they live in the bundle's psyche folder, which ships from the repo and is changed by pull request.
60
+ const OWN_PERSONA_NOTE = /\b(?:your|my|the\s+(?:agent|butler|bot|assistant)(?:['\u2019]s)?)\s+(?:own\s+)?(?:personality|persona|psyche|soul|identity|character|voice|self[- ]concept)\b|\b(?:channel|embody|role-?play|act\s+like|talk\s+like|sound\s+like|be\s+more)\b[^.\n]{0,40}\b(?:energy|vibe|character|persona)\b|\b(?:act|behave)\s+like\s+(?:a|an|the)\b/i;
61
+ const PERSONA_KEY = /^\s*(?:persona|personality|psyche|soul)\s*$/i;
62
+ function isOwnPersonaNote(key, content) {
63
+ return PERSONA_KEY.test(key ?? "") || OWN_PERSONA_NOTE.test(`${key ?? ""} ${content ?? ""}`);
64
+ }
65
+ exports.OWN_PERSONA_NOTE_REFUSAL = "that note is about my own personality or voice, not about the person, so it does not belong in a friend note. my psyche lives in the bundle's psyche folder and ships from the repo; i do not edit it live. a personality change goes to Claude Code as a request (over A2A, or as a failure or feature report), and it ships by pull request through the replay gate. this is a guardrail on persona content, not on the person: a note about the person themselves (their facts, preferences, voice, character in a game) is fine and saves normally. friend notes hold facts and preferences about this person only.";
57
66
  exports.notesToolDefinitions = [
58
67
  {
59
68
  tool: {
@@ -417,6 +426,8 @@ exports.notesToolDefinitions = [
417
426
  const friendId = ctx.context.friend?.id;
418
427
  if (!friendId)
419
428
  return "i can't save notes -- no friend identity available";
429
+ if (a.type !== "name" && isOwnPersonaNote(a.key, a.content))
430
+ return exports.OWN_PERSONA_NOTE_REFUSAL;
420
431
  const actor = ctx.relationshipAuthorization?.actor;
421
432
  const isSanctuaryRelationship = ctx.context.friend?.capabilityProfileId?.startsWith("sanctuary-") === true;
422
433
  const preferenceSource = a.source;
@@ -16,16 +16,17 @@ exports.stewardPolicyToolDefinition = {
16
16
  type: "function",
17
17
  function: {
18
18
  name: "steward_policy_manage",
19
- description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated owner Telegram request and fresh authorization. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
19
+ description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated owner Telegram request and fresh authorization. Replay identities can never update it. correct_provenance (key, source, correction) is only for fixing a wrong attribution on an existing desired state: it replaces the recorded source and records the correction note, never changes the value, and leaves an audit row; do not use it to change a value. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
20
20
  parameters: {
21
21
  type: "object",
22
22
  properties: {
23
- action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action"] },
23
+ action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action", "correct_provenance"] },
24
24
  expectedVersion: { type: "integer", minimum: 0, description: "Optional version returned by read. When omitted, the runtime uses the current policy version." },
25
25
  key: { type: "string" },
26
26
  value: { type: "string" },
27
27
  provenance: { type: "string", enum: ["stated", "observed", "default", "installed_explicit_policy"] },
28
- source: { type: "string" },
28
+ source: { type: "string", description: "Who or what stated this. For correct_provenance, the corrected attribution that replaces the recorded source." },
29
+ correction: { type: "string", description: "correct_provenance only: required note naming what is being corrected and why." },
29
30
  routineAction: { type: "string" },
30
31
  targetsJson: { type: "string", description: "JSON array of exact target names" },
31
32
  maxCount: { type: "integer", minimum: 1 },
@@ -54,6 +55,7 @@ exports.stewardPolicyToolDefinition = {
54
55
  throw new Error("steward policy mutation requires authenticated relationship authority");
55
56
  const { friendId, trustLevel, sessionEventId } = actor;
56
57
  const { profileId, requestId } = relationship;
58
+ (0, steward_policy_1.assertNotReplayIdentity)(ctx.agentRoot, [friendId, ctx.delegatedCommand?.delegateFriendId]);
57
59
  const sessionKey = ctx.currentSession?.key;
58
60
  const delegated = ctx.delegatedCommand;
59
61
  // Two ways to hold owner authority: the owner's own Telegram turn, or a delegated
@@ -82,6 +84,9 @@ exports.stewardPolicyToolDefinition = {
82
84
  throw new Error("desired state provenance is invalid");
83
85
  mutation = { kind: "set_desired_state", key: args.key ?? "", value: args.value ?? "", provenance: args.provenance, source: sourceFor(args.source), ...(args.expiresAt ? { expiresAt: args.expiresAt } : {}) };
84
86
  }
87
+ else if (args.action === "correct_provenance") {
88
+ mutation = { kind: "correct_provenance", key: args.key ?? "", source: sourceFor(args.source), correction: args.correction ?? "" };
89
+ }
85
90
  else if (args.action === "grant_routine_action") {
86
91
  if (args.provenance !== "stated" && args.provenance !== "installed_explicit_policy")
87
92
  throw new Error("routine action provenance is invalid");
@@ -73,7 +73,7 @@ const SANCTUARY_RELATIONSHIP_BASE_TOOLS = new Set([
73
73
  exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
74
74
  "media_search", "media_request", "media_request_status",
75
75
  "media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
76
- "media_search_now", "media_queue", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_fill_missing",
76
+ "media_search_now", "media_queue", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_quality_profile", "media_fill_missing",
77
77
  ]);
78
78
  /** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
79
79
  const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
@@ -284,6 +284,8 @@ function createSanctuaryToolContext(agentName) {
284
284
  });
285
285
  return {
286
286
  agentRoot,
287
+ // The Sanctuary profile opts in to the settle-time checks: replies about a work are held to what the turn looked up, and a brevity request is honored.
288
+ answerGates: { sourceGrounding: true, brevity: true },
287
289
  sanctuary: {
288
290
  listContainers: acceptanceRead("unraid_list_containers", reads.listContainers),
289
291
  getContainerLogs: acceptanceRead("unraid_get_container_logs", reads.getContainerLogs),