@ouro.bot/cli 0.1.0-alpha.981 → 0.1.0-alpha.983
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/README.md +1 -1
- package/changelog.json +12 -0
- package/deploy/unraid/README.txt +1 -1
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +159 -37
- package/deploy/unraid/sanctuary-replay-gate.mjs +312 -35
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +14 -14
- package/deploy/unraid/sanctuary.ouro/product/PRODUCT.md +11 -0
- package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +2 -2
- package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -3
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +3 -29
- package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +7 -19
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +1 -19
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +9 -3
- package/dist/heart/daemon/sanctuary-bundle-migration.js +28 -1
- package/dist/mind/bundle-manifest.js +1 -0
- package/dist/mind/house-names.js +43 -0
- package/dist/mind/prompt.js +11 -28
- package/dist/repertoire/guardrails.js +239 -18
- package/dist/repertoire/tools-escalation.js +2 -2
- package/dist/repertoire/tools-files.js +23 -5
- package/dist/repertoire/tools-notes.js +2 -2
- package/dist/repertoire/tools-unraid.js +1 -1
- package/dist/repertoire/tools.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -33,7 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.OURO_CLI_TRUST_MANIFEST = void 0;
|
|
36
|
+
exports.OURO_CLI_TRUST_MANIFEST = exports.PSYCHE_REFUSAL = void 0;
|
|
37
|
+
exports.canonicalTarget = canonicalTarget;
|
|
38
|
+
exports.insideAgentFolder = insideAgentFolder;
|
|
39
|
+
exports.psycheFileClass = psycheFileClass;
|
|
40
|
+
exports.psycheWriteAllowed = psycheWriteAllowed;
|
|
41
|
+
exports.psycheEditNote = psycheEditNote;
|
|
37
42
|
exports.guardInvocation = guardInvocation;
|
|
38
43
|
const fs = __importStar(require("node:fs"));
|
|
39
44
|
const os = __importStar(require("node:os"));
|
|
@@ -42,6 +47,7 @@ const friends_1 = require("@ouro.bot/friends");
|
|
|
42
47
|
const shell_quote_1 = require("shell-quote");
|
|
43
48
|
const runtime_1 = require("../nerves/runtime");
|
|
44
49
|
const store_1 = require("../commerce/store");
|
|
50
|
+
const identity_1 = require("../heart/identity");
|
|
45
51
|
const deny = (reason) => ({ allowed: false, reason });
|
|
46
52
|
const allow = { allowed: true };
|
|
47
53
|
// --- reason templates ---
|
|
@@ -50,7 +56,6 @@ const REASONS = {
|
|
|
50
56
|
readBeforeEdit: "i need to read that file first before i can edit it.",
|
|
51
57
|
readBeforeOverwrite: "i need to read that file first before i can overwrite it.",
|
|
52
58
|
protectedPath: "that path is protected — i can read it but not modify it.",
|
|
53
|
-
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.",
|
|
54
59
|
destructiveCommand: "that command is too dangerous to run — it could cause irreversible damage.",
|
|
55
60
|
// Trust reasons (vary by relationship)
|
|
56
61
|
needsTrust: "i'd need a closer friend to vouch for you before i can do that.",
|
|
@@ -70,10 +75,128 @@ const PROTECTED_PATH_SEGMENTS = [
|
|
|
70
75
|
"state/reports/",
|
|
71
76
|
];
|
|
72
77
|
const PROTECTED_FILENAMES = ["agent.json", "tool-profiles.json"];
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
/** How many symlinks a dangling-link walk follows before refusing (the Linux kernel's own limit is 40). */
|
|
79
|
+
const MAX_SYMLINK_HOPS = 40;
|
|
80
|
+
/** A lookup that failed only because part of the path does not exist yet; any other failure leaves the target unknown. */
|
|
81
|
+
function rethrowUnlessMissing(error) {
|
|
82
|
+
const code = error.code;
|
|
83
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The real location of `lexical`: `realpathSync.native` of the deepest part that exists, the final file included, with the
|
|
88
|
+
* missing remainder appended. A dangling symlink is followed, from its real parent, to where a write through it would land.
|
|
89
|
+
* Throws when the filesystem cannot say where the path lands (a denied lookup, a symlink loop, too many links), so the
|
|
90
|
+
* guard can refuse rather than fall back to the lexical path.
|
|
91
|
+
*/
|
|
92
|
+
function canonicalize(lexical) {
|
|
93
|
+
let current = lexical;
|
|
94
|
+
const missing = [];
|
|
95
|
+
let hops = 0;
|
|
96
|
+
for (;;) {
|
|
97
|
+
try {
|
|
98
|
+
return path.join(fs.realpathSync.native(current), ...missing);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
rethrowUnlessMissing(error);
|
|
102
|
+
}
|
|
103
|
+
let stat;
|
|
104
|
+
try {
|
|
105
|
+
stat = fs.lstatSync(current);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
rethrowUnlessMissing(error);
|
|
109
|
+
}
|
|
110
|
+
if (stat?.isSymbolicLink()) {
|
|
111
|
+
if (hops >= MAX_SYMLINK_HOPS)
|
|
112
|
+
throw new Error(`too many symbolic links resolving ${lexical}`);
|
|
113
|
+
hops += 1;
|
|
114
|
+
current = path.resolve(fs.realpathSync.native(path.dirname(current)), fs.readlinkSync(current));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const parent = path.dirname(current);
|
|
118
|
+
if (parent === current)
|
|
119
|
+
return path.join(current, ...missing);
|
|
120
|
+
missing.unshift(path.basename(current));
|
|
121
|
+
current = parent;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Every place a file tool could write `p`: resolved against the agent root and against the repo root (which is what
|
|
126
|
+
* `write_file` and `edit_file` use), each in its lexical and canonical form. An absolute `p` is resolved only as itself.
|
|
127
|
+
*/
|
|
128
|
+
function canonicalTarget(p, agentRoot) {
|
|
129
|
+
const lexicals = path.isAbsolute(p) ? [p] : [path.resolve(agentRoot, p), path.resolve((0, identity_1.getRepoRoot)(), p)];
|
|
130
|
+
return lexicals.flatMap((lexical) => [lexical, canonicalize(lexical)]);
|
|
131
|
+
}
|
|
132
|
+
/** True when any form of `p` is the agent's `folder` (in either its lexical or its canonical form) or under it. */
|
|
133
|
+
function insideAgentFolder(p, agentRoot, folder) {
|
|
134
|
+
const lexicalFolder = path.resolve(agentRoot, folder);
|
|
135
|
+
const folders = [lexicalFolder, canonicalize(lexicalFolder)];
|
|
136
|
+
return canonicalTarget(p, agentRoot).some((target) => folders.some((dir) => target === dir || target.startsWith(`${dir}${path.sep}`)));
|
|
137
|
+
}
|
|
138
|
+
// --- the psyche belongs to the agent ---
|
|
139
|
+
// Every agent writes its own TACIT.md: what it learns about how its house works. The rest of its psyche (who it is) changes only
|
|
140
|
+
// when a family human asks it directly, one to one, on a channel that proves who they are.
|
|
141
|
+
exports.PSYCHE_REFUSAL = "that file is part of me; I change it when my owner asks me in our own chat.";
|
|
142
|
+
const PSYCHE_IDENTITY_CHANNELS = new Set(["telegram", "bluebubbles", "teams"]);
|
|
143
|
+
/** `tacit` for the agent's psyche/TACIT.md, `identity` for anything else in psyche/ (the folder and new files included), `null` outside it. */
|
|
144
|
+
function psycheFileClass(filePath, agentRoot) {
|
|
145
|
+
if (!filePath || !agentRoot || !insideAgentFolder(filePath, agentRoot, "psyche"))
|
|
146
|
+
return null;
|
|
147
|
+
const lexicalFolder = path.resolve(agentRoot, "psyche");
|
|
148
|
+
const folders = [lexicalFolder, canonicalize(lexicalFolder)];
|
|
149
|
+
const tacits = folders.map((folder) => path.join(folder, "TACIT.md"));
|
|
150
|
+
// Every form of the path that lands in psyche/ must be TACIT: a link that sends one form of it to SOUL makes it identity.
|
|
151
|
+
const inPsyche = canonicalTarget(filePath, agentRoot).filter((target) => folders.some((dir) => target === dir || target.startsWith(`${dir}${path.sep}`)));
|
|
152
|
+
return inPsyche.every((target) => tacits.includes(target)) ? "tacit" : "identity";
|
|
153
|
+
}
|
|
154
|
+
/** The lstat of `p`, or null when it does not exist; throws on any other lookup failure. */
|
|
155
|
+
function lstatIfPresent(p) {
|
|
156
|
+
try {
|
|
157
|
+
return fs.lstatSync(p);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code === "ENOENT")
|
|
161
|
+
return null;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Whether the person in this conversation may have the agent write this psyche file. */
|
|
166
|
+
function psycheWriteAllowed(fileClass, _filePath, context) {
|
|
167
|
+
const agentRoot = context.agentRoot;
|
|
168
|
+
/* v8 ignore next -- a file is only classed as psyche when there is an agent root @preserve */
|
|
169
|
+
if (!agentRoot)
|
|
76
170
|
return false;
|
|
171
|
+
try {
|
|
172
|
+
if (lstatIfPresent(path.join(agentRoot, "psyche"))?.isSymbolicLink())
|
|
173
|
+
return false;
|
|
174
|
+
if (fileClass === "tacit") {
|
|
175
|
+
// A symlink or a hard link would carry the write to some other file.
|
|
176
|
+
const tacit = lstatIfPresent(path.join(agentRoot, "psyche", "TACIT.md"));
|
|
177
|
+
if (tacit && (tacit.isSymbolicLink() || tacit.nlink > 1))
|
|
178
|
+
return false;
|
|
179
|
+
if (context.friendId === undefined)
|
|
180
|
+
return true;
|
|
181
|
+
return context.trustLevel === undefined || context.trustLevel === "friend" || context.trustLevel === "family";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return context.friendKind === "human"
|
|
188
|
+
&& context.trustLevel === "family"
|
|
189
|
+
&& context.channel !== undefined && PSYCHE_IDENTITY_CHANNELS.has(context.channel)
|
|
190
|
+
&& context.isGroupChat !== true
|
|
191
|
+
// Teams does not yet tell the turn whether it is a group chat, so there only an explicit one-to-one counts.
|
|
192
|
+
&& (context.channel !== "teams" || context.isGroupChat === false);
|
|
193
|
+
}
|
|
194
|
+
/** The line a successful write to an identity file carries back to the agent. */
|
|
195
|
+
function psycheEditNote(file) {
|
|
196
|
+
return `psyche edit: I changed psyche/${file}. I tell my owner in one line which file changed and why.`;
|
|
197
|
+
}
|
|
198
|
+
/** The agent's psyche folder, in the lexical form the shell check uses; the file tools classify with `psycheFileClass`. */
|
|
199
|
+
function isPsychePath(filePath, agentRoot) {
|
|
77
200
|
const psyche = path.resolve(agentRoot, "psyche");
|
|
78
201
|
const target = path.resolve(agentRoot, filePath);
|
|
79
202
|
return target === psyche || target.startsWith(`${psyche}${path.sep}`);
|
|
@@ -85,8 +208,6 @@ function isPsychePath(filePath, agentRoot) {
|
|
|
85
208
|
* only: a shell can still write anywhere its uid can, which is why delegated-command authority lives outside the bundle.
|
|
86
209
|
*/
|
|
87
210
|
function isFriendsStorePath(filePath, agentRoot) {
|
|
88
|
-
if (!agentRoot || !filePath)
|
|
89
|
-
return false;
|
|
90
211
|
const store = path.resolve(agentRoot, "friends");
|
|
91
212
|
const target = path.resolve(agentRoot, filePath);
|
|
92
213
|
return target === store || target.startsWith(`${store}${path.sep}`);
|
|
@@ -132,9 +253,9 @@ function shellWritesToProtectedPath(command) {
|
|
|
132
253
|
return false;
|
|
133
254
|
}
|
|
134
255
|
// --- the psyche folder in shell commands ---
|
|
135
|
-
// The
|
|
136
|
-
//
|
|
137
|
-
// a read-only command, because enumerating the ways to write a file (>, >>, tee, cp, mv, sed -i, node -e, python, cd then
|
|
256
|
+
// The agent changes its own psyche through write_file and edit_file, where the trust gate below decides who may ask for it.
|
|
257
|
+
// The shell never writes there, for anyone: a shell command that mentions the psyche folder at all runs only if every part of
|
|
258
|
+
// it is a read-only command, because enumerating the ways to write a file (>, >>, tee, cp, mv, sed -i, node -e, python, cd then
|
|
138
259
|
// redirect) is a list the model can always step around.
|
|
139
260
|
const PSYCHE_READ_ONLY_COMMANDS = new Set(["cat", "ls", "head", "tail", "grep", "wc", "sha256sum", "stat", "cd", "pwd"]);
|
|
140
261
|
const SEGMENT_OPERATORS = new Set(["&&", "||", ";", "|"]);
|
|
@@ -177,7 +298,10 @@ function commandMentionsPsyche(command, words, agentRoot) {
|
|
|
177
298
|
if (words.some((word) => word === "psyche" || isPsychePath(word, agentRoot)))
|
|
178
299
|
return true;
|
|
179
300
|
// A relative "psyche/..." inside code or a quoted argument, when this agent has such a folder.
|
|
180
|
-
|
|
301
|
+
if (/(?:^|[\s'"=(])psyche\//.test(command) && fs.existsSync(psyche))
|
|
302
|
+
return true;
|
|
303
|
+
// ~/..., $HOME/... and paths relative to a cd or -C target that land in this agent's psyche/.
|
|
304
|
+
return commandMentionsAgentFolder(command, words, agentRoot, "psyche");
|
|
181
305
|
}
|
|
182
306
|
/**
|
|
183
307
|
* The shared rule: when a shell command mentions a protected folder at all, it runs only if every part of it is a read-only
|
|
@@ -212,7 +336,13 @@ function protectedFolderShellWriteRefused(command, mentions) {
|
|
|
212
336
|
function psycheShellWriteRefused(command, agentRoot) {
|
|
213
337
|
if (!agentRoot)
|
|
214
338
|
return false;
|
|
215
|
-
|
|
339
|
+
try {
|
|
340
|
+
return protectedFolderShellWriteRefused(command, (words) => commandMentionsPsyche(command, words, agentRoot));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// The filesystem could not say where a path that names psyche/ lands: refuse rather than guess.
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
216
346
|
}
|
|
217
347
|
// --- the friends store in shell commands ---
|
|
218
348
|
// Same stance as the psyche folder: a command that names this agent's friends store runs only if it is read-only, because a list of
|
|
@@ -234,12 +364,83 @@ function commandMentionsFriendsStore(command, words, agentRoot) {
|
|
|
234
364
|
return true;
|
|
235
365
|
// An absolute or relative path inside code or a quoted argument, such as node -e "...'/bundle/friends/x.json'...".
|
|
236
366
|
const store = path.resolve(agentRoot, "friends");
|
|
237
|
-
|
|
367
|
+
if (command.includes(store) || /(?:^|[\s'"=(])friends\//.test(command))
|
|
368
|
+
return true;
|
|
369
|
+
// Paths relative to a cd or -C target that land in this agent's friends/.
|
|
370
|
+
return commandMentionsAgentFolder(command, words, agentRoot, "friends");
|
|
238
371
|
}
|
|
239
372
|
function friendsShellWriteRefused(command, agentRoot) {
|
|
240
373
|
if (!agentRoot)
|
|
241
374
|
return false;
|
|
242
|
-
|
|
375
|
+
try {
|
|
376
|
+
return protectedFolderShellWriteRefused(command, (words) => commandMentionsFriendsStore(command, words, agentRoot));
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// The filesystem could not say where a path that names friends/ lands: refuse rather than guess.
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// --- where a path in a shell command lands ---
|
|
384
|
+
// A command can name one of this agent's folders by an absolute path, by ~/ or $HOME/, or relative to wherever it cd's (or
|
|
385
|
+
// git -C's) first. Each word whose path has a part exactly `<name>` is resolved against every directory the command could be
|
|
386
|
+
// working in, lexically and canonically, and counts when it lands in this agent's <name>/.
|
|
387
|
+
const UNFOLLOWABLE_WORKING_DIRECTORY = /(?:^|[\s;&|(`])(?:pushd|popd)(?:\s|$)|(?:^|[\s;&|(`])cd\s+(?:--|["']?\$)|\s-C\s*["']?\$|(?:^|[\s;&|(`/])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\b/;
|
|
388
|
+
/** Words of a shell command that name a path whose part is exactly `name`, wherever they sit (code and quotes included). */
|
|
389
|
+
function namedFolderWords(command, name) {
|
|
390
|
+
const part = new RegExp(`(?:^|/)${name}(?:/|$)`);
|
|
391
|
+
return command.split(/[\s'"=();|&<>`]+/).filter((token) => part.test(token));
|
|
392
|
+
}
|
|
393
|
+
function commandMentionsAgentFolder(command, words, agentRoot, name) {
|
|
394
|
+
const lexicalFolder = path.resolve(agentRoot, name);
|
|
395
|
+
if (globOrExpansionCouldReach(command, lexicalFolder, agentRoot))
|
|
396
|
+
return true;
|
|
397
|
+
const candidates = namedFolderWords(command, name);
|
|
398
|
+
if (candidates.length === 0)
|
|
399
|
+
return false;
|
|
400
|
+
// A variable in front of the path ($HOME/.../<name>/x): where it lands is the shell's to say, so it counts.
|
|
401
|
+
if (candidates.some((candidate) => candidate.includes("$")))
|
|
402
|
+
return true;
|
|
403
|
+
// A working directory this guard cannot follow (pushd, cd --, a cd or -C to a variable, or a nested shell's -c string):
|
|
404
|
+
// a relative path could land anywhere, so it counts.
|
|
405
|
+
if (UNFOLLOWABLE_WORKING_DIRECTORY.test(command))
|
|
406
|
+
return true;
|
|
407
|
+
// A path that names this bundle's own folder (x.ouro/psyche) or a glob just above the folder (*/psyche) is this agent's
|
|
408
|
+
// wherever the command works from, so eval, a piped shell, env --chdir, find -path and code that chdir's all count.
|
|
409
|
+
const bundle = path.basename(path.resolve(agentRoot)).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
410
|
+
const anchored = new RegExp(`(?:^|/)(?:${bundle}|[^/]*[*?[][^/]*)/${name}(?:/|$)`);
|
|
411
|
+
if (candidates.some((candidate) => anchored.test(candidate)))
|
|
412
|
+
return true;
|
|
413
|
+
const folders = [lexicalFolder, path.join(canonicalize(path.resolve(agentRoot)), name)];
|
|
414
|
+
// Every directory the command could be working in: where the shell starts, and each cd target, from each earlier one.
|
|
415
|
+
const cwds = [process.cwd()];
|
|
416
|
+
for (const [index, word] of words.entries()) {
|
|
417
|
+
// `git -C <dir>` works in <dir> just as `cd <dir>` does.
|
|
418
|
+
if (words[index - 1] !== "cd" && words[index - 1] !== "-C")
|
|
419
|
+
continue;
|
|
420
|
+
const target = expandHome(word);
|
|
421
|
+
cwds.push(...(path.isAbsolute(target) ? [target] : cwds.map((cwd) => path.resolve(cwd, target))));
|
|
422
|
+
}
|
|
423
|
+
return candidates.some((candidate) => {
|
|
424
|
+
const word = expandHome(candidate);
|
|
425
|
+
const lexicals = path.isAbsolute(word) ? [path.resolve(word)] : cwds.map((cwd) => path.resolve(cwd, word));
|
|
426
|
+
return lexicals.flatMap((lexical) => [lexical, canonicalize(lexical)])
|
|
427
|
+
.some((target) => folders.some((dir) => target === dir || target.startsWith(`${dir}${path.sep}`)));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// --- the product rules in shell commands ---
|
|
431
|
+
// product/PRODUCT.md is the package's standing rules for this agent; releases replace it, and nothing in a conversation does.
|
|
432
|
+
// Unlike the friends store, a bare relative "product/" is an ordinary project folder almost everywhere, so it counts as this
|
|
433
|
+
// agent's only when the command is plainly working in the agent root.
|
|
434
|
+
function productShellWriteRefused(command, agentRoot) {
|
|
435
|
+
if (!agentRoot)
|
|
436
|
+
return false;
|
|
437
|
+
try {
|
|
438
|
+
return protectedFolderShellWriteRefused(command, (words) => commandMentionsAgentFolder(command, words, agentRoot, "product"));
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// The filesystem could not say where a path that names product/ lands: refuse rather than guess.
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
243
444
|
}
|
|
244
445
|
// --- structural guardrail checks (always on, all trust levels) ---
|
|
245
446
|
function checkReadBeforeWrite(toolName, args, context) {
|
|
@@ -272,17 +473,37 @@ function checkDestructiveShellPatterns(toolName, args) {
|
|
|
272
473
|
function checkProtectedPaths(toolName, args, context) {
|
|
273
474
|
if (toolName === "write_file" || toolName === "edit_file") {
|
|
274
475
|
const filePath = args.path || "";
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
476
|
+
// The file tools resolve a relative path against the repo root and follow symlinks, so the guard checks every form.
|
|
477
|
+
// A path whose landing place the filesystem cannot report is refused rather than checked lexically.
|
|
478
|
+
const agentRoot = filePath ? context.agentRoot : undefined;
|
|
479
|
+
if (agentRoot) {
|
|
480
|
+
try {
|
|
481
|
+
const psycheClass = psycheFileClass(filePath, agentRoot);
|
|
482
|
+
if (psycheClass && !psycheWriteAllowed(psycheClass, filePath, context))
|
|
483
|
+
return deny(exports.PSYCHE_REFUSAL);
|
|
484
|
+
if (insideAgentFolder(filePath, agentRoot, "friends"))
|
|
485
|
+
return deny(REASONS.protectedPath);
|
|
486
|
+
if (insideAgentFolder(filePath, agentRoot, "product"))
|
|
487
|
+
return deny(REASONS.protectedPath);
|
|
488
|
+
// A link can carry a write that reads as harmless to a protected file (state/policy, agent.json).
|
|
489
|
+
if (canonicalTarget(filePath, agentRoot).some(isProtectedPath))
|
|
490
|
+
return deny(REASONS.protectedPath);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return deny(REASONS.protectedPath);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (isProtectedPath(filePath))
|
|
278
497
|
return deny(REASONS.protectedPath);
|
|
279
498
|
}
|
|
280
499
|
if (toolName === "shell") {
|
|
281
500
|
const command = args.command || "";
|
|
282
501
|
if (psycheShellWriteRefused(command, context.agentRoot))
|
|
283
|
-
return deny(
|
|
502
|
+
return deny(exports.PSYCHE_REFUSAL);
|
|
284
503
|
if (friendsShellWriteRefused(command, context.agentRoot))
|
|
285
504
|
return deny(REASONS.protectedPath);
|
|
505
|
+
if (productShellWriteRefused(command, context.agentRoot))
|
|
506
|
+
return deny(REASONS.protectedPath);
|
|
286
507
|
if (shellWritesToProtectedPath(command))
|
|
287
508
|
return deny(REASONS.protectedPath);
|
|
288
509
|
}
|
|
@@ -50,13 +50,13 @@ exports.reportFailureToolDefinition = {
|
|
|
50
50
|
type: "function",
|
|
51
51
|
function: {
|
|
52
52
|
name: "report_failure",
|
|
53
|
-
description: "File a failure report for the engineers who fix you. Use it when a tool you need is missing
|
|
53
|
+
description: "File a failure report for the engineers who fix you. Use it when a tool you need is missing or a tool errors in a way you cannot work around, so the engineers can get the owner's request moving. When the owner asks for something you have no tool for yet (for example, setting a thermostat), file it right away so the engineers can add it, and then tell him it is filed, with the short report id, in your own words. File it at once and ask him for nothing.",
|
|
54
54
|
parameters: {
|
|
55
55
|
type: "object",
|
|
56
56
|
properties: {
|
|
57
57
|
ari_words: { type: "string", description: "What the owner asked for, verbatim from the conversation." },
|
|
58
58
|
tried: { type: "string", description: "What you tried: tool names and arguments, summarized." },
|
|
59
|
-
error: { type: "string", description: "The tool error, or
|
|
59
|
+
error: { type: "string", description: "The tool error, or what stopped you." },
|
|
60
60
|
failed_tool: { type: "string", description: "The tool that failed or that you wished existed, if there is one." },
|
|
61
61
|
severity: { type: "string", enum: ["low", "medium", "high"], description: "How much this blocks the owner." },
|
|
62
62
|
},
|
|
@@ -41,6 +41,22 @@ const identity_1 = require("../heart/identity");
|
|
|
41
41
|
const file_state_1 = require("../mind/file-state");
|
|
42
42
|
const scrutiny_1 = require("../mind/scrutiny");
|
|
43
43
|
const tools_base_1 = require("./tools-base");
|
|
44
|
+
const guardrails_1 = require("./guardrails");
|
|
45
|
+
/** The psyche edit note for a write that landed on one of the agent's identity files, or "" for any other file. */
|
|
46
|
+
function psycheNoteFor(resolvedPath, ctx) {
|
|
47
|
+
try {
|
|
48
|
+
const agentRoot = ctx?.agentRoot ?? (0, identity_1.getAgentRoot)();
|
|
49
|
+
if ((0, guardrails_1.psycheFileClass)(resolvedPath, agentRoot) !== "identity")
|
|
50
|
+
return "";
|
|
51
|
+
// The note names the file that actually changed, even when the write went through a link.
|
|
52
|
+
const relative = path.relative(fs.realpathSync.native(path.resolve(agentRoot, "psyche")), fs.realpathSync.native(resolvedPath));
|
|
53
|
+
return `\n\n${(0, guardrails_1.psycheEditNote)(relative.split(path.sep).join("/"))}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* v8 ignore next -- defensive: the write already succeeded, so a failed lookup only drops the note @preserve */
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
44
60
|
function resolveLocalToolPath(targetPath) {
|
|
45
61
|
if (!path.isAbsolute(targetPath)) {
|
|
46
62
|
return path.resolve((0, identity_1.getRepoRoot)(), targetPath);
|
|
@@ -115,14 +131,15 @@ exports.fileToolDefinitions = [
|
|
|
115
131
|
},
|
|
116
132
|
},
|
|
117
133
|
},
|
|
118
|
-
handler: (a) => {
|
|
134
|
+
handler: (a, ctx) => {
|
|
119
135
|
const resolvedPath = resolveLocalToolPath(a.path);
|
|
120
136
|
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
121
137
|
fs.writeFileSync(resolvedPath, a.content, "utf-8");
|
|
122
138
|
(0, scrutiny_1.trackModifiedFile)(resolvedPath);
|
|
123
139
|
const scrutiny = (0, scrutiny_1.getPostImplementationScrutiny)((0, scrutiny_1.getModifiedFileCount)());
|
|
140
|
+
const note = psycheNoteFor(resolvedPath, ctx);
|
|
124
141
|
/* v8 ignore next -- scrutiny appendix branch depends on session-level file count @preserve */
|
|
125
|
-
return scrutiny ? `ok\n\n${scrutiny}` : "ok";
|
|
142
|
+
return (scrutiny ? `ok\n\n${scrutiny}` : "ok") + note;
|
|
126
143
|
},
|
|
127
144
|
summaryKeys: ["path"],
|
|
128
145
|
riskProfile: { mutates: "durable_state_write", risk: "high", reason: "writes file contents" },
|
|
@@ -144,7 +161,7 @@ exports.fileToolDefinitions = [
|
|
|
144
161
|
},
|
|
145
162
|
},
|
|
146
163
|
},
|
|
147
|
-
handler: (a) => {
|
|
164
|
+
handler: (a, ctx) => {
|
|
148
165
|
const resolvedPath = resolveLocalToolPath(a.path);
|
|
149
166
|
if (!tools_base_1.editFileReadTracker.has(resolvedPath)) {
|
|
150
167
|
return `error: you must read the file with read_file before editing it. call read_file on ${a.path} first.`;
|
|
@@ -193,6 +210,7 @@ exports.fileToolDefinitions = [
|
|
|
193
210
|
const newStringLines = a.new_string.split("\n");
|
|
194
211
|
const changeEndLine = changeStartLine + newStringLines.length;
|
|
195
212
|
const diffResult = buildContextDiff(lines, changeStartLine, changeEndLine);
|
|
213
|
+
const note = psycheNoteFor(resolvedPath, ctx);
|
|
196
214
|
// Track modified file and compute scrutiny appendix
|
|
197
215
|
(0, scrutiny_1.trackModifiedFile)(resolvedPath);
|
|
198
216
|
const scrutiny = (0, scrutiny_1.getPostImplementationScrutiny)((0, scrutiny_1.getModifiedFileCount)());
|
|
@@ -200,11 +218,11 @@ exports.fileToolDefinitions = [
|
|
|
200
218
|
/* v8 ignore start -- staleness+diff+scrutiny combo not exercised in integration tests @preserve */
|
|
201
219
|
if (stalenessCheck.stale) {
|
|
202
220
|
const base = `${diffResult}\n\n⚠️ warning: file changed externally since last read -- re-read recommended`;
|
|
203
|
-
return scrutiny ? `${base}\n\n${scrutiny}` : base;
|
|
221
|
+
return (scrutiny ? `${base}\n\n${scrutiny}` : base) + note;
|
|
204
222
|
}
|
|
205
223
|
/* v8 ignore stop */
|
|
206
224
|
/* v8 ignore next -- scrutiny appendix branch depends on session-level file count @preserve */
|
|
207
|
-
return scrutiny ? `${diffResult}\n\n${scrutiny}` : diffResult;
|
|
225
|
+
return (scrutiny ? `${diffResult}\n\n${scrutiny}` : diffResult) + note;
|
|
208
226
|
},
|
|
209
227
|
summaryKeys: ["path"],
|
|
210
228
|
riskProfile: { mutates: "durable_state_write", risk: "high", reason: "edits file contents" },
|
|
@@ -57,13 +57,13 @@ function bundleRelativeLocator(filePath, ctx) {
|
|
|
57
57
|
return filePath.split(path.sep).join("/");
|
|
58
58
|
}
|
|
59
59
|
// A friend note is a fact or preference about a person. The agent's own personality, voice and psyche are not facts
|
|
60
|
-
// about the person: they live in the bundle's psyche folder, which
|
|
60
|
+
// about the person: they live in the bundle's psyche folder, which the agent itself keeps.
|
|
61
61
|
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;
|
|
62
62
|
const PERSONA_KEY = /^\s*(?:persona|personality|psyche|soul)\s*$/i;
|
|
63
63
|
function isOwnPersonaNote(key, content) {
|
|
64
64
|
return PERSONA_KEY.test(key ?? "") || OWN_PERSONA_NOTE.test(`${key ?? ""} ${content ?? ""}`);
|
|
65
65
|
}
|
|
66
|
-
exports.OWN_PERSONA_NOTE_REFUSAL = "that note is about my own personality or voice, not about
|
|
66
|
+
exports.OWN_PERSONA_NOTE_REFUSAL = "that note is about my own personality or voice, not about this person, so it belongs in my psyche, not in a friend note. what i learn about how the house works goes into psyche/TACIT.md, and the rest of my psyche changes when my owner asks me directly. a note about the person themselves (their facts, preferences, voice, a character in a game) saves normally.";
|
|
67
67
|
exports.notesToolDefinitions = [
|
|
68
68
|
{
|
|
69
69
|
tool: {
|
|
@@ -446,7 +446,7 @@ exports.unraidToolDefinitions = [
|
|
|
446
446
|
readDefinition("unraid_check_services", "Freshly check the fixed public Sanctuary service endpoints and return bounded status with an observation timestamp.", "checkServices"),
|
|
447
447
|
readDefinition("sanctuary_get_download_queue", "Read the bounded live usenet (SABnzbd) download queue state only, including whether the spend guard has paused it. It cannot see torrents or Sonarr/Radarr progress: an idle result here does not mean a download is absent. For a series or movie download, including torrents, use media_queue.", "getDownloadQueue"),
|
|
448
448
|
{
|
|
449
|
-
tool: { type: "function", function: { name: "sanctuary_resume_download_queue", description: "Resume the household download queue
|
|
449
|
+
tool: { type: "function", function: { name: "sanctuary_resume_download_queue", description: "Resume the household download queue, then independently verify paused=false.", parameters: emptyParameters } },
|
|
450
450
|
handler: async (_args, ctx) => JSON.stringify(ctx?.sanctuary ? await ctx.sanctuary.resumeDownloadQueue() : JSON.parse(missingRuntime())),
|
|
451
451
|
riskProfile: { mutates: "external_side_effect", risk: "high", reason: "resumes downloads that can spend prepaid provider credit" },
|
|
452
452
|
approvalPolicy: () => ({ kind: "required", policyId: "sanctuary.downloads.resume.v1", actionClass: "sanctuary.downloads.resume", requiresSoleCall: true }),
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -433,6 +433,7 @@ async function preflightToolCall(name, args, ctx) {
|
|
|
433
433
|
const guardContext = {
|
|
434
434
|
readPaths: tools_base_1.editFileReadTracker, trustLevel: ctx?.context?.friend?.trustLevel,
|
|
435
435
|
agentRoot: ctx?.agentRoot ?? safeGetAgentRoot(), friendId: ctx?.context?.friend?.id,
|
|
436
|
+
friendKind: ctx?.context?.friend?.kind, channel: ctx?.context?.channel?.channel,
|
|
436
437
|
...(def?.mcpServer ? { mcpServerName: def.mcpServer } : {}),
|
|
437
438
|
...(typeof group === "boolean" ? { isGroupChat: group } : {}),
|
|
438
439
|
};
|
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.983",
|
|
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.983",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|