@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.
Files changed (38) hide show
  1. package/changelog.json +12 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +21 -5
  26. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  27. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  28. package/dist/heart/sync-lock.js +431 -0
  29. package/dist/heart/sync.js +1252 -399
  30. package/dist/mind/prompt.js +92 -19
  31. package/dist/repertoire/guardrails.js +285 -3
  32. package/dist/repertoire/tools-bundle.js +107 -185
  33. package/dist/repertoire/tools.js +5 -1
  34. package/dist/senses/pipeline.js +37 -3
  35. package/dist/senses/private-runtime.js +1 -6
  36. package/dist/senses/voice/twilio-phone.js +16 -16
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
@@ -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
- /** Every file the psyche is built from, read once; null when a file is absent or unreadable. */
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
- try {
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 call \`bundle_push confirmation_token: ...\` with the token
325
- the review returned.
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
- these tools refuse destructive operations by default and require an
335
- explicit force flag — that's the safety rail. i never pass force
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 are always allowed (no structural or trust guardrails)
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)