@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.
- package/changelog.json +6 -0
- package/deploy/unraid/README.txt +7 -5
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +58 -3
- package/deploy/unraid/sanctuary-replay-gate.mjs +214 -7
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +148 -1
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +8 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +4 -2
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/heart/core.js +45 -3
- package/dist/heart/daemon/container-spec-auditor.js +3 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +2 -2
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +2 -1
- package/dist/heart/reply-style.js +56 -0
- package/dist/heart/source-grounding.js +194 -0
- package/dist/repertoire/guardrails.js +59 -2
- package/dist/repertoire/mcp-tools.js +5 -1
- package/dist/repertoire/mcp-write-guard.js +46 -0
- package/dist/repertoire/tools-notes.js +12 -1
- package/dist/repertoire/tools.js +1 -1
- package/dist/senses/sanctuary-runtime.js +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -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;
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -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),
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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",
|