@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.
- package/changelog.json +12 -0
- package/deploy/unraid/README.txt +7 -5
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +58 -3
- package/deploy/unraid/sanctuary-replay-gate.mjs +245 -8
- 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/a2a/replay-harness.js +26 -0
- 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/heart/steward-policy.js +37 -14
- 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-steward-policy.js +8 -3
- 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
|
+
}
|
|
@@ -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 === "
|
|
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
|
|
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 === "
|
|
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 === "
|
|
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 === "
|
|
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,
|
|
446
|
-
return rows.some((row) => row.mutationKind
|
|
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");
|
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),
|