@gethmy/harness 1.4.0 → 1.6.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.
package/dist/cli.js CHANGED
@@ -501,7 +501,6 @@ var init_stageHandoff = __esm(() => {
501
501
 
502
502
  // ../harmony-shared/dist/types.js
503
503
  var init_types = () => {};
504
-
505
504
  // ../harmony-shared/dist/index.js
506
505
  var init_dist = __esm(() => {
507
506
  init_agentStaleness();
@@ -611,6 +610,492 @@ var init_log = __esm(() => {
611
610
  };
612
611
  });
613
612
 
613
+ // src/confine-to-repo.ts
614
+ import { realpathSync } from "node:fs";
615
+ import { dirname as dirname2, isAbsolute as isAbsolute2, parse, resolve as resolve2, sep as sep2 } from "node:path";
616
+ function isGitMetadata(repoRoot, candidate) {
617
+ const rel = candidate.startsWith(repoRoot) ? candidate.slice(repoRoot.length) : candidate;
618
+ return rel.split(/[\\/]/).some((segment) => segment.toLowerCase() === ".git");
619
+ }
620
+ function patternEscapes(pattern) {
621
+ if (isAbsolute2(pattern))
622
+ return true;
623
+ if (/[{}[\]~()!+@]/.test(pattern))
624
+ return true;
625
+ return pattern.split(/[\\/]/).some((segment) => segment === "..");
626
+ }
627
+ function pathArgsFor(mode) {
628
+ return mode === "write" ? { ...READ_PATH_ARGS, ...WRITE_PATH_ARGS } : READ_PATH_ARGS;
629
+ }
630
+ function realPathOrNearest(p) {
631
+ const abs = isAbsolute2(p) ? p : resolve2(p);
632
+ const { root } = parse(abs);
633
+ let real = root;
634
+ for (const part of abs.slice(root.length).split(sep2)) {
635
+ if (part === "" || part === ".")
636
+ continue;
637
+ if (part === "..") {
638
+ real = dirname2(real);
639
+ continue;
640
+ }
641
+ const next = real.endsWith(sep2) ? real + part : real + sep2 + part;
642
+ try {
643
+ real = realpathSync(next);
644
+ } catch {
645
+ real = next;
646
+ }
647
+ }
648
+ return real;
649
+ }
650
+ function isInsideTree(root, target) {
651
+ const normalizedRoot = realPathOrNearest(root);
652
+ const normalizedTarget = realPathOrNearest(target);
653
+ if (normalizedTarget === normalizedRoot)
654
+ return true;
655
+ return normalizedTarget.startsWith(normalizedRoot + sep2);
656
+ }
657
+ function decideConfinedTool(repoRoot, toolName, input, mode = "read") {
658
+ const pathArgs = pathArgsFor(mode)[toolName];
659
+ if (!pathArgs) {
660
+ return {
661
+ behavior: "deny",
662
+ message: `${toolName} is not available to this run.`
663
+ };
664
+ }
665
+ const verb = toolName in WRITE_PATH_ARGS ? "write" : "read";
666
+ for (const key of PATTERN_ARG_BY_TOOL[toolName] ?? []) {
667
+ const value = input[key];
668
+ if (typeof value !== "string" || value.length === 0)
669
+ continue;
670
+ if (patternEscapes(value)) {
671
+ return {
672
+ behavior: "deny",
673
+ message: `${toolName} patterns must stay inside the repository — no absolute path and no "..". Refused: ${value}`
674
+ };
675
+ }
676
+ }
677
+ for (const key of pathArgs) {
678
+ const value = input[key];
679
+ if (typeof value !== "string" || value.length === 0)
680
+ continue;
681
+ const candidate = isAbsolute2(value) ? value : `${repoRoot}${sep2}${value}`;
682
+ if (isGitMetadata(repoRoot, candidate)) {
683
+ return {
684
+ behavior: "deny",
685
+ message: `${toolName} may not touch the repository's git metadata. Refused: ${value}`
686
+ };
687
+ }
688
+ if (!isInsideTree(repoRoot, candidate)) {
689
+ return {
690
+ behavior: "deny",
691
+ message: `${toolName} may only ${verb} inside the repository. Refused: ${value}`
692
+ };
693
+ }
694
+ }
695
+ return { behavior: "allow" };
696
+ }
697
+ function confineToRepo(repoRoot, mode = "read") {
698
+ return async (toolName, input) => decideConfinedTool(repoRoot, toolName, input, mode);
699
+ }
700
+ var READ_PATH_ARGS, WRITE_PATH_ARGS, CONFINED_READ_TOOLS, CONFINED_WRITE_TOOLS, PATTERN_ARG_BY_TOOL;
701
+ var init_confine_to_repo = __esm(() => {
702
+ READ_PATH_ARGS = {
703
+ Read: ["file_path", "path", "notebook_path"],
704
+ Grep: ["path"],
705
+ Glob: ["path"]
706
+ };
707
+ WRITE_PATH_ARGS = {
708
+ Write: ["file_path"],
709
+ Edit: ["file_path"],
710
+ MultiEdit: ["file_path"],
711
+ NotebookEdit: ["notebook_path"]
712
+ };
713
+ CONFINED_READ_TOOLS = Object.freeze(Object.keys(READ_PATH_ARGS));
714
+ CONFINED_WRITE_TOOLS = Object.freeze([
715
+ ...Object.keys(READ_PATH_ARGS),
716
+ ...Object.keys(WRITE_PATH_ARGS).filter((t) => t !== "MultiEdit")
717
+ ]);
718
+ PATTERN_ARG_BY_TOOL = {
719
+ Glob: ["pattern"],
720
+ Grep: ["glob"]
721
+ };
722
+ });
723
+
724
+ // src/runner.ts
725
+ import { getConfigDir } from "@gethmy/mcp/src/config.js";
726
+ function mayHoldCredentials(role) {
727
+ return role === "author" || role === "reviewer";
728
+ }
729
+ function credentialReadDeny() {
730
+ return `Read(/${getConfigDir()}/**)`;
731
+ }
732
+ function credentialAccessDeny() {
733
+ const dir = `/${getConfigDir()}/**`;
734
+ return [`Read(${dir})`, `Grep(${dir})`, `Glob(${dir})`];
735
+ }
736
+ function buildRoleLaunch(args) {
737
+ const role = normalizeStageRole(args.role);
738
+ const keep = mayHoldCredentials(role);
739
+ const env = {};
740
+ for (const [key, value] of Object.entries(args.parentEnv)) {
741
+ if (value === undefined)
742
+ continue;
743
+ if (!keep && HARMONY_CREDENTIAL_KEYS.includes(key))
744
+ continue;
745
+ env[key] = value;
746
+ }
747
+ return {
748
+ role,
749
+ prompt: args.prompt,
750
+ repoPath: args.repoPath,
751
+ env,
752
+ disallowedTools: keep ? [] : [credentialReadDeny()]
753
+ };
754
+ }
755
+ function envKeysDroppedByLaunch(parentEnv, launch) {
756
+ return Object.keys(parentEnv).filter((key) => parentEnv[key] !== undefined && !Object.hasOwn(launch.env, key));
757
+ }
758
+ var HARMONY_CREDENTIAL_KEYS;
759
+ var init_runner = __esm(() => {
760
+ init_dist();
761
+ HARMONY_CREDENTIAL_KEYS = [
762
+ "HARMONY_API_KEY",
763
+ "HARMONY_API_URL",
764
+ "HARMONY_WORKSPACE_ID",
765
+ "SUPABASE_ANON_KEY",
766
+ "SUPABASE_SERVICE_ROLE_KEY",
767
+ "SUPABASE_URL"
768
+ ];
769
+ });
770
+
771
+ // src/run-containment.ts
772
+ import { createHash as createHash2 } from "node:crypto";
773
+ import { readFileSync as readFileSync2 } from "node:fs";
774
+ import { createRequire as createRequire2 } from "node:module";
775
+ import { homedir, tmpdir as tmpdir2 } from "node:os";
776
+ import { dirname as dirname3, isAbsolute as isAbsolute3, join as join2 } from "node:path";
777
+ import { getConfigDir as getConfigDir2 } from "@gethmy/mcp/src/config.js";
778
+ function credentialDirectories() {
779
+ const home = homedir();
780
+ return [
781
+ getConfigDir2(),
782
+ join2(home, ".claude"),
783
+ join2(home, ".claude.json"),
784
+ join2(home, ".ssh"),
785
+ join2(home, ".gnupg"),
786
+ join2(home, ".aws"),
787
+ join2(home, ".codex"),
788
+ join2(home, ".gemini"),
789
+ join2(home, ".config", "gh"),
790
+ join2(home, ".config", "gcloud"),
791
+ join2(home, ".config", "anthropic"),
792
+ join2(home, ".config", "op"),
793
+ join2(home, ".docker"),
794
+ join2(home, ".kube"),
795
+ join2(home, ".netrc"),
796
+ join2(home, ".npmrc"),
797
+ join2(home, ".git-credentials")
798
+ ];
799
+ }
800
+ function writeOnlyDenyPaths() {
801
+ const paths = [join2(homedir(), ".gitconfig")];
802
+ const xdg = process.env.XDG_CONFIG_HOME;
803
+ paths.push(xdg && isAbsolute3(xdg) ? join2(xdg, "git") : join2(homedir(), ".config", "git"));
804
+ return paths;
805
+ }
806
+ function credentialToolDeny() {
807
+ return credentialDirectories().flatMap((dir) => [
808
+ `Read(/${dir})`,
809
+ `Read(/${dir}/**)`
810
+ ]);
811
+ }
812
+ function toolchainCacheDirectories(worktree) {
813
+ const home = homedir();
814
+ const scratch = `harmony-run-${createHash2("sha256").update(worktree).digest("hex").slice(0, 16)}`;
815
+ const candidate = process.env.TMPDIR ?? tmpdir2();
816
+ const tmpRoot = isAbsolute3(candidate) ? candidate : "/tmp";
817
+ return [
818
+ join2(home, ".bun", "install", "cache"),
819
+ join2(home, ".npm", "_cacache"),
820
+ join2(tmpRoot, scratch)
821
+ ];
822
+ }
823
+ function secretEnvKeysToStrip(parentEnv = process.env) {
824
+ const stripped = new Set(HARMONY_CREDENTIAL_KEYS);
825
+ for (const key of Object.keys(parentEnv)) {
826
+ if (KEEP_ENV_KEYS.has(key))
827
+ continue;
828
+ if (SECRET_ENV_PATTERN.test(key))
829
+ stripped.add(key);
830
+ }
831
+ return [...stripped];
832
+ }
833
+ function assertNoProjectSandboxOverride(worktree) {
834
+ for (const name of ["settings.json", "settings.local.json"]) {
835
+ const path = join2(worktree, ".claude", name);
836
+ let raw;
837
+ try {
838
+ raw = readFileSync2(path, "utf-8");
839
+ } catch {
840
+ continue;
841
+ }
842
+ let parsed;
843
+ try {
844
+ parsed = JSON.parse(raw);
845
+ } catch {
846
+ continue;
847
+ }
848
+ if (parsed === null || typeof parsed !== "object")
849
+ continue;
850
+ const offending = Object.keys(parsed).filter((key) => !INERT_PROJECT_SETTING_KEYS.has(key));
851
+ if (offending.length > 0) {
852
+ throw new ProjectSandboxOverrideError(path, offending);
853
+ }
854
+ }
855
+ }
856
+ function containedEnv(parentEnv = process.env) {
857
+ const strip = new Set(secretEnvKeysToStrip(parentEnv));
858
+ const out = {};
859
+ for (const [key, value] of Object.entries(parentEnv)) {
860
+ if (value === undefined || strip.has(key))
861
+ continue;
862
+ out[key] = value;
863
+ }
864
+ return out;
865
+ }
866
+ function gitMetadataDenyPaths(worktree) {
867
+ const paths = new Set;
868
+ const dotGit = join2(worktree, ".git");
869
+ const require2 = createRequire2(import.meta.url);
870
+ const { execFileSync } = require2("node:child_process");
871
+ const { statSync: statSync2 } = require2("node:fs");
872
+ const gitDirs = new Set;
873
+ try {
874
+ const out = execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "--git-dir", "--git-common-dir"], { cwd: worktree, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
875
+ for (const line of out.split(`
876
+ `)) {
877
+ const trimmed = line.trim();
878
+ if (!trimmed)
879
+ continue;
880
+ gitDirs.add(isAbsolute3(trimmed) ? trimmed : join2(worktree, trimmed));
881
+ }
882
+ } catch {}
883
+ gitDirs.add(dotGit);
884
+ for (const dir of gitDirs) {
885
+ paths.add(join2(dir, "config"));
886
+ paths.add(join2(dir, "config.worktree"));
887
+ paths.add(join2(dir, "hooks"));
888
+ }
889
+ try {
890
+ if (statSync2(dotGit).isFile())
891
+ paths.add(dotGit);
892
+ } catch {}
893
+ return [...paths];
894
+ }
895
+ function hostPersistencePaths() {
896
+ const home = homedir();
897
+ return [
898
+ join2(home, ".zshenv"),
899
+ join2(home, ".zprofile"),
900
+ join2(home, ".zshrc"),
901
+ join2(home, ".zlogin"),
902
+ join2(home, ".bashrc"),
903
+ join2(home, ".bash_profile"),
904
+ join2(home, ".bash_login"),
905
+ join2(home, ".profile"),
906
+ join2(home, ".config", "fish", "config.fish"),
907
+ join2(home, ".config", "fish", "conf.d"),
908
+ join2(home, "Library", "LaunchAgents"),
909
+ join2(home, ".config", "systemd", "user"),
910
+ join2(home, ".config", "autostart")
911
+ ];
912
+ }
913
+ function credentialWriteToolDeny(worktree) {
914
+ return [
915
+ ...credentialDirectories(),
916
+ ...writeOnlyDenyPaths(),
917
+ ...hostPersistencePaths(),
918
+ ...gitMetadataDenyPaths(worktree)
919
+ ].flatMap((p) => [`Edit(/${p})`, `Edit(/${p}/**)`]);
920
+ }
921
+ function implementRunToolPolicy(args) {
922
+ const writable = [args.worktree, ...toolchainCacheDirectories(args.worktree)];
923
+ const gitMeta = gitMetadataDenyPaths(args.worktree);
924
+ const secrets = credentialDirectories();
925
+ return async (toolName, input) => {
926
+ const allow = { behavior: "allow", updatedInput: input };
927
+ const deny = (message) => ({ behavior: "deny", message });
928
+ if (toolName.startsWith("mcp__"))
929
+ return allow;
930
+ if (toolName === "Bash" || toolName === "BashOutput") {
931
+ return allow;
932
+ }
933
+ const writePaths = WRITE_TOOL_PATHS[toolName];
934
+ if (writePaths) {
935
+ if (args.readOnly === true) {
936
+ return deny(`${toolName} is not available to a review run.`);
937
+ }
938
+ for (const key of writePaths) {
939
+ const value = input[key];
940
+ if (typeof value !== "string" || value.length === 0)
941
+ continue;
942
+ const target = isAbsolute3(value) ? value : join2(args.worktree, value);
943
+ if (gitMeta.some((p) => isInsideTree(p, target) || p === target)) {
944
+ return deny(`${toolName} may not touch git metadata. Refused: ${value}`);
945
+ }
946
+ if (!writable.some((root) => isInsideTree(root, target))) {
947
+ return deny(`${toolName} may only write inside this run's worktree. Refused: ${value}`);
948
+ }
949
+ }
950
+ return allow;
951
+ }
952
+ const readPaths = READ_TOOL_PATHS[toolName];
953
+ if (readPaths) {
954
+ for (const key of readPaths) {
955
+ const value = input[key];
956
+ if (typeof value !== "string" || value.length === 0)
957
+ continue;
958
+ const target = isAbsolute3(value) ? value : join2(args.worktree, value);
959
+ if (secrets.some((dir) => isInsideTree(dir, target))) {
960
+ return deny(`${toolName} may not read the credential directories. Refused: ${value}`);
961
+ }
962
+ }
963
+ return allow;
964
+ }
965
+ return allow;
966
+ };
967
+ }
968
+ function harmonyMcpServer() {
969
+ const require2 = createRequire2(import.meta.url);
970
+ const cli = join2(dirname3(require2.resolve("@gethmy/mcp")), "cli.js");
971
+ return {
972
+ harmony: {
973
+ type: "stdio",
974
+ command: process.execPath,
975
+ args: [cli, "serve"]
976
+ }
977
+ };
978
+ }
979
+ function implementRunContainment(args) {
980
+ assertNoProjectSandboxOverride(args.worktree);
981
+ return {
982
+ sandbox: {
983
+ enabled: true,
984
+ failIfUnavailable: true,
985
+ allowUnsandboxedCommands: false,
986
+ autoAllowBashIfSandboxed: args.readOnly !== true,
987
+ network: {
988
+ allowedDomains: [...IMPLEMENT_ALLOWED_DOMAINS],
989
+ allowLocalBinding: false
990
+ },
991
+ filesystem: {
992
+ allowWrite: [
993
+ args.worktree,
994
+ ...toolchainCacheDirectories(args.worktree)
995
+ ],
996
+ denyRead: credentialDirectories(),
997
+ denyWrite: [
998
+ ...credentialDirectories(),
999
+ ...writeOnlyDenyPaths(),
1000
+ ...hostPersistencePaths(),
1001
+ ...gitMetadataDenyPaths(args.worktree)
1002
+ ]
1003
+ }
1004
+ },
1005
+ canUseTool: implementRunToolPolicy(args),
1006
+ gateEveryToolCall: true,
1007
+ settingSources: args.readOnly === true ? [] : ["project"],
1008
+ mcpServers: harmonyMcpServer(),
1009
+ strictMcpConfig: true,
1010
+ stripEnvKeys: secretEnvKeysToStrip(),
1011
+ disallowedTools: [
1012
+ ...args.extraDisallowedTools ?? [],
1013
+ ...credentialToolDeny(),
1014
+ ...credentialWriteToolDeny(args.worktree)
1015
+ ]
1016
+ };
1017
+ }
1018
+ function implementRunContainmentCliArgs(args) {
1019
+ const containment = implementRunContainment(args);
1020
+ return [
1021
+ "--settings",
1022
+ JSON.stringify({ sandbox: containment.sandbox }),
1023
+ "--setting-sources",
1024
+ containment.settingSources.join(","),
1025
+ "--mcp-config",
1026
+ JSON.stringify({ mcpServers: containment.mcpServers }),
1027
+ "--strict-mcp-config",
1028
+ "--disallowedTools",
1029
+ containment.disallowedTools.join(",")
1030
+ ];
1031
+ }
1032
+ var SECRET_ENV_PATTERN, KEEP_ENV_KEYS, ProjectSandboxOverrideError, INERT_PROJECT_SETTING_KEYS, WRITE_TOOL_PATHS, READ_TOOL_PATHS, GIT_NO_HOOKS, IMPLEMENT_ALLOWED_DOMAINS;
1033
+ var init_run_containment = __esm(() => {
1034
+ init_confine_to_repo();
1035
+ init_runner();
1036
+ SECRET_ENV_PATTERN = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|_PAT$|^PAT_|PRIVATE_KEY|ACCESS_KEY)/i;
1037
+ KEEP_ENV_KEYS = new Set([
1038
+ "ANTHROPIC_API_KEY",
1039
+ "ANTHROPIC_AUTH_TOKEN",
1040
+ "ANTHROPIC_BASE_URL",
1041
+ "CLAUDE_CODE_OAUTH_TOKEN",
1042
+ "SSH_AUTH_SOCK",
1043
+ "GIT_AUTHOR_NAME",
1044
+ "GIT_AUTHOR_EMAIL",
1045
+ "GIT_COMMITTER_NAME",
1046
+ "GIT_COMMITTER_EMAIL",
1047
+ "XAUTHORITY"
1048
+ ]);
1049
+ ProjectSandboxOverrideError = class ProjectSandboxOverrideError extends Error {
1050
+ settingsPath;
1051
+ offendingKeys;
1052
+ constructor(settingsPath, offendingKeys) {
1053
+ super(`Refusing to spawn: ${settingsPath} sets ${offendingKeys.map((k) => `"${k}"`).join(", ")}. ` + "Project settings are loaded so the run can read CLAUDE.md, and that same layer " + "can widen the sandbox (`sandbox.filesystem.allowWrite`) or execute commands " + "outside it (`hooks`, `env`). A run that can write this file could therefore " + "widen its own bounds, so only keys known not to affect execution are accepted. " + "Remove the key, or the run stays refused.");
1054
+ this.settingsPath = settingsPath;
1055
+ this.offendingKeys = offendingKeys;
1056
+ this.name = "ProjectSandboxOverrideError";
1057
+ }
1058
+ };
1059
+ INERT_PROJECT_SETTING_KEYS = new Set([
1060
+ "$schema",
1061
+ "cleanupPeriodDays",
1062
+ "includeCoAuthoredBy",
1063
+ "language",
1064
+ "outputStyle",
1065
+ "spinnerTipsEnabled",
1066
+ "theme",
1067
+ "verbose"
1068
+ ]);
1069
+ WRITE_TOOL_PATHS = {
1070
+ Write: ["file_path"],
1071
+ Edit: ["file_path"],
1072
+ MultiEdit: ["file_path"],
1073
+ NotebookEdit: ["notebook_path"]
1074
+ };
1075
+ READ_TOOL_PATHS = {
1076
+ Read: ["file_path", "path", "notebook_path"],
1077
+ Grep: ["path"],
1078
+ Glob: ["path"]
1079
+ };
1080
+ GIT_NO_HOOKS = [
1081
+ "-c",
1082
+ "core.hooksPath=",
1083
+ "-c",
1084
+ "core.fsmonitor=",
1085
+ "-c",
1086
+ "core.pager=cat"
1087
+ ];
1088
+ IMPLEMENT_ALLOWED_DOMAINS = [
1089
+ "api.anthropic.com",
1090
+ "*.anthropic.com",
1091
+ "registry.npmjs.org",
1092
+ "*.npmjs.org",
1093
+ "github.com",
1094
+ "*.github.com",
1095
+ "*.githubusercontent.com"
1096
+ ];
1097
+ });
1098
+
614
1099
  // src/cli.ts
615
1100
  init_dist();
616
1101
 
@@ -896,6 +1381,7 @@ class SdkAgentRunner {
896
1381
  ...this.cfg.settingSources ? { settingSources: this.cfg.settingSources } : {},
897
1382
  ...this.cfg.mcpServers ? { mcpServers: this.cfg.mcpServers } : {},
898
1383
  ...this.cfg.strictMcpConfig ? { strictMcpConfig: true } : {},
1384
+ ...this.cfg.sandbox ? { sandbox: this.cfg.sandbox } : {},
899
1385
  stderr: (data) => {
900
1386
  this.capturedStderr += data;
901
1387
  },
@@ -1316,6 +1802,7 @@ init_dist();
1316
1802
 
1317
1803
  // src/exec-types.ts
1318
1804
  var DEFAULT_METRIC_TIMEOUT_MS = 300000;
1805
+ var SANDBOX_STARTUP_GRACE_MS = 60000;
1319
1806
 
1320
1807
  // src/gate-config-error.ts
1321
1808
  init_dist();
@@ -2030,11 +2517,12 @@ function errText(err) {
2030
2517
  }
2031
2518
 
2032
2519
  // src/verification.ts
2033
- init_log();
2034
2520
  import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_process";
2521
+ init_log();
2035
2522
 
2036
2523
  // src/pm.ts
2037
2524
  init_log();
2525
+ init_run_containment();
2038
2526
  import { execFileSync } from "node:child_process";
2039
2527
  import { existsSync } from "node:fs";
2040
2528
  var TAG6 = "pm";
@@ -2044,7 +2532,7 @@ function detectPackageManager() {
2044
2532
  return cached;
2045
2533
  let repoRoot;
2046
2534
  try {
2047
- repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
2535
+ repoRoot = execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"], {
2048
2536
  encoding: "utf-8"
2049
2537
  }).trim();
2050
2538
  } catch {
@@ -2087,7 +2575,7 @@ function spawnRunArgs(script, ...extra) {
2087
2575
  // src/project-type.ts
2088
2576
  init_log();
2089
2577
  import { execFileSync as execFileSync2 } from "node:child_process";
2090
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
2578
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
2091
2579
  var TAG7 = "project-type";
2092
2580
  var _cache = new Map;
2093
2581
  function _resetCache() {
@@ -2190,7 +2678,7 @@ var NPM_PLACEHOLDER_TEST = /no test specified/i;
2190
2678
  function hasNodeTestScript(dir) {
2191
2679
  let script;
2192
2680
  try {
2193
- const pkg = JSON.parse(readFileSync2(`${dir}/package.json`, "utf-8"));
2681
+ const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2194
2682
  script = pkg.scripts?.test;
2195
2683
  } catch (err) {
2196
2684
  log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
@@ -2207,7 +2695,7 @@ function hasNodeTestScript(dir) {
2207
2695
  function firstNodeScript(dir, candidates) {
2208
2696
  let scripts;
2209
2697
  try {
2210
- const pkg = JSON.parse(readFileSync2(`${dir}/package.json`, "utf-8"));
2698
+ const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2211
2699
  scripts = pkg.scripts ?? {};
2212
2700
  } catch (err) {
2213
2701
  log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
@@ -2262,10 +2750,108 @@ function resolveXcodeScheme(pt) {
2262
2750
  }
2263
2751
  }
2264
2752
 
2753
+ // src/repair-sandbox.ts
2754
+ init_log();
2755
+ import { randomUUID } from "node:crypto";
2756
+ import { promisify } from "node:util";
2757
+ var TAG8 = "repair-sandbox";
2758
+ async function dockerExec(argv, opts) {
2759
+ const { execFile } = await import("node:child_process");
2760
+ return promisify(execFile)("docker", argv, {
2761
+ encoding: "utf-8",
2762
+ ...opts
2763
+ });
2764
+ }
2765
+ var MAX_OUTPUT_BUFFER2 = 20971520;
2766
+ var PROBE_TIMEOUT_MS = 1e4;
2767
+ var SANDBOX_MOUNT = "/repo";
2768
+ var SANDBOX_MEMORY = "4g";
2769
+ var SANDBOX_PIDS = "512";
2770
+ var dockerProbe = null;
2771
+ async function sandboxAvailable() {
2772
+ if (dockerProbe === true)
2773
+ return true;
2774
+ try {
2775
+ await dockerExec(["version", "--format", "{{.Server.Version}}"], {
2776
+ timeout: PROBE_TIMEOUT_MS
2777
+ });
2778
+ dockerProbe = true;
2779
+ } catch {
2780
+ dockerProbe = false;
2781
+ }
2782
+ return dockerProbe;
2783
+ }
2784
+ function __resetSandboxProbe() {
2785
+ dockerProbe = null;
2786
+ }
2787
+ async function removeContainer(name) {
2788
+ try {
2789
+ await dockerExec(["rm", "--force", name], { timeout: PROBE_TIMEOUT_MS });
2790
+ log.warn(TAG8, `removed the timed-out sandbox container ${name}`);
2791
+ } catch {}
2792
+ }
2793
+ function sandboxRunArgs(image, worktree, command, name) {
2794
+ return [
2795
+ "run",
2796
+ "--rm",
2797
+ ...name ? ["--name", name] : [],
2798
+ "--network=none",
2799
+ "--cap-drop=ALL",
2800
+ "--security-opt=no-new-privileges",
2801
+ `--memory=${SANDBOX_MEMORY}`,
2802
+ `--pids-limit=${SANDBOX_PIDS}`,
2803
+ ...typeof process.getuid === "function" && typeof process.getgid === "function" ? ["--user", `${process.getuid()}:${process.getgid()}`] : [],
2804
+ "--env",
2805
+ "HOME=/tmp",
2806
+ "--volume",
2807
+ `${worktree}:${SANDBOX_MOUNT}`,
2808
+ "--workdir",
2809
+ SANDBOX_MOUNT,
2810
+ "--entrypoint",
2811
+ command.cmd,
2812
+ image,
2813
+ ...command.args
2814
+ ];
2815
+ }
2816
+ async function runInSandbox(args) {
2817
+ const name = `harmony-repair-${randomUUID()}`;
2818
+ const argv = sandboxRunArgs(args.image, args.worktree, args.command, name);
2819
+ log.info(TAG8, `sandbox: ${args.command.cmd} ${args.command.args.join(" ")} (image ${args.image})`);
2820
+ try {
2821
+ const { stdout } = await dockerExec(argv, {
2822
+ timeout: args.timeoutMs,
2823
+ maxBuffer: MAX_OUTPUT_BUFFER2
2824
+ });
2825
+ return { passed: true, output: stdout ?? "" };
2826
+ } catch (err) {
2827
+ const e = err;
2828
+ const output = `${e.stdout ?? ""}${e.stderr ?? ""}`;
2829
+ if (typeof e.code !== "number") {
2830
+ const timedOut = e.killed === true || e.signal != null;
2831
+ if (timedOut)
2832
+ await removeContainer(name);
2833
+ return {
2834
+ passed: false,
2835
+ output,
2836
+ sandboxError: timedOut ? `the sandbox timed out after ${args.timeoutMs}ms` : `the sandbox did not run: ${e.message ?? "unknown error"}`
2837
+ };
2838
+ }
2839
+ if (e.code === 125) {
2840
+ return {
2841
+ passed: false,
2842
+ output,
2843
+ sandboxError: `the sandbox could not start (image "${args.image}" missing or unusable)`
2844
+ };
2845
+ }
2846
+ return { passed: false, output };
2847
+ }
2848
+ }
2849
+
2265
2850
  // src/revert-guard.ts
2266
2851
  init_log();
2852
+ init_run_containment();
2267
2853
  import { execFileSync as execFileSync3 } from "node:child_process";
2268
- var TAG8 = "revert-guard";
2854
+ var TAG9 = "revert-guard";
2269
2855
  var TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
2270
2856
  function isTestFile(path) {
2271
2857
  return TEST_FILE.test(path);
@@ -2275,21 +2861,27 @@ function filterTestFiles(paths) {
2275
2861
  }
2276
2862
  function refetchBase(worktreePath, baseBranch) {
2277
2863
  try {
2278
- execFileSync3("git", ["fetch", "origin", baseBranch], {
2864
+ execFileSync3("git", [...GIT_NO_HOOKS, "fetch", "origin", baseBranch], {
2279
2865
  cwd: worktreePath,
2280
2866
  stdio: "pipe"
2281
2867
  });
2282
2868
  } catch {
2283
- log.warn(TAG8, "Failed to re-fetch base for revert guard — using last fetch");
2869
+ log.warn(TAG9, "Failed to re-fetch base for revert guard — using last fetch");
2284
2870
  }
2285
2871
  }
2286
2872
  function listDeletedFilesAgainstBase(worktreePath, baseBranch) {
2287
2873
  try {
2288
- const out = execFileSync3("git", ["diff", "--diff-filter=D", "--name-only", `origin/${baseBranch}...HEAD`], { cwd: worktreePath, encoding: "utf-8" });
2874
+ const out = execFileSync3("git", [
2875
+ ...GIT_NO_HOOKS,
2876
+ "diff",
2877
+ "--diff-filter=D",
2878
+ "--name-only",
2879
+ `origin/${baseBranch}...HEAD`
2880
+ ], { cwd: worktreePath, encoding: "utf-8" });
2289
2881
  return out.split(`
2290
2882
  `).map((l) => l.trim()).filter((l) => l.length > 0);
2291
2883
  } catch (err) {
2292
- log.warn(TAG8, `Failed to list deleted files: ${err instanceof Error ? err.message : err}`);
2884
+ log.warn(TAG9, `Failed to list deleted files: ${err instanceof Error ? err.message : err}`);
2293
2885
  return [];
2294
2886
  }
2295
2887
  }
@@ -2299,8 +2891,9 @@ function findDeletedTestFiles(worktreePath, baseBranch) {
2299
2891
  }
2300
2892
 
2301
2893
  // src/verification.ts
2302
- var TAG9 = "verification";
2303
- var MAX_OUTPUT_BUFFER2 = 64 * 1024 * 1024;
2894
+ init_run_containment();
2895
+ var TAG10 = "verification";
2896
+ var MAX_OUTPUT_BUFFER3 = 64 * 1024 * 1024;
2304
2897
  async function runVerification(worktreePath, config, workerId) {
2305
2898
  const result = {
2306
2899
  passed: true,
@@ -2310,133 +2903,163 @@ async function runVerification(worktreePath, config, workerId) {
2310
2903
  reviewFindings: [],
2311
2904
  revertWarnings: []
2312
2905
  };
2906
+ const sandbox = verificationSandbox(config);
2313
2907
  if (config.verification.revertGuard) {
2314
- log.info(TAG9, `[worker:${workerId}] Checking for reverted merged work...`);
2908
+ log.info(TAG10, `[worker:${workerId}] Checking for reverted merged work...`);
2315
2909
  const deletedTests = findDeletedTestFiles(worktreePath, config.worktree.baseBranch);
2316
2910
  if (deletedTests.length > 0) {
2317
2911
  result.revertWarnings = deletedTests.map((f) => `Branch deletes test file '${f}' relative to current ${config.worktree.baseBranch} — ` + "likely an accidental revert of already-merged work. Restore the test or rebase on current main.");
2318
- log.warn(TAG9, `[worker:${workerId}] Revert guard tripped: ${deletedTests.length} deleted test file(s)`);
2912
+ log.warn(TAG10, `[worker:${workerId}] Revert guard tripped: ${deletedTests.length} deleted test file(s)`);
2319
2913
  result.passed = false;
2320
2914
  } else {
2321
- log.info(TAG9, `[worker:${workerId}] Revert guard passed`);
2915
+ log.info(TAG10, `[worker:${workerId}] Revert guard passed`);
2322
2916
  }
2323
2917
  }
2324
2918
  if (config.verification.build) {
2325
- log.info(TAG9, `[worker:${workerId}] Running build...`);
2326
- result.buildErrors = runBuild(worktreePath, config.verification.timeout);
2919
+ log.info(TAG10, `[worker:${workerId}] Running build...`);
2920
+ result.buildErrors = await runBuild(worktreePath, config.verification.timeout, sandbox);
2327
2921
  if (result.buildErrors.length > 0) {
2328
- log.warn(TAG9, `[worker:${workerId}] Build failed with ${result.buildErrors.length} error(s)`);
2922
+ log.warn(TAG10, `[worker:${workerId}] Build failed with ${result.buildErrors.length} error(s)`);
2329
2923
  result.passed = false;
2330
2924
  } else {
2331
- log.info(TAG9, `[worker:${workerId}] Build passed`);
2925
+ log.info(TAG10, `[worker:${workerId}] Build passed`);
2332
2926
  }
2333
2927
  }
2334
2928
  if (config.verification.test && result.buildErrors.length === 0) {
2335
- log.info(TAG9, `[worker:${workerId}] Running tests...`);
2336
- result.testFailures = runTests(worktreePath, config.verification.testTimeout);
2929
+ log.info(TAG10, `[worker:${workerId}] Running tests...`);
2930
+ result.testFailures = await runTests(worktreePath, config.verification.testTimeout, sandbox);
2337
2931
  if (result.testFailures.length > 0) {
2338
- log.warn(TAG9, `[worker:${workerId}] Tests failed with ${result.testFailures.length} failure(s)`);
2932
+ log.warn(TAG10, `[worker:${workerId}] Tests failed with ${result.testFailures.length} failure(s)`);
2339
2933
  result.passed = false;
2340
2934
  } else {
2341
- log.info(TAG9, `[worker:${workerId}] Tests passed`);
2935
+ log.info(TAG10, `[worker:${workerId}] Tests passed`);
2342
2936
  }
2343
2937
  }
2344
2938
  if (config.verification.lint) {
2345
- log.info(TAG9, `[worker:${workerId}] Running lint...`);
2346
- result.lintWarnings = runLint(worktreePath, config.verification.timeout);
2939
+ log.info(TAG10, `[worker:${workerId}] Running lint...`);
2940
+ result.lintWarnings = await runLint(worktreePath, config.verification.timeout, sandbox);
2347
2941
  if (result.lintWarnings.length > 0) {
2348
- log.warn(TAG9, `[worker:${workerId}] Lint found ${result.lintWarnings.length} issue(s)`);
2942
+ log.warn(TAG10, `[worker:${workerId}] Lint found ${result.lintWarnings.length} issue(s)`);
2349
2943
  } else {
2350
- log.info(TAG9, `[worker:${workerId}] Lint passed`);
2944
+ log.info(TAG10, `[worker:${workerId}] Lint passed`);
2351
2945
  }
2352
2946
  }
2353
2947
  if (config.verification.deepReview) {
2354
- log.info(TAG9, `[worker:${workerId}] Running deep review...`);
2948
+ log.info(TAG10, `[worker:${workerId}] Running deep review...`);
2355
2949
  result.reviewFindings = await runDeepReview(worktreePath, config, workerId);
2356
2950
  if (result.reviewFindings.length > 0) {
2357
- log.warn(TAG9, `[worker:${workerId}] Deep review found ${result.reviewFindings.length} finding(s)`);
2951
+ log.warn(TAG10, `[worker:${workerId}] Deep review found ${result.reviewFindings.length} finding(s)`);
2358
2952
  } else {
2359
- log.info(TAG9, `[worker:${workerId}] Deep review passed`);
2953
+ log.info(TAG10, `[worker:${workerId}] Deep review passed`);
2360
2954
  }
2361
2955
  }
2362
2956
  return result;
2363
2957
  }
2364
- function runBuild(worktreePath, timeout) {
2365
- const command = buildCommand(worktreePath);
2366
- if (!command) {
2367
- log.warn(TAG9, `No known build toolchain for ${worktreePath} — skipping build`);
2368
- return [];
2958
+ function verificationSandbox(config) {
2959
+ const image = config.verification.sandboxImage?.trim();
2960
+ if (!image)
2961
+ return;
2962
+ return { image };
2963
+ }
2964
+ async function execStep(command, args) {
2965
+ const { worktreePath, timeout } = args;
2966
+ const sandbox = args.sandbox?.image.trim() ? args.sandbox : undefined;
2967
+ if (sandbox) {
2968
+ if (!await sandboxAvailable()) {
2969
+ return {
2970
+ ok: false,
2971
+ sandboxError: `verification.sandboxImage is set to "${sandbox.image}" but no container runtime answered — ` + "start Docker or unset the image to verify on the host"
2972
+ };
2973
+ }
2974
+ const result = await runInSandbox({
2975
+ image: sandbox.image,
2976
+ worktree: worktreePath,
2977
+ command,
2978
+ timeoutMs: timeout + SANDBOX_STARTUP_GRACE_MS
2979
+ });
2980
+ if (result.sandboxError) {
2981
+ return { ok: false, sandboxError: result.sandboxError };
2982
+ }
2983
+ if (result.passed)
2984
+ return { ok: true };
2985
+ return { ok: false, err: { stdout: result.output, stderr: "" } };
2369
2986
  }
2370
2987
  try {
2371
2988
  execFileSync4(command.cmd, command.args, {
2372
2989
  cwd: worktreePath,
2373
2990
  timeout,
2374
2991
  stdio: "pipe",
2375
- maxBuffer: MAX_OUTPUT_BUFFER2
2992
+ maxBuffer: MAX_OUTPUT_BUFFER3,
2993
+ env: containedEnv()
2376
2994
  });
2377
- return [];
2995
+ return { ok: true };
2378
2996
  } catch (err) {
2379
- return parseErrorOutput(err);
2997
+ return { ok: false, err };
2380
2998
  }
2381
2999
  }
2382
- function runTests(worktreePath, timeout) {
3000
+ async function runBuild(worktreePath, timeout, sandbox) {
3001
+ const command = buildCommand(worktreePath);
3002
+ if (!command) {
3003
+ log.warn(TAG10, `No known build toolchain for ${worktreePath} — skipping build`);
3004
+ return [];
3005
+ }
3006
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3007
+ if (outcome.ok)
3008
+ return [];
3009
+ if (outcome.sandboxError) {
3010
+ log.error(TAG10, `Build not verified: ${outcome.sandboxError}`);
3011
+ return [`Build did not run: ${outcome.sandboxError}`];
3012
+ }
3013
+ return parseErrorOutput(outcome.err);
3014
+ }
3015
+ async function runTests(worktreePath, timeout, sandbox) {
2383
3016
  const command = testCommand(worktreePath);
2384
3017
  if (!command) {
2385
- log.warn(TAG9, `No test command for detected toolchain in ${worktreePath} — skipping tests`);
3018
+ log.warn(TAG10, `No test command for detected toolchain in ${worktreePath} — skipping tests`);
2386
3019
  return [];
2387
3020
  }
2388
- try {
2389
- execFileSync4(command.cmd, command.args, {
2390
- cwd: worktreePath,
2391
- timeout,
2392
- stdio: "pipe",
2393
- maxBuffer: MAX_OUTPUT_BUFFER2
2394
- });
3021
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3022
+ if (outcome.ok)
2395
3023
  return [];
2396
- } catch (err) {
2397
- const output = combineOutput(err);
2398
- log.warn(TAG9, `Test run failed:
2399
- ${output.slice(-4000) || "(no output captured)"}`);
2400
- return parseTestFailures(err, timeout);
3024
+ if (outcome.sandboxError) {
3025
+ log.error(TAG10, `Tests not verified: ${outcome.sandboxError}`);
3026
+ return [`Test run did not happen: ${outcome.sandboxError}`];
2401
3027
  }
3028
+ const output = combineOutput(outcome.err);
3029
+ log.warn(TAG10, `Test run failed:
3030
+ ${output.slice(-4000) || "(no output captured)"}`);
3031
+ return parseTestFailures(outcome.err, timeout);
2402
3032
  }
2403
- function runFormatFix(worktreePath, timeout, workerId) {
3033
+ async function runFormatFix(worktreePath, timeout, workerId, sandbox) {
2404
3034
  const command = formatFixCommand(worktreePath);
2405
3035
  if (!command)
2406
3036
  return;
2407
- try {
2408
- execFileSync4(command.cmd, command.args, {
2409
- cwd: worktreePath,
2410
- timeout,
2411
- stdio: "pipe",
2412
- maxBuffer: MAX_OUTPUT_BUFFER2
2413
- });
2414
- log.info(TAG9, `[worker:${workerId}] Auto-formatted worktree before commit/push`);
2415
- } catch (err) {
2416
- log.warn(TAG9, `[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
3037
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3038
+ if (outcome.ok) {
3039
+ log.info(TAG10, `[worker:${workerId}] Auto-formatted worktree before commit/push`);
3040
+ return;
2417
3041
  }
3042
+ const why = outcome.sandboxError ? outcome.sandboxError : outcome.err instanceof Error ? outcome.err.message : String(outcome.err);
3043
+ log.warn(TAG10, `[worker:${workerId}] Auto-format step did not complete (non-fatal): ${why}`);
2418
3044
  }
2419
- function runLint(worktreePath, timeout) {
3045
+ async function runLint(worktreePath, timeout, sandbox) {
2420
3046
  const command = lintCommand(worktreePath);
2421
3047
  if (!command) {
2422
- log.info(TAG9, `No lint step for detected toolchain in ${worktreePath} — skipping lint`);
3048
+ log.info(TAG10, `No lint step for detected toolchain in ${worktreePath} — skipping lint`);
2423
3049
  return [];
2424
3050
  }
2425
- try {
2426
- execFileSync4(command.cmd, command.args, {
2427
- cwd: worktreePath,
2428
- timeout,
2429
- stdio: "pipe",
2430
- maxBuffer: MAX_OUTPUT_BUFFER2
2431
- });
3051
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3052
+ if (outcome.ok)
2432
3053
  return [];
2433
- } catch (err) {
2434
- return parseErrorOutput(err);
3054
+ if (outcome.sandboxError) {
3055
+ log.error(TAG10, `Lint not verified: ${outcome.sandboxError}`);
3056
+ return [`Lint did not run: ${outcome.sandboxError}`];
2435
3057
  }
3058
+ return parseErrorOutput(outcome.err);
2436
3059
  }
2437
3060
  async function runDeepReview(worktreePath, config, workerId) {
2438
3061
  if (!supportsDevServer(worktreePath)) {
2439
- log.info(TAG9, `[worker:${workerId}] Detected non-web toolchain — skipping deep review`);
3062
+ log.info(TAG10, `[worker:${workerId}] Detected non-web toolchain — skipping deep review`);
2440
3063
  return [];
2441
3064
  }
2442
3065
  const port = config.verification.devServerBasePort + workerId;
@@ -2445,22 +3068,23 @@ async function runDeepReview(worktreePath, config, workerId) {
2445
3068
  const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
2446
3069
  devServer = spawn2(cmd, args, {
2447
3070
  cwd: worktreePath,
2448
- stdio: ["ignore", "pipe", "pipe"]
3071
+ stdio: ["ignore", "pipe", "pipe"],
3072
+ env: containedEnv()
2449
3073
  });
2450
3074
  try {
2451
3075
  await waitForDevServer(devServer, 30000);
2452
3076
  await probeDevServer(port);
2453
3077
  } catch (err) {
2454
- log.error(TAG9, `Dev server did not become ready: ${err instanceof Error ? err.message : err}`);
3078
+ log.error(TAG10, `Dev server did not become ready: ${err instanceof Error ? err.message : err}`);
2455
3079
  return [];
2456
3080
  }
2457
3081
  let diff = "";
2458
3082
  try {
2459
- diff = execFileSync4("git", ["diff", `origin/${config.worktree.baseBranch}..HEAD`], {
3083
+ diff = execFileSync4("git", [...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`], {
2460
3084
  cwd: worktreePath,
2461
3085
  encoding: "utf-8",
2462
3086
  timeout: 30000,
2463
- maxBuffer: MAX_OUTPUT_BUFFER2
3087
+ maxBuffer: MAX_OUTPUT_BUFFER3
2464
3088
  });
2465
3089
  } catch {
2466
3090
  diff = "(unable to retrieve diff)";
@@ -2477,14 +3101,16 @@ async function runDeepReview(worktreePath, config, workerId) {
2477
3101
  "```"
2478
3102
  ].join(`
2479
3103
  `);
2480
- const leanSources = config.claude.leanSettingSources;
2481
3104
  const output = execFileSync4("claude", [
2482
3105
  "--print",
2483
3106
  "--model",
2484
3107
  "sonnet",
2485
3108
  "--max-turns",
2486
3109
  "10",
2487
- ...leanSources ? ["--setting-sources", leanSources] : [],
3110
+ ...implementRunContainmentCliArgs({
3111
+ worktree: worktreePath,
3112
+ readOnly: true
3113
+ }),
2488
3114
  "--",
2489
3115
  reviewPrompt
2490
3116
  ], {
@@ -2492,11 +3118,12 @@ async function runDeepReview(worktreePath, config, workerId) {
2492
3118
  encoding: "utf-8",
2493
3119
  timeout: config.verification.timeout,
2494
3120
  stdio: "pipe",
2495
- maxBuffer: MAX_OUTPUT_BUFFER2
3121
+ maxBuffer: MAX_OUTPUT_BUFFER3,
3122
+ env: containedEnv()
2496
3123
  });
2497
3124
  return parseReviewFindings(output);
2498
3125
  } catch (err) {
2499
- log.error(TAG9, `Deep review failed: ${err instanceof Error ? err.message : err}`);
3126
+ log.error(TAG10, `Deep review failed: ${err instanceof Error ? err.message : err}`);
2500
3127
  return [];
2501
3128
  } finally {
2502
3129
  if (devServer && !devServer.killed) {
@@ -2522,7 +3149,6 @@ function attemptAutoFix(worktreePath, config, errors) {
2522
3149
  "```"
2523
3150
  ].join(`
2524
3151
  `);
2525
- const leanSources = config.claude.leanSettingSources;
2526
3152
  const args = [
2527
3153
  "--print",
2528
3154
  "--model",
@@ -2531,16 +3157,17 @@ function attemptAutoFix(worktreePath, config, errors) {
2531
3157
  "50",
2532
3158
  "--allowedTools",
2533
3159
  "Bash,Read,Write,Edit,Glob,Grep",
2534
- ...leanSources ? ["--setting-sources", leanSources] : [],
3160
+ ...implementRunContainmentCliArgs({ worktree: worktreePath }),
2535
3161
  "--",
2536
3162
  fixPrompt
2537
3163
  ];
2538
- log.info(TAG9, "Spawning Claude for auto-fix...");
3164
+ log.info(TAG10, "Spawning Claude for auto-fix...");
2539
3165
  execFileSync4("claude", args, {
2540
3166
  cwd: worktreePath,
2541
3167
  timeout: config.verification.timeout,
2542
3168
  stdio: "pipe",
2543
- maxBuffer: MAX_OUTPUT_BUFFER2
3169
+ maxBuffer: MAX_OUTPUT_BUFFER3,
3170
+ env: containedEnv()
2544
3171
  });
2545
3172
  }
2546
3173
  async function reportFindings(client, cardId, result, recovery) {
@@ -2573,7 +3200,7 @@ async function reportFindings(client, cardId, result, recovery) {
2573
3200
  try {
2574
3201
  await client.createSubtask(cardId, title);
2575
3202
  } catch (err) {
2576
- log.error(TAG9, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
3203
+ log.error(TAG10, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
2577
3204
  }
2578
3205
  }));
2579
3206
  if (overflow > 0) {
@@ -2581,7 +3208,7 @@ async function reportFindings(client, cardId, result, recovery) {
2581
3208
  await client.createSubtask(cardId, `...and ${overflow} more issues`);
2582
3209
  } catch {}
2583
3210
  }
2584
- log.info(TAG9, `Reported ${Math.min(items.length, maxSubtasks)} finding(s) as subtasks on card ${cardId}`);
3211
+ log.info(TAG10, `Reported ${Math.min(items.length, maxSubtasks)} finding(s) as subtasks on card ${cardId}`);
2585
3212
  }
2586
3213
  function combineOutput(err) {
2587
3214
  const stderr = err?.stderr?.toString() ?? "";
@@ -2614,7 +3241,7 @@ function parseTestFailures(err, timeout) {
2614
3241
  }
2615
3242
  if (e?.code === "ENOBUFS") {
2616
3243
  return [
2617
- `Test output exceeded the ${MAX_OUTPUT_BUFFER2 / (1024 * 1024)}MB capture limit and the run was killed — ` + "the suite's real result is unknown. Quieten the reporter or raise the limit."
3244
+ `Test output exceeded the ${MAX_OUTPUT_BUFFER3 / (1024 * 1024)}MB capture limit and the run was killed — ` + "the suite's real result is unknown. Quieten the reporter or raise the limit."
2618
3245
  ];
2619
3246
  }
2620
3247
  const combined = combineOutput(err);
@@ -2641,7 +3268,7 @@ class DevServerReadinessError extends Error {
2641
3268
  }
2642
3269
  }
2643
3270
  function waitForDevServer(proc, timeout) {
2644
- return new Promise((resolve2, reject) => {
3271
+ return new Promise((resolve3, reject) => {
2645
3272
  let settled = false;
2646
3273
  const cleanup = () => {
2647
3274
  proc.stdout?.off("data", onData);
@@ -2655,7 +3282,7 @@ function waitForDevServer(proc, timeout) {
2655
3282
  return;
2656
3283
  settled = true;
2657
3284
  cleanup();
2658
- resolve2();
3285
+ resolve3();
2659
3286
  };
2660
3287
  const settleReject = (err) => {
2661
3288
  if (settled)
@@ -2705,7 +3332,7 @@ async function probeDevServer(port, timeoutMs = 5000) {
2705
3332
  }
2706
3333
 
2707
3334
  // src/gate-collectors.ts
2708
- var TAG10 = "gate-collectors";
3335
+ var TAG11 = "gate-collectors";
2709
3336
  async function resolveStageGate(client, card) {
2710
3337
  const currentStage = card.current_stage;
2711
3338
  const playbookId = card.playbook_id;
@@ -2723,7 +3350,7 @@ async function resolveStageGate(client, card) {
2723
3350
  return null;
2724
3351
  return { stage: resolution.stage, gate };
2725
3352
  } catch (err) {
2726
- log.warn(TAG10, `resolveStageGate failed for stage "${currentStage}": ${err instanceof Error ? err.message : err}`);
3353
+ log.warn(TAG11, `resolveStageGate failed for stage "${currentStage}": ${err instanceof Error ? err.message : err}`);
2727
3354
  return null;
2728
3355
  }
2729
3356
  }
@@ -2758,8 +3385,8 @@ class BuildGreenCollector {
2758
3385
  async collect(_context) {
2759
3386
  const doBuild = this.deps.runBuild ?? runBuild;
2760
3387
  const doLint = this.deps.runLint ?? runLint;
2761
- const buildErrors = doBuild(this.deps.worktreePath, this.deps.buildTimeout);
2762
- const lintWarnings = doLint(this.deps.worktreePath, this.deps.lintTimeout);
3388
+ const buildErrors = await doBuild(this.deps.worktreePath, this.deps.buildTimeout, this.deps.sandbox);
3389
+ const lintWarnings = await doLint(this.deps.worktreePath, this.deps.lintTimeout, this.deps.sandbox);
2763
3390
  const buildPassed = buildErrors.length === 0;
2764
3391
  const lintPassed = lintWarnings.length === 0;
2765
3392
  const result = buildPassed ? "passed" : "failed";
@@ -2855,7 +3482,7 @@ function buildGateCollectorRegistry(deps) {
2855
3482
  async function collectGateEvidence(registry, context) {
2856
3483
  const collector = registry[context.gate.kind];
2857
3484
  if (!collector) {
2858
- log.info(TAG10, `No collector for gate kind "${context.gate.kind}" — reporting blocked`);
3485
+ log.info(TAG11, `No collector for gate kind "${context.gate.kind}" — reporting blocked`);
2859
3486
  return {
2860
3487
  result: "blocked",
2861
3488
  structured: {
@@ -2867,14 +3494,14 @@ async function collectGateEvidence(registry, context) {
2867
3494
  return await collector.collect(context);
2868
3495
  } catch (err) {
2869
3496
  const msg = err instanceof Error ? err.message : String(err);
2870
- log.warn(TAG10, `Collector for "${context.gate.kind}" threw: ${msg} — reporting blocked`);
3497
+ log.warn(TAG11, `Collector for "${context.gate.kind}" threw: ${msg} — reporting blocked`);
2871
3498
  return { result: "blocked", structured: { error: msg } };
2872
3499
  }
2873
3500
  }
2874
3501
 
2875
3502
  // src/harmony-client.ts
2876
3503
  init_log();
2877
- var TAG11 = "harmony-client";
3504
+ var TAG12 = "harmony-client";
2878
3505
  function readClientConfig(env) {
2879
3506
  const apiUrl = env.HARMONY_API_URL?.trim();
2880
3507
  const apiKey = env.HARMONY_API_KEY?.trim();
@@ -2926,7 +3553,7 @@ class HarmonyClient {
2926
3553
  purpose: "gate_evaluation"
2927
3554
  });
2928
3555
  if (!response.ok) {
2929
- log.warn(TAG11, `Oracle fetch for stage ${stageId} returned ${response.status} — no oracle read, the gate will report blocked`);
3556
+ log.warn(TAG12, `Oracle fetch for stage ${stageId} returned ${response.status} — no oracle read, the gate will report blocked`);
2930
3557
  return null;
2931
3558
  }
2932
3559
  const body = await response.json();
@@ -3009,52 +3636,7 @@ function relayAgentEvent(draft) {
3009
3636
 
3010
3637
  // src/stage-cli.ts
3011
3638
  init_dist();
3012
-
3013
- // src/runner.ts
3014
- init_dist();
3015
- import { getConfigDir } from "@gethmy/mcp/src/config.js";
3016
- var HARMONY_CREDENTIAL_KEYS = [
3017
- "HARMONY_API_KEY",
3018
- "HARMONY_API_URL",
3019
- "HARMONY_WORKSPACE_ID",
3020
- "SUPABASE_ANON_KEY",
3021
- "SUPABASE_SERVICE_ROLE_KEY",
3022
- "SUPABASE_URL"
3023
- ];
3024
- function mayHoldCredentials(role) {
3025
- return role === "author" || role === "reviewer";
3026
- }
3027
- function credentialReadDeny() {
3028
- return `Read(/${getConfigDir()}/**)`;
3029
- }
3030
- function credentialAccessDeny() {
3031
- const dir = `/${getConfigDir()}/**`;
3032
- return [`Read(${dir})`, `Grep(${dir})`, `Glob(${dir})`];
3033
- }
3034
- function buildRoleLaunch(args) {
3035
- const role = normalizeStageRole(args.role);
3036
- const keep = mayHoldCredentials(role);
3037
- const env = {};
3038
- for (const [key, value] of Object.entries(args.parentEnv)) {
3039
- if (value === undefined)
3040
- continue;
3041
- if (!keep && HARMONY_CREDENTIAL_KEYS.includes(key))
3042
- continue;
3043
- env[key] = value;
3044
- }
3045
- return {
3046
- role,
3047
- prompt: args.prompt,
3048
- repoPath: args.repoPath,
3049
- env,
3050
- disallowedTools: keep ? [] : [credentialReadDeny()]
3051
- };
3052
- }
3053
- function envKeysDroppedByLaunch(parentEnv, launch) {
3054
- return Object.keys(parentEnv).filter((key) => parentEnv[key] !== undefined && !Object.hasOwn(launch.env, key));
3055
- }
3056
-
3057
- // src/stage-cli.ts
3639
+ init_runner();
3058
3640
  var STAGE_RUN_USAGE = "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>]";
3059
3641
  function readFlag(argv, name) {
3060
3642
  const index = argv.indexOf(`--${name}`);
@@ -3255,7 +3837,7 @@ async function runStage(request, deps) {
3255
3837
  }
3256
3838
 
3257
3839
  // src/cli.ts
3258
- var TAG12 = "cli";
3840
+ var TAG13 = "cli";
3259
3841
  var GATE_VERIFICATION_TIMEOUT_MS = 600000;
3260
3842
  var SHUTDOWN_HARD_EXIT_MS = 15000;
3261
3843
  var activeRunner = null;
@@ -3286,12 +3868,12 @@ async function runRole(request, prompt, emit2) {
3286
3868
  });
3287
3869
  const runner = new SdkAgentRunner(launch.config);
3288
3870
  activeRunner = runner;
3289
- log.info(TAG12, `Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`);
3871
+ log.info(TAG13, `Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`);
3290
3872
  const timeoutMs = stageTimeoutMs(process.env);
3291
3873
  let timedOut = false;
3292
3874
  const clock = timeoutMs > 0 ? setTimeout(() => {
3293
3875
  timedOut = true;
3294
- log.warn(TAG12, `stage ${request.stageId} exceeded ${timeoutMs}ms — stopping the subagent`);
3876
+ log.warn(TAG13, `stage ${request.stageId} exceeded ${timeoutMs}ms — stopping the subagent`);
3295
3877
  runner.stop("timeout");
3296
3878
  }, timeoutMs) : null;
3297
3879
  clock?.unref?.();
@@ -3307,9 +3889,9 @@ async function runRole(request, prompt, emit2) {
3307
3889
  if (relayed)
3308
3890
  emit2(relayed);
3309
3891
  if (event.kind === "error") {
3310
- log.warn(TAG12, `subagent error: ${event.payload.message}`);
3892
+ log.warn(TAG13, `subagent error: ${event.payload.message}`);
3311
3893
  } else {
3312
- log.event(TAG12, `subagent ${event.kind}`);
3894
+ log.event(TAG13, `subagent ${event.kind}`);
3313
3895
  }
3314
3896
  }
3315
3897
  } finally {
@@ -3340,8 +3922,8 @@ ${STAGE_RUN_USAGE}
3340
3922
  const { cardId, stageId, workspaceId, repoPath, sessionId, metricsPath } = parsed.args;
3341
3923
  let driverMetrics = {};
3342
3924
  if (metricsPath !== null) {
3343
- const { readFileSync: readFileSync3 } = await import("node:fs");
3344
- driverMetrics = parseMetricsAllowlist(readFileSync3(metricsPath, "utf8"), metricsPath);
3925
+ const { readFileSync: readFileSync4 } = await import("node:fs");
3926
+ driverMetrics = parseMetricsAllowlist(readFileSync4(metricsPath, "utf8"), metricsPath);
3345
3927
  }
3346
3928
  const client = new HarmonyClient(readClientConfig(process.env));
3347
3929
  const card = await client.fetchStageCard(cardId);