@garuda-agent/garuda 0.16.1 → 0.18.0

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 (63) hide show
  1. package/CHANGELOG.md +319 -0
  2. package/README.md +56 -1074
  3. package/SECURITY.md +9 -2
  4. package/dist/{acpCommand-OTAYW7BS.js → acpCommand-IUZPLPLI.js} +25 -23
  5. package/dist/{anthropic-NQDBCHJU.js → anthropic-NQIZLD64.js} +12 -6
  6. package/dist/{chunk-NP6RIC25.js → chunk-2FIV2VZP.js} +11 -7
  7. package/dist/{chunk-YQTGMCU6.js → chunk-3MRQJKUO.js} +29 -5
  8. package/dist/{chunk-HE2K657U.js → chunk-4BVCYCQT.js} +47 -16
  9. package/dist/{chunk-WMOLXTBU.js → chunk-4LFJFDCF.js} +2 -1
  10. package/dist/{chunk-44QN5SWZ.js → chunk-55CFEEIN.js} +22 -2
  11. package/dist/{chunk-USCUXCAU.js → chunk-5B5HDTUR.js} +3 -3
  12. package/dist/chunk-65NUEOI7.js +143 -0
  13. package/dist/{chunk-TUUEBHG2.js → chunk-6XHON3Q6.js} +2 -2
  14. package/dist/chunk-7KBOIPUT.js +157 -0
  15. package/dist/{chunk-B6FA4I7Q.js → chunk-7TH3NHCQ.js} +2 -2
  16. package/dist/{chunk-OUCV3FCT.js → chunk-AOGFYUKG.js} +3 -3
  17. package/dist/{chunk-GXZACJUI.js → chunk-B4SJY7KT.js} +322 -39
  18. package/dist/{chunk-3EQU2GUX.js → chunk-BHZ6G65V.js} +5 -1
  19. package/dist/chunk-C57GHLKP.js +9 -0
  20. package/dist/chunk-DOCDCQVM.js +484 -0
  21. package/dist/chunk-FOPBOB7W.js +332 -0
  22. package/dist/{chunk-TILFPYWO.js → chunk-NHWHG3SW.js} +1930 -992
  23. package/dist/{chunk-BQTUOO4G.js → chunk-OQ26W5L4.js} +81 -32
  24. package/dist/{chunk-HUIK3WXO.js → chunk-P6XWWIEK.js} +20 -3
  25. package/dist/chunk-Q4P3W657.js +6372 -0
  26. package/dist/{chunk-ZAE25JM2.js → chunk-QANRZW7W.js} +8 -1
  27. package/dist/chunk-UIRJOQQS.js +118 -0
  28. package/dist/chunk-UKIE4AAM.js +472 -0
  29. package/dist/{chunk-KKB5W47S.js → chunk-VTMD5IHD.js} +26 -2
  30. package/dist/cli/index.js +148 -57
  31. package/dist/config-WV2H64TF.js +23 -0
  32. package/dist/{create-VXIH2GUZ.js → create-W6BV75QX.js} +18 -12
  33. package/dist/{deadline-F5RJJ2QW.js → deadline-TP7ZGLT2.js} +4 -2
  34. package/dist/evalCommand-SKJQ6CW2.js +686 -0
  35. package/dist/evalTune-CZRCPSZ3.js +295 -0
  36. package/dist/{export-HN3CT7AV.js → export-SJ3DUP6W.js} +2 -2
  37. package/dist/{inkChat-WEDGQMCY.js → inkChat-WYYAFZXD.js} +20 -19
  38. package/dist/install-TKWN5AYH.js +19 -0
  39. package/dist/{job-3NYDMP2M.js → job-7JOI4YIQ.js} +6 -2
  40. package/dist/jobCommand-2ODQLHB3.js +33 -0
  41. package/dist/{launchd-SSX362VG.js → launchd-3A3ONEW5.js} +9 -8
  42. package/dist/{lspCommand-JRXKRSL7.js → lspCommand-ZZ6R4PA2.js} +7 -6
  43. package/dist/{manager-773PUDFE.js → manager-TCYTA5XU.js} +15 -14
  44. package/dist/{nightCommand-65MUEV32.js → nightCommand-WJU6VZA6.js} +26 -20
  45. package/dist/{openaiCompatible-CJWVJP5M.js → openaiCompatible-6TLDDW5U.js} +29 -15
  46. package/dist/{policy-EJPLEEXP.js → policy-ZJ7RMOFN.js} +10 -4
  47. package/dist/{remove-6GDSTAIO.js → remove-42WQNVPX.js} +8 -7
  48. package/dist/{run-ML6GSPN4.js → run-PTMRQCIU.js} +5 -5
  49. package/dist/{server-NGKVIQEV.js → server-VEYIJHEU.js} +2 -2
  50. package/dist/{setupCommand-VA7D5MZK.js → setupCommand-YMF6I3MD.js} +45 -13
  51. package/dist/text-LS35WSRL.js +17 -0
  52. package/dist/tune-YVI2KWQF.js +31 -0
  53. package/native/bin/garuda-reap +0 -0
  54. package/native/garuda-reap.c +113 -0
  55. package/package.json +39 -13
  56. package/dist/chunk-EIXUSVFX.js +0 -116
  57. package/dist/chunk-R7I2NBPX.js +0 -71
  58. package/dist/chunk-ULAJPMY7.js +0 -107
  59. package/dist/chunk-VBZCNMUD.js +0 -9
  60. package/dist/evalCommand-RGMZRYDR.js +0 -2812
  61. package/dist/install-EWDSPFFZ.js +0 -18
  62. package/dist/jobCommand-ACSGSBQ4.js +0 -32
  63. package/dist/text-J2GD647J.js +0 -11
@@ -1,16 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- realRelative
4
- } from "./chunk-WMOLXTBU.js";
5
2
  import {
6
3
  EFFORTS
7
- } from "./chunk-3EQU2GUX.js";
4
+ } from "./chunk-BHZ6G65V.js";
8
5
  import {
6
+ PROFILES,
9
7
  isCommandDisallowedByPolicy,
10
8
  isHostBlockedByPolicy,
11
9
  isPathDeniedByPolicy,
12
10
  isSandboxRequiredByPolicy
13
- } from "./chunk-HUIK3WXO.js";
11
+ } from "./chunk-P6XWWIEK.js";
12
+ import {
13
+ prepareWriteScope,
14
+ scopeDenial,
15
+ scopePaths
16
+ } from "./chunk-UIRJOQQS.js";
17
+ import {
18
+ realRelative
19
+ } from "./chunk-4LFJFDCF.js";
14
20
  import {
15
21
  CASE_INSENSITIVE_FS,
16
22
  formatRule,
@@ -131,6 +137,14 @@ function unique(paths) {
131
137
  return [...new Set(paths.filter((p) => isAbsolute(p)))];
132
138
  }
133
139
  var MAX_POLICY_DENIED_PATHS = 1e3;
140
+ var PolicyPathsOverflow = class extends Error {
141
+ name = "PolicyPathsOverflow";
142
+ constructor(found) {
143
+ super(
144
+ `The team policy's denyPaths match ${found} paths in this project, more paths than the OS sandbox can protect (at most ${MAX_POLICY_DENIED_PATHS}). The command does not run. Narrow the patterns, or deny a folder instead of its files.`
145
+ );
146
+ }
147
+ };
134
148
  function policyDeniedPaths(root, patterns, ignoreCase = CASE_INSENSITIVE_FS) {
135
149
  if (patterns.length === 0) return [];
136
150
  const found = globbySync(patterns.map(fullGlob), {
@@ -140,10 +154,10 @@ function policyDeniedPaths(root, patterns, ignoreCase = CASE_INSENSITIVE_FS) {
140
154
  onlyFiles: false,
141
155
  followSymbolicLinks: false,
142
156
  gitignore: false,
143
- caseSensitiveMatch: !ignoreCase,
144
- ignore: ["**/node_modules/**", "**/.git/**"]
157
+ caseSensitiveMatch: !ignoreCase
145
158
  });
146
- return found.slice(0, MAX_POLICY_DENIED_PATHS);
159
+ if (found.length > MAX_POLICY_DENIED_PATHS) throw new PolicyPathsOverflow(found.length);
160
+ return found;
147
161
  }
148
162
 
149
163
  // src/sandbox/process.ts
@@ -372,13 +386,61 @@ var ProcessExecutor = class {
372
386
  daemons = new DaemonManager(this);
373
387
  /** Process group ids of running commands. */
374
388
  running = /* @__PURE__ */ new Set();
389
+ /** Markers of commands and processes that still run (0.17, IPI-04): shutdown() reaps them. */
390
+ trackers = /* @__PURE__ */ new Set();
391
+ /**
392
+ * Markers for one command, when this executor can find the processes that leave its process
393
+ * group (Seatbelt with the helper). Others need none: bubblewrap's PID namespace ends them all.
394
+ */
395
+ track(_policy) {
396
+ return void 0;
397
+ }
398
+ /** Remove what tracking keeps (the markers folder), at shutdown. */
399
+ closeTracking() {
400
+ }
375
401
  shutdown() {
376
402
  this.daemons.shutdown();
377
403
  for (const pid of this.running) signalGroup(pid, "SIGKILL");
378
404
  this.running.clear();
405
+ for (const tracker of this.trackers) {
406
+ try {
407
+ tracker.reapSync();
408
+ } catch {
409
+ }
410
+ }
411
+ this.trackers.clear();
412
+ this.closeTracking();
413
+ }
414
+ /** The launch, with markers; the markers go away when the launch fails. */
415
+ launchTracked(argv, policy) {
416
+ const tracker = this.track(policy);
417
+ try {
418
+ const launch = this.launch(argv, policy, tracker);
419
+ if (tracker === void 0) return launch;
420
+ this.trackers.add(tracker);
421
+ return { ...launch, tracker };
422
+ } catch (error) {
423
+ tracker?.dispose();
424
+ throw error;
425
+ }
426
+ }
427
+ /** Kill the tracked processes that still run (the command's own group is already gone). */
428
+ async reap(tracker) {
429
+ try {
430
+ const n = await tracker.reap();
431
+ return n > 0 ? { reaped: n } : {};
432
+ } catch (error) {
433
+ return { reapProblem: error.message };
434
+ } finally {
435
+ this.trackers.delete(tracker);
436
+ }
437
+ }
438
+ dropTracker(tracker) {
439
+ if (tracker === void 0 || !this.trackers.delete(tracker)) return;
440
+ tracker.dispose();
379
441
  }
380
442
  start(argv, policy, env = {}) {
381
- const { file, args } = this.launch(argv, policy);
443
+ const { file, args, tracker } = this.launchTracked(argv, policy);
382
444
  const child = spawn(file, args, {
383
445
  cwd: policy.root,
384
446
  env: { ...allowedEnv(policy.envAllowlist), ...proxyEnv(policy), ...env },
@@ -394,7 +456,9 @@ var ProcessExecutor = class {
394
456
  signalGroup(pid, "SIGKILL");
395
457
  }
396
458
  if (killTimer !== void 0) clearTimeout(killTimer);
459
+ if (tracker !== void 0) void this.reap(tracker);
397
460
  });
461
+ child.on("error", () => this.dropTracker(tracker));
398
462
  return {
399
463
  pid,
400
464
  stdin: child.stdin,
@@ -423,7 +487,7 @@ var ProcessExecutor = class {
423
487
  reject(options.signal.reason);
424
488
  return;
425
489
  }
426
- const { file, args } = this.launch(["bash", "-c", command], policy);
490
+ const { file, args, tracker } = this.launchTracked(["bash", "-c", command], policy);
427
491
  const child = spawn(file, args, {
428
492
  cwd: policy.root,
429
493
  env: { ...allowedEnv(policy.envAllowlist), ...proxyEnv(policy), ...options.env },
@@ -458,6 +522,7 @@ var ProcessExecutor = class {
458
522
  child.on("error", (error) => {
459
523
  if (finished) return;
460
524
  finished = true;
525
+ this.dropTracker(tracker);
461
526
  if (pid !== void 0) this.running.delete(pid);
462
527
  clearTimeout(timer);
463
528
  if (drainTimer !== void 0) clearTimeout(drainTimer);
@@ -473,7 +538,7 @@ var ProcessExecutor = class {
473
538
  if (killTimer !== void 0) clearTimeout(killTimer);
474
539
  options.signal?.removeEventListener("abort", onAbort);
475
540
  if (child.pid !== void 0) signalGroup(child.pid, "SIGKILL");
476
- resolve2({
541
+ const result = {
477
542
  exitCode: code,
478
543
  signal,
479
544
  stdout: stdout.result(),
@@ -481,7 +546,12 @@ var ProcessExecutor = class {
481
546
  timedOut,
482
547
  aborted,
483
548
  durationMs: Date.now() - started
484
- });
549
+ };
550
+ if (tracker === void 0) {
551
+ resolve2(result);
552
+ return;
553
+ }
554
+ void this.reap(tracker).then((reaped) => resolve2({ ...result, ...reaped }));
485
555
  };
486
556
  child.on("exit", (code, signal) => {
487
557
  drainTimer = setTimeout(() => {
@@ -541,9 +611,9 @@ var HostExecutor = class extends ProcessExecutor {
541
611
  };
542
612
 
543
613
  // src/sandbox/index.ts
544
- import { spawnSync } from "child_process";
545
- import { existsSync as existsSync2 } from "fs";
546
- import { delimiter, join as join2 } from "path";
614
+ import { spawnSync as spawnSync2 } from "child_process";
615
+ import { existsSync as existsSync3 } from "fs";
616
+ import { delimiter, join as join3 } from "path";
547
617
 
548
618
  // src/sandbox/bwrap.ts
549
619
  import { existsSync, statSync } from "fs";
@@ -564,6 +634,12 @@ function bwrapArgv(argv, policy, kind = pathKind) {
564
634
  const args = ["--ro-bind", "/", "/", "--dev", "/dev"];
565
635
  for (const path of policy.writePaths)
566
636
  if (kind(path) !== void 0) args.push("--bind", path, path);
637
+ for (const mount of policy.mounts ?? []) args.push("--bind", mount.source, mount.target);
638
+ if (policy.writeScope !== void 0) {
639
+ args.push("--ro-bind", policy.writeScope.root, policy.writeScope.root);
640
+ for (const path of policy.writeScope.paths)
641
+ if (kind(path) !== void 0) args.push("--bind", path, path);
642
+ }
567
643
  for (const path of policy.denyWritePaths) {
568
644
  if (kind(path) !== void 0) args.push("--ro-bind", path, path);
569
645
  }
@@ -600,24 +676,146 @@ function pathKind(path) {
600
676
  return statSync(path).isDirectory() ? "dir" : "file";
601
677
  }
602
678
 
679
+ // src/sandbox/reaper.ts
680
+ import { execFile, spawnSync } from "child_process";
681
+ import { randomBytes } from "crypto";
682
+ import { existsSync as existsSync2, mkdtempSync, realpathSync as realpathSync2, rmSync, writeFileSync } from "fs";
683
+ import { tmpdir as tmpdir2 } from "os";
684
+ import { dirname, join as join2 } from "path";
685
+ import { fileURLToPath } from "url";
686
+ var HELPER_TIMEOUT_MS = 1e4;
687
+ var ReapError = class extends Error {
688
+ name = "ReapError";
689
+ };
690
+ var Reaper = class {
691
+ constructor(helper, parent = tmpdir2()) {
692
+ this.helper = helper;
693
+ this.parent = parent;
694
+ }
695
+ helper;
696
+ parent;
697
+ folder;
698
+ /** The markers folder, made at the first command. Seatbelt matches real paths (/private/var). */
699
+ dir() {
700
+ this.folder ??= realpathSync2(mkdtempSync(join2(this.parent, "garuda-markers-")));
701
+ return this.folder;
702
+ }
703
+ /** Remove the markers folder (at shutdown). */
704
+ close() {
705
+ if (this.folder !== void 0) rmSync(this.folder, { recursive: true, force: true });
706
+ this.folder = void 0;
707
+ }
708
+ /** New markers for one command. */
709
+ track() {
710
+ const dir = this.dir();
711
+ const id = randomBytes(8).toString("hex");
712
+ const a = join2(dir, `${id}-a`);
713
+ const b = join2(dir, `${id}-b`);
714
+ writeFileSync(a, "", { mode: 384 });
715
+ writeFileSync(b, "", { mode: 384 });
716
+ const remove = () => {
717
+ rmSync(a, { force: true });
718
+ rmSync(b, { force: true });
719
+ };
720
+ const helper = this.helper;
721
+ return {
722
+ rules: [
723
+ `(deny file-read* (literal ${quote(a)}))`,
724
+ `(allow file-read* (literal ${quote(b)}))`,
725
+ `(deny file-write* (subpath ${quote(dir)}))`
726
+ ],
727
+ reap: () => new Promise((resolve2, reject) => {
728
+ execFile(
729
+ helper,
730
+ [a, b],
731
+ { timeout: HELPER_TIMEOUT_MS, encoding: "utf8" },
732
+ (error, stdout, stderr) => {
733
+ remove();
734
+ if (error) reject(new ReapError(why(error.message, stderr)));
735
+ else resolve2(killed(stdout));
736
+ }
737
+ );
738
+ }),
739
+ reapSync: () => {
740
+ const r = spawnSync(helper, [a, b], { timeout: HELPER_TIMEOUT_MS, encoding: "utf8" });
741
+ remove();
742
+ if (r.status !== 0) throw new ReapError(why(r.error?.message ?? "", r.stderr ?? ""));
743
+ return killed(r.stdout ?? "");
744
+ },
745
+ dispose: remove
746
+ };
747
+ }
748
+ };
749
+ function locateHelper() {
750
+ let dir;
751
+ try {
752
+ dir = dirname(fileURLToPath(import.meta.url));
753
+ } catch {
754
+ return void 0;
755
+ }
756
+ for (let i = 0; i < 5; i++) {
757
+ const candidate = join2(dir, "native", "bin", "garuda-reap");
758
+ if (existsSync2(candidate)) return candidate;
759
+ const up = dirname(dir);
760
+ if (up === dir) break;
761
+ dir = up;
762
+ }
763
+ return void 0;
764
+ }
765
+ function killed(stdout) {
766
+ const match = /killed (\d+)/.exec(stdout);
767
+ return match === null ? 0 : Number(match[1]);
768
+ }
769
+ function why(message, stderr) {
770
+ return `garuda-reap failed: ${(stderr.trim() || message).split("\n")[0]}`;
771
+ }
772
+ function quote(path) {
773
+ return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
774
+ }
775
+
603
776
  // src/sandbox/seatbelt.ts
604
777
  var SANDBOX_EXEC = "/usr/bin/sandbox-exec";
605
778
  var SeatbeltExecutor = class extends ProcessExecutor {
779
+ /**
780
+ * `reaper` (0.17, IPI-04): finds and kills the processes that leave a command's process group.
781
+ * Without it, such a process outlives the command (index.ts warns, or strict refuses).
782
+ */
783
+ constructor(reaper) {
784
+ super();
785
+ this.reaper = reaper;
786
+ }
787
+ reaper;
606
788
  name = "seatbelt";
607
789
  isolation = "os";
608
- launch(argv, policy) {
790
+ track(policy) {
791
+ return policy.sandbox && this.reaper !== void 0 ? this.reaper.track() : void 0;
792
+ }
793
+ closeTracking() {
794
+ this.reaper?.close();
795
+ }
796
+ launch(argv, policy, tracker) {
609
797
  if (!policy.sandbox) return plain(argv);
610
- return { file: SANDBOX_EXEC, args: ["-p", seatbeltProfile(policy), ...argv] };
798
+ if ((policy.mounts ?? []).length > 0) throw new Error("Seatbelt cannot mount folders.");
799
+ return {
800
+ file: SANDBOX_EXEC,
801
+ args: ["-p", seatbeltProfile(policy, tracker?.rules), ...argv]
802
+ };
611
803
  }
612
804
  };
613
- function seatbeltProfile(policy) {
614
- const paths = (list) => list.map((p) => `(subpath ${quote(p)})`).join(" ");
805
+ function seatbeltProfile(policy, extra = []) {
806
+ const paths = (list) => list.map((p) => `(subpath ${quote2(p)})`).join(" ");
615
807
  const lines = [
616
808
  "(version 1)",
617
809
  "(allow default)",
618
810
  "(deny file-write*)",
619
811
  `(allow file-write* ${paths(policy.writePaths)} (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/"))`
620
812
  ];
813
+ if (policy.writeScope !== void 0) {
814
+ lines.push(`(deny file-write* ${paths([policy.writeScope.root])})`);
815
+ if (policy.writeScope.paths.length > 0) {
816
+ lines.push(`(allow file-write* ${paths(policy.writeScope.paths)})`);
817
+ }
818
+ }
621
819
  if (policy.denyWritePaths.length > 0) {
622
820
  lines.push(`(deny file-write* ${paths(policy.denyWritePaths)})`);
623
821
  }
@@ -635,42 +833,75 @@ function seatbeltProfile(policy) {
635
833
  '(allow network-bind (local ip "localhost:*"))'
636
834
  );
637
835
  }
836
+ lines.push(...extra);
638
837
  return lines.join("\n");
639
838
  }
640
- function quote(path) {
839
+ function quote2(path) {
641
840
  return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
642
841
  }
643
842
 
644
843
  // src/sandbox/index.ts
645
844
  var EXECUTOR_NAMES = ["auto", "os", "host"];
646
- function createExecutor(name = "auto", find = findOsSandbox) {
845
+ var StrictProfileError = class extends Error {
846
+ };
847
+ function strictFix(platform) {
848
+ if (platform === "linux")
849
+ return "Install bubblewrap (for example: sudo apt install bubblewrap) and allow user namespaces, or remove the strict profile.";
850
+ return 'Run Garuda where the OS sandbox works, or remove the strict profile ("profile": "strict").';
851
+ }
852
+ function createExecutor(name = "auto", find = findOsSandbox, strict = false, platform = process.platform) {
853
+ if (strict && name === "host") {
854
+ throw new StrictProfileError(
855
+ 'The strict profile requires the OS sandbox, but "executor" is "host". Remove "executor": "host" from .garuda/settings.json, or remove the strict profile.'
856
+ );
857
+ }
647
858
  if (name === "host") return { executor: new HostExecutor() };
648
859
  const found = find();
649
- if ("executor" in found) return { executor: found.executor };
860
+ if ("executor" in found) {
861
+ if (found.warning === void 0) return { executor: found.executor };
862
+ if (strict) {
863
+ throw new StrictProfileError(
864
+ `The strict profile requires the macOS helper garuda-reap. ${found.warning}`
865
+ );
866
+ }
867
+ return { executor: found.executor, notice: found.warning };
868
+ }
869
+ if (strict) {
870
+ throw new StrictProfileError(
871
+ `The strict profile requires the OS sandbox, and this machine has none: ${found.problem} Garuda does not run commands on the host in this profile. ${strictFix(platform)}`
872
+ );
873
+ }
650
874
  if (name === "os") throw new Error(`No OS sandbox: ${found.problem} ${found.fix}`);
651
875
  return {
652
876
  executor: new HostExecutor(),
653
- notice: `No OS sandbox: ${found.problem} Commands run on your machine, and each one asks for approval. ${found.fix}`
877
+ notice: `No OS sandbox: ${found.problem} Commands run on your machine, and each one asks for approval, unless an allow rule covers it. ${found.fix}`
654
878
  };
655
879
  }
880
+ var HELPER_MISSING = "The macOS helper garuda-reap is missing, so a process that leaves a command's process group (for example with setsid) can keep running after the command, inside the sandbox. Reinstall Garuda; in a source checkout, run `pnpm build` (it needs the Command Line Tools).";
656
881
  var cached;
657
882
  function findOsSandbox() {
658
883
  cached ??= detect(process.platform);
659
- return "make" in cached ? { executor: cached.make() } : cached;
884
+ if (!("make" in cached)) return cached;
885
+ return cached.warning === void 0 ? { executor: cached.make() } : { executor: cached.make(), warning: cached.warning };
660
886
  }
661
887
  function detect(platform) {
662
888
  const fix = 'Set "executor": "host" in .garuda/settings.json to hide this notice.';
663
889
  if (platform === "darwin") {
664
- if (!existsSync2(SANDBOX_EXEC)) {
890
+ if (!existsSync3(SANDBOX_EXEC)) {
665
891
  return { problem: `${SANDBOX_EXEC} is missing.`, fix };
666
892
  }
667
- const probe = spawnSync(SANDBOX_EXEC, ["-p", "(version 1)(allow default)", "--", "true"], {
893
+ const probe = spawnSync2(SANDBOX_EXEC, ["-p", "(version 1)(allow default)", "--", "true"], {
668
894
  encoding: "utf8",
669
895
  timeout: 5e3
670
896
  });
671
- if (probe.status === 0) return { make: () => new SeatbeltExecutor() };
672
- const why = (probe.stderr || probe.error?.message || "unknown error").trim().split("\n")[0];
673
- return { problem: `sandbox-exec cannot start a sandbox here (${why}).`, fix };
897
+ if (probe.status === 0) {
898
+ const helper = locateHelper();
899
+ if (helper === void 0)
900
+ return { make: () => new SeatbeltExecutor(), warning: HELPER_MISSING };
901
+ return { make: () => new SeatbeltExecutor(new Reaper(helper)) };
902
+ }
903
+ const why2 = (probe.stderr || probe.error?.message || "unknown error").trim().split("\n")[0];
904
+ return { problem: `sandbox-exec cannot start a sandbox here (${why2}).`, fix };
674
905
  }
675
906
  if (platform === "linux") {
676
907
  const bwrap = onPath("bwrap");
@@ -680,33 +911,34 @@ function detect(platform) {
680
911
  fix: 'Install it (for example: sudo apt install bubblewrap), or set "executor": "host" in .garuda/settings.json.'
681
912
  };
682
913
  }
683
- const probe = spawnSync(
914
+ const probe = spawnSync2(
684
915
  bwrap,
685
916
  ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-net", "--die-with-parent", "--", "true"],
686
917
  { encoding: "utf8", timeout: 5e3 }
687
918
  );
688
919
  if (probe.status === 0) return { make: () => new BwrapExecutor(bwrap) };
689
- const why = (probe.stderr || probe.error?.message || "unknown error").trim().split("\n")[0];
690
- return { problem: `bubblewrap cannot start a sandbox here (${why}).`, fix };
920
+ const why2 = (probe.stderr || probe.error?.message || "unknown error").trim().split("\n")[0];
921
+ return { problem: `bubblewrap cannot start a sandbox here (${why2}).`, fix };
691
922
  }
692
923
  return { problem: `Garuda has no OS sandbox for ${platform}.`, fix };
693
924
  }
694
925
  function onPath(program) {
695
926
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
696
- const path = join2(dir, program);
697
- if (dir !== "" && existsSync2(path)) return path;
927
+ const path = join3(dir, program);
928
+ if (dir !== "" && existsSync3(path)) return path;
698
929
  }
699
930
  return void 0;
700
931
  }
701
932
 
702
933
  // src/permissions/settings.ts
703
934
  import { readFile } from "fs/promises";
704
- import { join as join3 } from "path";
935
+ import { join as join4 } from "path";
705
936
  import { z } from "zod";
706
- var SETTINGS_FILE = join3(".garuda", "settings.json");
937
+ var SETTINGS_FILE = join4(".garuda", "settings.json");
707
938
  var NOTIFY_CHOICES = ["auto", "osc9", "bell", "off"];
708
939
  var schema = z.strictObject({
709
940
  executor: z.enum(EXECUTOR_NAMES).optional(),
941
+ profile: z.enum(PROFILES).optional(),
710
942
  sandbox: z.strictObject({
711
943
  writePaths: z.array(z.string()).optional(),
712
944
  denyRead: z.array(z.string()).optional()
@@ -782,6 +1014,11 @@ var schema = z.strictObject({
782
1014
  * (merge gate): no measured gain yet, and they add tools and long-lived processes.
783
1015
  */
784
1016
  daemons: z.strictObject({ enabled: z.boolean().optional() }).optional(),
1017
+ /**
1018
+ * Staged commands (0.17, review T5): "staged" runs each sandboxed command in a staging copy and
1019
+ * asks before its changes reach the project. Default "direct".
1020
+ */
1021
+ commands: z.strictObject({ mode: z.enum(["direct", "staged"]).optional() }).optional(),
785
1022
  /** Mixture-of-Experts language specialist subagent dispatch (0.17). */
786
1023
  moe: z.strictObject({
787
1024
  enabled: z.boolean().optional(),
@@ -810,6 +1047,7 @@ function parseSettings(json, source = SETTINGS_FILE) {
810
1047
  if (!parsed.success) throw new Error(`${source}: ${z.prettifyError(parsed.error)}`);
811
1048
  const {
812
1049
  executor = "auto",
1050
+ profile,
813
1051
  sandbox,
814
1052
  web,
815
1053
  permissions = {},
@@ -820,6 +1058,7 @@ function parseSettings(json, source = SETTINGS_FILE) {
820
1058
  subagents,
821
1059
  moe,
822
1060
  daemons,
1061
+ commands,
823
1062
  todo,
824
1063
  lsp,
825
1064
  undo,
@@ -843,6 +1082,7 @@ function parseSettings(json, source = SETTINGS_FILE) {
843
1082
  });
844
1083
  return {
845
1084
  executor,
1085
+ ...profile === void 0 ? {} : { profile },
846
1086
  allow: rules(permissions.allow),
847
1087
  deny: rules(permissions.deny),
848
1088
  envAllow: env.allow ?? [],
@@ -859,6 +1099,7 @@ function parseSettings(json, source = SETTINGS_FILE) {
859
1099
  ...limits.tokenBudget === void 0 ? {} : { tokenBudget: limits.tokenBudget },
860
1100
  ...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
861
1101
  ...model.price === void 0 ? {} : { price: model.price },
1102
+ ...commands?.mode === void 0 ? {} : { commands: { mode: commands.mode } },
862
1103
  ...todo?.enabled === void 0 ? {} : { todo: { enabled: todo.enabled } },
863
1104
  ...lsp?.enabled === void 0 ? {} : { lsp: { enabled: lsp.enabled } },
864
1105
  ...undo?.enabled === void 0 ? {} : { undo: { enabled: undo.enabled } },
@@ -904,7 +1145,7 @@ function parseSettings(json, source = SETTINGS_FILE) {
904
1145
  async function loadSettings(root) {
905
1146
  let text;
906
1147
  try {
907
- text = await readFile(join3(root, SETTINGS_FILE), "utf8");
1148
+ text = await readFile(join4(root, SETTINGS_FILE), "utf8");
908
1149
  } catch (error) {
909
1150
  if (error.code === "ENOENT") return DEFAULT_SETTINGS;
910
1151
  throw error;
@@ -1119,6 +1360,31 @@ var PermissionEngine = class {
1119
1360
  return { decision: this.planDecision(tool, target, allowRule !== void 0) };
1120
1361
  }
1121
1362
  if (request.readOnly) return { decision: { allowed: true, by: "read_only" } };
1363
+ if (target?.kind === "command" && target.outsideSandbox === true && (this.unattended !== void 0 || this.settings.writeScope !== void 0)) {
1364
+ this.unattended?.onDeny?.(tool, target);
1365
+ return {
1366
+ decision: {
1367
+ allowed: false,
1368
+ by: "rule",
1369
+ reason: "A scheduled job runs commands only in the OS sandbox: outside_sandbox is not allowed. Do not retry it; list it in your final answer as work left for the user."
1370
+ }
1371
+ };
1372
+ }
1373
+ const scope = this.settings.writeScope;
1374
+ const ruled = allowRule !== void 0 && !(target?.kind === "url" && target.alwaysAsk === true) || this.sessionRules.some((r) => ruleMatches(r, tool, target, "allow"));
1375
+ if (scope !== void 0 && target?.kind === "path" && (ruled || this.unattended === void 0)) {
1376
+ const outside = await scopeDenial(this.root, target.path, scope);
1377
+ if (outside !== void 0) {
1378
+ this.unattended?.onDeny?.(tool, target);
1379
+ return {
1380
+ decision: {
1381
+ allowed: false,
1382
+ by: "rule",
1383
+ reason: `${outside}. Do not retry it; list it in your final answer as work left for the user.`
1384
+ }
1385
+ };
1386
+ }
1387
+ }
1122
1388
  if (target?.kind === "command" && !target.outsideSandbox && this.isolation !== "none") {
1123
1389
  return { decision: { allowed: true, by: "sandbox" } };
1124
1390
  }
@@ -1252,17 +1518,25 @@ var PermissionEngine = class {
1252
1518
  const denied = policyDeniedPaths(this.root, this.policy?.denyPaths ?? []);
1253
1519
  let writePaths = [.../* @__PURE__ */ new Set([...paths.writePaths, ...this.access.writePaths])];
1254
1520
  let denyWritePaths = [...paths.denyWritePaths, ...denied];
1521
+ const inRoot = (p) => p === this.root || p.startsWith(`${this.root}${sep}`);
1255
1522
  if (readOnlyRoot) {
1256
- const inRoot = (p) => p === this.root || p.startsWith(`${this.root}${sep}`);
1257
1523
  writePaths = writePaths.filter((p) => !inRoot(p));
1258
1524
  denyWritePaths = [this.root, ...denyWritePaths];
1259
1525
  }
1526
+ const scope = !readOnlyRoot && sandbox && this.settings.writeScope !== void 0 ? { root: this.root, paths: scopePaths(this.root, this.settings.writeScope) } : void 0;
1527
+ if (scope !== void 0) {
1528
+ writePaths = writePaths.filter((p) => !inRoot(p));
1529
+ const problem = prepareWriteScope(this.root, this.settings.writeScope ?? []);
1530
+ if (problem !== void 0)
1531
+ throw new Error(`The job's write scope cannot be used: ${problem}.`);
1532
+ }
1260
1533
  return {
1261
1534
  root: this.root,
1262
1535
  sandbox,
1263
1536
  ...paths,
1264
1537
  denyReadPaths: [...paths.denyReadPaths, ...denied],
1265
1538
  writePaths,
1539
+ ...scope === void 0 ? {} : { writeScope: scope },
1266
1540
  denyWritePaths,
1267
1541
  network: !sandbox,
1268
1542
  ...sandbox && this.network !== void 0 ? { proxy: this.network.policy } : {},
@@ -1273,6 +1547,15 @@ var PermissionEngine = class {
1273
1547
  maxOutputBytes: DEFAULT_MAX_OUTPUT_BYTES
1274
1548
  };
1275
1549
  }
1550
+ /**
1551
+ * Why a write to this path is outside the job's write scope, or undefined (no scope, or inside
1552
+ * it). Write tools call it again just before they write (second review of 0.17): a link can
1553
+ * change between the permission check and the write.
1554
+ */
1555
+ async writeScopeDenial(path) {
1556
+ const scope = this.settings.writeScope;
1557
+ return scope === void 0 ? void 0 : scopeDenial(this.root, path, scope);
1558
+ }
1276
1559
  /** The network allowlist (0.13): the runtime sets it when the proxy runs. */
1277
1560
  setNetwork(network) {
1278
1561
  this.network = network;
@@ -1319,4 +1602,4 @@ export {
1319
1602
  JOB_DENIAL,
1320
1603
  PermissionEngine
1321
1604
  };
1322
- //# sourceMappingURL=chunk-GXZACJUI.js.map
1605
+ //# sourceMappingURL=chunk-B4SJY7KT.js.map
@@ -8,6 +8,9 @@ var ZERO_USAGE = {
8
8
  cacheReadTokens: 0,
9
9
  cacheWriteTokens: 0
10
10
  };
11
+ function reportedUsage(response) {
12
+ return response.usageReported === false ? ZERO_USAGE : response.usage;
13
+ }
11
14
  function addUsage(a, b) {
12
15
  const searches = (a.webSearches ?? 0) + (b.webSearches ?? 0);
13
16
  return {
@@ -22,6 +25,7 @@ function addUsage(a, b) {
22
25
  export {
23
26
  EFFORTS,
24
27
  ZERO_USAGE,
28
+ reportedUsage,
25
29
  addUsage
26
30
  };
27
- //# sourceMappingURL=chunk-3EQU2GUX.js.map
31
+ //# sourceMappingURL=chunk-BHZ6G65V.js.map
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/version.ts
4
+ var VERSION = "0.18.0";
5
+
6
+ export {
7
+ VERSION
8
+ };
9
+ //# sourceMappingURL=chunk-C57GHLKP.js.map