@ouro.bot/cli 0.1.0-alpha.874 → 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
+ }
@@ -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;
@@ -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),
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.874",
3
+ "version": "0.1.0-alpha.876",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.874",
9
+ "version": "0.1.0-alpha.876",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.874",
3
+ "version": "0.1.0-alpha.876",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },