@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.992
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/Dockerfile +15 -2
- package/deploy/unraid/README.txt +44 -9
- package/deploy/unraid/github_known_hosts +3 -0
- package/deploy/unraid/ouro-sync-ssh +98 -0
- package/deploy/unraid/ouro-sync.sudoers +3 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
- package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
- package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
- package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/heart/config.js +2 -1
- package/dist/heart/daemon/agent-discovery.js +5 -1
- package/dist/heart/daemon/agent-home.js +67 -31
- package/dist/heart/daemon/boot-sync-probe.js +36 -10
- package/dist/heart/daemon/cli-exec.js +79 -20
- package/dist/heart/daemon/cli-help.js +12 -0
- package/dist/heart/daemon/cli-parse.js +14 -0
- package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
- package/dist/heart/daemon/container-spec-auditor.js +18 -2
- package/dist/heart/daemon/daemon-entry.js +22 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +21 -5
- package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
- package/dist/heart/daemon/sanctuary-package-management.js +10 -0
- package/dist/heart/sync-lock.js +431 -0
- package/dist/heart/sync.js +1252 -399
- package/dist/mind/prompt.js +92 -19
- package/dist/repertoire/guardrails.js +285 -3
- package/dist/repertoire/tools-bundle.js +107 -185
- package/dist/repertoire/tools.js +5 -1
- package/dist/senses/pipeline.js +37 -3
- package/dist/senses/private-runtime.js +1 -6
- package/dist/senses/voice/twilio-phone.js +16 -16
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/mind/prompt.js
CHANGED
|
@@ -65,6 +65,7 @@ const house_names_1 = require("./house-names");
|
|
|
65
65
|
const fs = __importStar(require("fs"));
|
|
66
66
|
const path = __importStar(require("path"));
|
|
67
67
|
const crypto_1 = require("crypto");
|
|
68
|
+
const child_process_1 = require("child_process");
|
|
68
69
|
const core_1 = require("../heart/core");
|
|
69
70
|
const ouro_version_manager_1 = require("../heart/versioning/ouro-version-manager");
|
|
70
71
|
const tools_1 = require("../repertoire/tools");
|
|
@@ -76,6 +77,7 @@ const runtime_credentials_1 = require("../heart/runtime-credentials");
|
|
|
76
77
|
const runtime_mode_1 = require("../heart/daemon/runtime-mode");
|
|
77
78
|
const friends_1 = require("@ouro.bot/friends");
|
|
78
79
|
const runtime_1 = require("../nerves/runtime");
|
|
80
|
+
const sync_1 = require("../heart/sync");
|
|
79
81
|
const relationship_authorization_1 = require("../repertoire/relationship-authorization");
|
|
80
82
|
const bundle_manifest_1 = require("./bundle-manifest");
|
|
81
83
|
const first_impressions_1 = require("./first-impressions");
|
|
@@ -112,16 +114,91 @@ const PSYCHE_SOURCE_FILES = {
|
|
|
112
114
|
product: ["product", "PRODUCT.md"],
|
|
113
115
|
houseNames: ["house", "house.json"],
|
|
114
116
|
};
|
|
115
|
-
/**
|
|
117
|
+
/** A conflict marker: a line starting `<<<<<<< ` with a later line starting `>>>>>>> `. A lone `=======` is valid text. */
|
|
118
|
+
function hasConflictMarkers(text) {
|
|
119
|
+
const lines = text.split("\n");
|
|
120
|
+
const open = lines.findIndex((line) => line.startsWith("<<<<<<< "));
|
|
121
|
+
return open !== -1 && lines.slice(open + 1).some((line) => line.startsWith(">>>>>>> "));
|
|
122
|
+
}
|
|
123
|
+
// The last text of each file (by absolute path) that was read without conflict markers.
|
|
124
|
+
const _lastGoodText = new Map();
|
|
125
|
+
// Marker problems already reported, by path and content hash.
|
|
126
|
+
const _reportedMarkers = new Set();
|
|
127
|
+
// The outcome of the git fallback for a marked file, by path, valid while the file's marked content is unchanged.
|
|
128
|
+
const _gitFallback = new Map();
|
|
129
|
+
/** The committed copy of a bundle file, or null for a non-git bundle or a path git does not have. */
|
|
130
|
+
function committedText(agentRoot, parts) {
|
|
131
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
132
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"])
|
|
133
|
+
delete env[name];
|
|
134
|
+
try {
|
|
135
|
+
return (0, child_process_1.execFileSync)("git", ["-c", "core.hooksPath=/dev/null", "show", `HEAD:./${parts.join("/")}`], {
|
|
136
|
+
cwd: agentRoot,
|
|
137
|
+
env,
|
|
138
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
139
|
+
encoding: "utf-8",
|
|
140
|
+
timeout: 5_000,
|
|
141
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function reportConflictMarkers(agentRoot, filePath, parts, text) {
|
|
149
|
+
const reportKey = `${filePath}:${(0, crypto_1.createHash)("sha256").update(text).digest("hex")}`;
|
|
150
|
+
if (_reportedMarkers.has(reportKey))
|
|
151
|
+
return;
|
|
152
|
+
_reportedMarkers.add(reportKey);
|
|
153
|
+
const relative = parts.join("/");
|
|
154
|
+
(0, runtime_1.emitNervesEvent)({
|
|
155
|
+
level: "warn",
|
|
156
|
+
component: "mind",
|
|
157
|
+
event: "psyche.conflict_markers",
|
|
158
|
+
message: "psyche file has unresolved merge conflict markers; keeping it out of the prompt",
|
|
159
|
+
meta: { path: filePath },
|
|
160
|
+
});
|
|
161
|
+
(0, sync_1.tellOwner)(agentRoot, (0, sync_1.agentNameFromRoot)(agentRoot), {
|
|
162
|
+
noticeId: `psyche-conflict-markers:${reportKey}`,
|
|
163
|
+
text: `${relative} has unresolved merge conflict markers.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** A file's text; text with conflict markers is replaced by the last good text, else the committed copy, else null. */
|
|
167
|
+
function readPsycheFile(agentRoot, parts) {
|
|
168
|
+
const filePath = path.join(agentRoot, ...parts);
|
|
169
|
+
let text;
|
|
170
|
+
try {
|
|
171
|
+
text = String(fs.readFileSync(filePath, "utf-8"));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (!hasConflictMarkers(text)) {
|
|
177
|
+
_lastGoodText.set(filePath, text);
|
|
178
|
+
_gitFallback.delete(filePath);
|
|
179
|
+
return text;
|
|
180
|
+
}
|
|
181
|
+
reportConflictMarkers(agentRoot, filePath, parts, text);
|
|
182
|
+
const lastGood = _lastGoodText.get(filePath);
|
|
183
|
+
if (lastGood !== undefined)
|
|
184
|
+
return lastGood;
|
|
185
|
+
const hash = (0, crypto_1.createHash)("sha256").update(text).digest("hex");
|
|
186
|
+
let remembered = _gitFallback.get(filePath);
|
|
187
|
+
if (remembered?.hash !== hash) {
|
|
188
|
+
remembered = { hash, committed: committedText(agentRoot, parts) };
|
|
189
|
+
_gitFallback.set(filePath, remembered);
|
|
190
|
+
}
|
|
191
|
+
const committed = remembered.committed;
|
|
192
|
+
if (committed === null || hasConflictMarkers(committed))
|
|
193
|
+
return null;
|
|
194
|
+
_lastGoodText.set(filePath, committed);
|
|
195
|
+
return committed;
|
|
196
|
+
}
|
|
197
|
+
/** Every file the psyche is built from, read once; null when a file is absent, unreadable or unusable. */
|
|
116
198
|
function readPsycheSources(agentRoot) {
|
|
117
199
|
const sources = {};
|
|
118
200
|
for (const [key, parts] of Object.entries(PSYCHE_SOURCE_FILES)) {
|
|
119
|
-
|
|
120
|
-
sources[key] = String(fs.readFileSync(path.join(agentRoot, ...parts), "utf-8"));
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
sources[key] = null;
|
|
124
|
-
}
|
|
201
|
+
sources[key] = readPsycheFile(agentRoot, parts);
|
|
125
202
|
}
|
|
126
203
|
return sources;
|
|
127
204
|
}
|
|
@@ -165,6 +242,9 @@ function loadPsyche() {
|
|
|
165
242
|
}
|
|
166
243
|
function resetPsycheCache() {
|
|
167
244
|
_psycheCache.clear();
|
|
245
|
+
_lastGoodText.clear();
|
|
246
|
+
_reportedMarkers.clear();
|
|
247
|
+
_gitFallback.clear();
|
|
168
248
|
}
|
|
169
249
|
const DEFAULT_ACTIVE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
170
250
|
function buildSessionSummary(options) {
|
|
@@ -321,19 +401,12 @@ the general flow when i see a non-empty bundleState:
|
|
|
321
401
|
enumerates my PII payload (friends, Desk record, Arc, etc.), probes
|
|
322
402
|
the remote for github public/private status, and returns a warning
|
|
323
403
|
text i MUST show my friend verbatim. only after explicit confirmation
|
|
324
|
-
do i
|
|
325
|
-
|
|
326
|
-
6. if \`remote_push_failed\`: the remote advanced and my retry was
|
|
327
|
-
rejected too. use \`bundle_pull_rebase\` to pull down their changes
|
|
328
|
-
and rebase my commits on top.
|
|
329
|
-
7. if \`pull_rebase_conflict\`: the rebase left merge conflicts i need
|
|
330
|
-
to walk my friend through. read \`state/pending-sync.json\` to see
|
|
331
|
-
the conflictFiles array, then resolve each conflict in conversation
|
|
332
|
-
with them before re-running \`bundle_pull_rebase\`.
|
|
404
|
+
do i pass the token the review returned as \`confirmation_token\` to
|
|
405
|
+
\`bundle_push\` or \`bundle_pull_rebase\`.
|
|
333
406
|
|
|
334
|
-
|
|
335
|
-
explicit force flag — that's the safety rail. i
|
|
336
|
-
without asking my friend first.
|
|
407
|
+
\`bundle_init_git\` and \`bundle_add_remote\` refuse to overwrite by
|
|
408
|
+
default and require an explicit force flag — that's the safety rail. i
|
|
409
|
+
never pass force without asking my friend first.
|
|
337
410
|
|
|
338
411
|
### peers — other agents on this machine
|
|
339
412
|
|
|
@@ -33,12 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.OURO_CLI_TRUST_MANIFEST = exports.PSYCHE_REFUSAL = void 0;
|
|
36
|
+
exports.BUNDLE_GIT_REFUSAL = exports.OURO_CLI_TRUST_MANIFEST = exports.PSYCHE_REFUSAL = void 0;
|
|
37
37
|
exports.canonicalTarget = canonicalTarget;
|
|
38
38
|
exports.insideAgentFolder = insideAgentFolder;
|
|
39
39
|
exports.psycheFileClass = psycheFileClass;
|
|
40
40
|
exports.psycheWriteAllowed = psycheWriteAllowed;
|
|
41
41
|
exports.psycheEditNote = psycheEditNote;
|
|
42
|
+
exports.bundleGitRefused = bundleGitRefused;
|
|
42
43
|
exports.guardInvocation = guardInvocation;
|
|
43
44
|
const fs = __importStar(require("node:fs"));
|
|
44
45
|
const os = __importStar(require("node:os"));
|
|
@@ -48,6 +49,7 @@ const shell_quote_1 = require("shell-quote");
|
|
|
48
49
|
const runtime_1 = require("../nerves/runtime");
|
|
49
50
|
const store_1 = require("../commerce/store");
|
|
50
51
|
const identity_1 = require("../heart/identity");
|
|
52
|
+
const sync_1 = require("../heart/sync");
|
|
51
53
|
const deny = (reason) => ({ allowed: false, reason });
|
|
52
54
|
const allow = { allowed: true };
|
|
53
55
|
// --- reason templates ---
|
|
@@ -56,6 +58,7 @@ const REASONS = {
|
|
|
56
58
|
readBeforeEdit: "i need to read that file first before i can edit it.",
|
|
57
59
|
readBeforeOverwrite: "i need to read that file first before i can overwrite it.",
|
|
58
60
|
protectedPath: "that path is protected — i can read it but not modify it.",
|
|
61
|
+
unreadablePath: "that path is protected — i can't read it.",
|
|
59
62
|
destructiveCommand: "that command is too dangerous to run — it could cause irreversible damage.",
|
|
60
63
|
// Trust reasons (vary by relationship)
|
|
61
64
|
needsTrust: "i'd need a closer friend to vouch for you before i can do that.",
|
|
@@ -508,6 +511,10 @@ function checkProtectedPaths(toolName, args, context) {
|
|
|
508
511
|
return deny(REASONS.protectedPath);
|
|
509
512
|
if (productShellWriteRefused(command, context.agentRoot))
|
|
510
513
|
return deny(REASONS.protectedPath);
|
|
514
|
+
if (bundleGitRefused(command, context.agentRoot))
|
|
515
|
+
return deny(exports.BUNDLE_GIT_REFUSAL);
|
|
516
|
+
if (syncSecretShellRefused(command))
|
|
517
|
+
return deny(REASONS.unreadablePath);
|
|
511
518
|
if (shellWritesToProtectedPath(command))
|
|
512
519
|
return deny(REASONS.protectedPath);
|
|
513
520
|
}
|
|
@@ -1446,6 +1453,281 @@ function checkTrustLevelGuardrails(toolName, args, context) {
|
|
|
1446
1453
|
}
|
|
1447
1454
|
return checkWriteTrustGuardrails(toolName, args, context);
|
|
1448
1455
|
}
|
|
1456
|
+
// --- git aimed at this agent's own bundle ---
|
|
1457
|
+
// Sync owns the bundle's repository: it commits, merges and pushes under its own lock. A git command from the shell that
|
|
1458
|
+
// works in the bundle, or points git at it, is refused. Every directory the command could be working in counts: where the
|
|
1459
|
+
// shell starts, each cd, pushd, env -C and git -C, and the --git-dir, --work-tree, GIT_DIR and GIT_WORK_TREE it names.
|
|
1460
|
+
// A git call whose directory the guard cannot work out (a variable, a glob, cd -) is refused too.
|
|
1461
|
+
exports.BUNDLE_GIT_REFUSAL = "my bundle syncs itself; I don't run git in it.";
|
|
1462
|
+
const GIT_PATH_VARIABLES = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"]);
|
|
1463
|
+
const GIT_GUARD_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
1464
|
+
/** The commands a runner (xargs, find -exec, timeout) can start that the scan follows into. */
|
|
1465
|
+
const RUNNER_GIT_TARGETS = new Set(["git", "env", "eval", ...GIT_GUARD_SHELLS]);
|
|
1466
|
+
const DECLARATION_BUILTINS = new Set(["export", "declare", "typeset", "local", "readonly"]);
|
|
1467
|
+
// Commands that run another command given as their arguments; the guard looks for git (or a shell) among those arguments.
|
|
1468
|
+
const COMMAND_RUNNERS = new Set(["xargs", "find", "timeout", "nice", "ionice", "stdbuf", "setsid", "flock", "watch", "parallel", "doas", "chronic", "unbuffer", "chroot"]);
|
|
1469
|
+
/** git global options that take their value as the next word. */
|
|
1470
|
+
const GIT_VALUE_OPTIONS = new Set(["--namespace", "--exec-path", "--super-prefix", "--attr-source"]);
|
|
1471
|
+
function unknownShellWord(word) {
|
|
1472
|
+
return word.includes(SHELL_PARAMETER_MARKER) || word.includes(SHELL_ARRAY_EXPANSION) || /[*?[\]{}]/.test(word);
|
|
1473
|
+
}
|
|
1474
|
+
function shellPathWord(word) {
|
|
1475
|
+
return word === undefined || unknownShellWord(word) ? null : expandHome(word);
|
|
1476
|
+
}
|
|
1477
|
+
function noteGitAssignment(token, scan, known) {
|
|
1478
|
+
const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s);
|
|
1479
|
+
if (!assignment)
|
|
1480
|
+
return false;
|
|
1481
|
+
const [, name, value] = assignment;
|
|
1482
|
+
if (GIT_PATH_VARIABLES.has(name))
|
|
1483
|
+
scan.gitPaths.push(value);
|
|
1484
|
+
if (/^GIT_CONFIG(?:_|$)/.test(name))
|
|
1485
|
+
scan.configEnv = true;
|
|
1486
|
+
if (name === "CDPATH")
|
|
1487
|
+
scan.cdpath = true;
|
|
1488
|
+
if (unknownShellWord(value))
|
|
1489
|
+
known.delete(name);
|
|
1490
|
+
else
|
|
1491
|
+
known.set(name, value);
|
|
1492
|
+
return true;
|
|
1493
|
+
}
|
|
1494
|
+
function scanShellForGit(command, scan, depth) {
|
|
1495
|
+
if (depth >= 32) {
|
|
1496
|
+
scan.opaqueShell = true;
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const parsed = parseShellCommands(executableShellView(command));
|
|
1500
|
+
const known = new Map();
|
|
1501
|
+
for (const name of STABLE_VARIABLES)
|
|
1502
|
+
if (process.env[name])
|
|
1503
|
+
known.set(name, process.env[name]);
|
|
1504
|
+
for (const raw of parsed.commands) {
|
|
1505
|
+
const tokens = raw.map((token) => resolveKnownShellParameters(token, known, parsed.parameterNames));
|
|
1506
|
+
let index = 0;
|
|
1507
|
+
while (index < tokens.length && noteGitAssignment(tokens[index], scan, known))
|
|
1508
|
+
index += 1;
|
|
1509
|
+
if (DECLARATION_BUILTINS.has(tokens[index] ?? "")) {
|
|
1510
|
+
for (const token of tokens.slice(index + 1))
|
|
1511
|
+
noteGitAssignment(token, scan, known);
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
if (index < tokens.length)
|
|
1515
|
+
scanCommandForGit(tokens, index, scan, known, depth);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function scanCommandForGit(tokens, start, scan, known, depth) {
|
|
1519
|
+
let index = start;
|
|
1520
|
+
for (let hops = 0; hops <= tokens.length; hops += 1) {
|
|
1521
|
+
const resolved = resolveShellCommand(tokens, index);
|
|
1522
|
+
// A wrapper's own options: env -C and sudo --chdir change the directory, env NAME=value sets git's variables.
|
|
1523
|
+
for (let k = index; k < resolved.index; k += 1) {
|
|
1524
|
+
const word = tokens[k];
|
|
1525
|
+
if (word === "-C" || word === "--chdir")
|
|
1526
|
+
scan.cwdTargets.push(shellPathWord(tokens[k + 1]));
|
|
1527
|
+
else if (word.startsWith("--chdir="))
|
|
1528
|
+
scan.cwdTargets.push(shellPathWord(word.slice("--chdir=".length)));
|
|
1529
|
+
else if (/^-C./.test(word))
|
|
1530
|
+
scan.cwdTargets.push(shellPathWord(word.slice(2)));
|
|
1531
|
+
else
|
|
1532
|
+
noteGitAssignment(word, scan, known);
|
|
1533
|
+
}
|
|
1534
|
+
if (resolved.script !== undefined)
|
|
1535
|
+
return scanShellForGit(resolved.script, scan, depth + 1);
|
|
1536
|
+
index = resolved.index;
|
|
1537
|
+
if (index >= tokens.length)
|
|
1538
|
+
return;
|
|
1539
|
+
// A command word the guard cannot read ($RUN git status) could be anything, git or a wrapper around it.
|
|
1540
|
+
if (unknownShellWord(tokens[index])) {
|
|
1541
|
+
if (tokens.slice(index).some((word) => /(?:^|\/)git$/.test(word)))
|
|
1542
|
+
scan.opaqueShell = true;
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const executable = exactShellExecutable(tokens[index]);
|
|
1546
|
+
if (executable === "git") {
|
|
1547
|
+
scan.calls.push(tokens.slice(index + 1));
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (executable === "cd" || executable === "pushd") {
|
|
1551
|
+
const target = tokens.slice(index + 1).find((word) => !/^-[LPe@]+$/.test(word));
|
|
1552
|
+
const viaCdpath = target !== undefined && scan.cdpath && !path.isAbsolute(expandHome(target));
|
|
1553
|
+
scan.cwdTargets.push(target === undefined ? (executable === "cd" ? os.homedir() : null) : target === "-" || viaCdpath ? null : shellPathWord(target));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (GIT_GUARD_SHELLS.has(executable)) {
|
|
1557
|
+
const option = tokens.findIndex((word, k) => k > index && /^-[^-]*c/.test(word));
|
|
1558
|
+
if (option >= 0)
|
|
1559
|
+
return scanShellForGit(tokens[option + 1] ?? "", scan, depth + 1);
|
|
1560
|
+
if (tokens.slice(index + 1).every((word) => word.startsWith("-")))
|
|
1561
|
+
scan.opaqueShell = true;
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (executable === "eval")
|
|
1565
|
+
return scanShellForGit(tokens.slice(index + 1).join(" "), scan, depth + 1);
|
|
1566
|
+
if (!COMMAND_RUNNERS.has(executable))
|
|
1567
|
+
return;
|
|
1568
|
+
// find -execdir runs its command in each directory it finds.
|
|
1569
|
+
if (executable === "find" && tokens.some((word, k) => k > index && (word === "-execdir" || word === "-okdir")))
|
|
1570
|
+
scan.cwdTargets.push(null);
|
|
1571
|
+
// xargs -I/-i/--replace puts its input into the command's words.
|
|
1572
|
+
if (executable === "xargs" && tokens.some((word, k) => k > index && /^(?:-I|-i|--replace)/.test(word)))
|
|
1573
|
+
scan.cwdTargets.push(null);
|
|
1574
|
+
const next = tokens.findIndex((word, k) => k > index && RUNNER_GIT_TARGETS.has(exactShellExecutable(word)));
|
|
1575
|
+
if (next < 0)
|
|
1576
|
+
return;
|
|
1577
|
+
index = next;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
/** Where one git call works and what it names: its directories after -C, then --git-dir, --work-tree, and init or clone targets. Null when a word is unknown. */
|
|
1581
|
+
function gitCallPaths(args, cwds) {
|
|
1582
|
+
let dirs = [...cwds];
|
|
1583
|
+
const named = [];
|
|
1584
|
+
let index = 0;
|
|
1585
|
+
const value = (word) => shellPathWord(word);
|
|
1586
|
+
while (index < args.length) {
|
|
1587
|
+
const word = args[index];
|
|
1588
|
+
if (word === "-C") {
|
|
1589
|
+
const dir = value(args[index + 1]);
|
|
1590
|
+
if (dir === null)
|
|
1591
|
+
return null;
|
|
1592
|
+
dirs = dirs.map((cwd) => path.isAbsolute(dir) ? dir : cwd === null ? null : path.resolve(cwd, dir));
|
|
1593
|
+
index += 2;
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
if (word === "--git-dir" || word === "--work-tree") {
|
|
1597
|
+
const dir = value(args[index + 1]);
|
|
1598
|
+
if (dir === null)
|
|
1599
|
+
return null;
|
|
1600
|
+
named.push(dir);
|
|
1601
|
+
index += 2;
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (word.startsWith("--git-dir=") || word.startsWith("--work-tree=")) {
|
|
1605
|
+
const dir = value(word.slice(word.indexOf("=") + 1));
|
|
1606
|
+
if (dir === null)
|
|
1607
|
+
return null;
|
|
1608
|
+
named.push(dir);
|
|
1609
|
+
index += 1;
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
if (word === "-c") {
|
|
1613
|
+
const setting = args[index + 1] ?? "";
|
|
1614
|
+
// An alias can run any command, git in any directory included.
|
|
1615
|
+
if (/^alias\./i.test(setting))
|
|
1616
|
+
return null;
|
|
1617
|
+
const worktree = setting.match(/^core\.worktree=(.*)$/is);
|
|
1618
|
+
if (worktree) {
|
|
1619
|
+
const dir = value(worktree[1]);
|
|
1620
|
+
if (dir === null)
|
|
1621
|
+
return null;
|
|
1622
|
+
named.push(dir);
|
|
1623
|
+
}
|
|
1624
|
+
index += 2;
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
if (word === "--config-env" || word.startsWith("--config-env="))
|
|
1628
|
+
return null;
|
|
1629
|
+
if (GIT_VALUE_OPTIONS.has(word)) {
|
|
1630
|
+
index += 2;
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (word.startsWith("-")) {
|
|
1634
|
+
index += 1;
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
// The subcommand. init and clone also name a directory to create or fill.
|
|
1638
|
+
const positional = args.slice(index + 1).filter((arg) => !arg.startsWith("-"));
|
|
1639
|
+
const separate = args.slice(index + 1).flatMap((arg, k, rest) => arg === "--separate-git-dir" ? [rest[k + 1]] : arg.startsWith("--separate-git-dir=") ? [arg.slice("--separate-git-dir=".length)] : []);
|
|
1640
|
+
const targets = word === "init" ? [...positional.slice(-1), ...separate] : word === "clone" && positional.length >= 2 ? [...positional.slice(-1), ...separate] : [];
|
|
1641
|
+
for (const target of targets) {
|
|
1642
|
+
const dir = value(target);
|
|
1643
|
+
if (dir === null)
|
|
1644
|
+
return null;
|
|
1645
|
+
named.push(dir);
|
|
1646
|
+
}
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
if (dirs.some((dir) => dir === null))
|
|
1650
|
+
return null;
|
|
1651
|
+
const known = dirs;
|
|
1652
|
+
return [...known, ...named.flatMap((dir) => path.isAbsolute(dir) ? [dir] : known.map((cwd) => path.resolve(cwd, dir)))];
|
|
1653
|
+
}
|
|
1654
|
+
/** Whether a shell command could run git in the agent's bundle. Exported for tests. */
|
|
1655
|
+
function bundleGitRefused(command, agentRoot) {
|
|
1656
|
+
// Quotes and backslashes can split the word (g''it, "g"it, g\it); the shell joins it back.
|
|
1657
|
+
const plain = command.replace(/['"\\]/g, "");
|
|
1658
|
+
if (!agentRoot || !plain.includes("git"))
|
|
1659
|
+
return false;
|
|
1660
|
+
try {
|
|
1661
|
+
const scan = { cwdTargets: [], gitPaths: [], calls: [], opaqueShell: false, configEnv: false, cdpath: !!process.env.CDPATH };
|
|
1662
|
+
scanShellForGit(command, scan, 0);
|
|
1663
|
+
if (scan.opaqueShell && /(?:^|[^A-Za-z0-9_-])git(?:$|[^A-Za-z0-9_-])/.test(plain))
|
|
1664
|
+
return true;
|
|
1665
|
+
if (scan.calls.length === 0)
|
|
1666
|
+
return false;
|
|
1667
|
+
// Every directory the command could be working in: where the shell starts, and each change, from each earlier one.
|
|
1668
|
+
const cwds = [process.cwd()];
|
|
1669
|
+
for (const target of scan.cwdTargets) {
|
|
1670
|
+
if (target === null)
|
|
1671
|
+
cwds.push(null);
|
|
1672
|
+
else
|
|
1673
|
+
cwds.push(...(path.isAbsolute(target) ? [target] : cwds.map((cwd) => cwd === null ? null : path.resolve(cwd, target))));
|
|
1674
|
+
}
|
|
1675
|
+
const roots = [path.resolve(agentRoot), canonicalize(path.resolve(agentRoot))];
|
|
1676
|
+
// git config from the environment can hide a git aimed at the bundle in an alias: refuse it when the command names the bundle.
|
|
1677
|
+
if (scan.configEnv && [...roots, path.basename(agentRoot)].some((root) => plain.includes(root)))
|
|
1678
|
+
return true;
|
|
1679
|
+
const inBundle = (target) => [target, canonicalize(target)].some((form) => roots.some((root) => form === root || form.startsWith(`${root}${path.sep}`)));
|
|
1680
|
+
for (const call of scan.calls) {
|
|
1681
|
+
const paths = gitCallPaths(call, cwds);
|
|
1682
|
+
if (paths === null)
|
|
1683
|
+
return true;
|
|
1684
|
+
const variables = scan.gitPaths.map((value) => shellPathWord(value));
|
|
1685
|
+
if (variables.some((value) => value === null))
|
|
1686
|
+
return true;
|
|
1687
|
+
const bases = paths.slice(0, cwds.length);
|
|
1688
|
+
const named = variables.flatMap((value) => path.isAbsolute(value) ? [value] : bases.map((base) => path.resolve(base, value)));
|
|
1689
|
+
if ([...paths, ...named].some(inBundle))
|
|
1690
|
+
return true;
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1694
|
+
catch {
|
|
1695
|
+
// The command could not be parsed, or the filesystem could not say where a path lands: refuse rather than guess.
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
// --- the sync key's directory, closed to the read-only file tools ---
|
|
1700
|
+
/** The directories read_file, glob and grep would read for these arguments, as the tools resolve them. */
|
|
1701
|
+
function readToolTargets(toolName, args) {
|
|
1702
|
+
const local = (p) => path.isAbsolute(p) ? p : path.resolve((0, identity_1.getRepoRoot)(), p);
|
|
1703
|
+
if (toolName === "read_file")
|
|
1704
|
+
return [{ target: local(args.path ?? ""), recursive: false }];
|
|
1705
|
+
if (toolName === "grep")
|
|
1706
|
+
return [{ target: local(args.path ?? ""), recursive: true }];
|
|
1707
|
+
const cwd = args.cwd ? local(args.cwd) : process.cwd();
|
|
1708
|
+
// The part of the pattern before its first glob segment is a directory the glob reads.
|
|
1709
|
+
const segments = (args.pattern ?? "").split("/");
|
|
1710
|
+
const firstGlob = segments.findIndex((segment) => /[*?[\]{}()!]/.test(segment));
|
|
1711
|
+
const literal = (firstGlob < 0 ? segments : segments.slice(0, firstGlob)).join("/");
|
|
1712
|
+
return [{ target: cwd, recursive: false }, { target: path.resolve(cwd, literal || "."), recursive: false }];
|
|
1713
|
+
}
|
|
1714
|
+
function syncSecretReadRefused(toolName, args) {
|
|
1715
|
+
try {
|
|
1716
|
+
const lexical = path.resolve((0, sync_1.syncSecretDir)());
|
|
1717
|
+
const secrets = [lexical, canonicalize(lexical)];
|
|
1718
|
+
return readToolTargets(toolName, args).some(({ target, recursive }) => [target, canonicalize(target)].some((form) => secrets.some((secret) => form === secret || form.startsWith(`${secret}${path.sep}`) || (recursive && (form === path.parse(form).root || secret.startsWith(`${form}${path.sep}`))))));
|
|
1719
|
+
}
|
|
1720
|
+
catch {
|
|
1721
|
+
return true;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* A shell command that names the key's directory. The agent's uid cannot read
|
|
1726
|
+
* the key; this only turns an accidental attempt into a plain refusal.
|
|
1727
|
+
*/
|
|
1728
|
+
function syncSecretShellRefused(command) {
|
|
1729
|
+
return command.includes(path.resolve((0, sync_1.syncSecretDir)()));
|
|
1730
|
+
}
|
|
1449
1731
|
// --- main entry point ---
|
|
1450
1732
|
function guardInvocation(toolName, args, context) {
|
|
1451
1733
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -1454,9 +1736,9 @@ function guardInvocation(toolName, args, context) {
|
|
|
1454
1736
|
message: "guardrail check",
|
|
1455
1737
|
meta: { toolName },
|
|
1456
1738
|
});
|
|
1457
|
-
// Read-only tools
|
|
1739
|
+
// Read-only tools skip the structural and trust guardrails; only the sync key's directory is closed to them.
|
|
1458
1740
|
if (READ_ONLY_TOOLS.has(toolName))
|
|
1459
|
-
return allow;
|
|
1741
|
+
return syncSecretReadRefused(toolName, args) ? deny(REASONS.unreadablePath) : allow;
|
|
1460
1742
|
// Layer 1: structural guardrails (always on)
|
|
1461
1743
|
const structuralResult = checkStructuralGuardrails(toolName, args, context);
|
|
1462
1744
|
if (!structuralResult.allowed)
|