@gethmy/harness 1.4.0 → 1.5.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
  },
@@ -2035,6 +2521,7 @@ import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_proce
2035
2521
 
2036
2522
  // src/pm.ts
2037
2523
  init_log();
2524
+ init_run_containment();
2038
2525
  import { execFileSync } from "node:child_process";
2039
2526
  import { existsSync } from "node:fs";
2040
2527
  var TAG6 = "pm";
@@ -2044,7 +2531,7 @@ function detectPackageManager() {
2044
2531
  return cached;
2045
2532
  let repoRoot;
2046
2533
  try {
2047
- repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
2534
+ repoRoot = execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"], {
2048
2535
  encoding: "utf-8"
2049
2536
  }).trim();
2050
2537
  } catch {
@@ -2087,7 +2574,7 @@ function spawnRunArgs(script, ...extra) {
2087
2574
  // src/project-type.ts
2088
2575
  init_log();
2089
2576
  import { execFileSync as execFileSync2 } from "node:child_process";
2090
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
2577
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
2091
2578
  var TAG7 = "project-type";
2092
2579
  var _cache = new Map;
2093
2580
  function _resetCache() {
@@ -2190,7 +2677,7 @@ var NPM_PLACEHOLDER_TEST = /no test specified/i;
2190
2677
  function hasNodeTestScript(dir) {
2191
2678
  let script;
2192
2679
  try {
2193
- const pkg = JSON.parse(readFileSync2(`${dir}/package.json`, "utf-8"));
2680
+ const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2194
2681
  script = pkg.scripts?.test;
2195
2682
  } catch (err) {
2196
2683
  log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
@@ -2207,7 +2694,7 @@ function hasNodeTestScript(dir) {
2207
2694
  function firstNodeScript(dir, candidates) {
2208
2695
  let scripts;
2209
2696
  try {
2210
- const pkg = JSON.parse(readFileSync2(`${dir}/package.json`, "utf-8"));
2697
+ const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2211
2698
  scripts = pkg.scripts ?? {};
2212
2699
  } catch (err) {
2213
2700
  log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
@@ -2264,6 +2751,7 @@ function resolveXcodeScheme(pt) {
2264
2751
 
2265
2752
  // src/revert-guard.ts
2266
2753
  init_log();
2754
+ init_run_containment();
2267
2755
  import { execFileSync as execFileSync3 } from "node:child_process";
2268
2756
  var TAG8 = "revert-guard";
2269
2757
  var TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
@@ -2275,7 +2763,7 @@ function filterTestFiles(paths) {
2275
2763
  }
2276
2764
  function refetchBase(worktreePath, baseBranch) {
2277
2765
  try {
2278
- execFileSync3("git", ["fetch", "origin", baseBranch], {
2766
+ execFileSync3("git", [...GIT_NO_HOOKS, "fetch", "origin", baseBranch], {
2279
2767
  cwd: worktreePath,
2280
2768
  stdio: "pipe"
2281
2769
  });
@@ -2285,7 +2773,13 @@ function refetchBase(worktreePath, baseBranch) {
2285
2773
  }
2286
2774
  function listDeletedFilesAgainstBase(worktreePath, baseBranch) {
2287
2775
  try {
2288
- const out = execFileSync3("git", ["diff", "--diff-filter=D", "--name-only", `origin/${baseBranch}...HEAD`], { cwd: worktreePath, encoding: "utf-8" });
2776
+ const out = execFileSync3("git", [
2777
+ ...GIT_NO_HOOKS,
2778
+ "diff",
2779
+ "--diff-filter=D",
2780
+ "--name-only",
2781
+ `origin/${baseBranch}...HEAD`
2782
+ ], { cwd: worktreePath, encoding: "utf-8" });
2289
2783
  return out.split(`
2290
2784
  `).map((l) => l.trim()).filter((l) => l.length > 0);
2291
2785
  } catch (err) {
@@ -2299,6 +2793,7 @@ function findDeletedTestFiles(worktreePath, baseBranch) {
2299
2793
  }
2300
2794
 
2301
2795
  // src/verification.ts
2796
+ init_run_containment();
2302
2797
  var TAG9 = "verification";
2303
2798
  var MAX_OUTPUT_BUFFER2 = 64 * 1024 * 1024;
2304
2799
  async function runVerification(worktreePath, config, workerId) {
@@ -2372,7 +2867,8 @@ function runBuild(worktreePath, timeout) {
2372
2867
  cwd: worktreePath,
2373
2868
  timeout,
2374
2869
  stdio: "pipe",
2375
- maxBuffer: MAX_OUTPUT_BUFFER2
2870
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2871
+ env: containedEnv()
2376
2872
  });
2377
2873
  return [];
2378
2874
  } catch (err) {
@@ -2390,7 +2886,8 @@ function runTests(worktreePath, timeout) {
2390
2886
  cwd: worktreePath,
2391
2887
  timeout,
2392
2888
  stdio: "pipe",
2393
- maxBuffer: MAX_OUTPUT_BUFFER2
2889
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2890
+ env: containedEnv()
2394
2891
  });
2395
2892
  return [];
2396
2893
  } catch (err) {
@@ -2409,7 +2906,8 @@ function runFormatFix(worktreePath, timeout, workerId) {
2409
2906
  cwd: worktreePath,
2410
2907
  timeout,
2411
2908
  stdio: "pipe",
2412
- maxBuffer: MAX_OUTPUT_BUFFER2
2909
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2910
+ env: containedEnv()
2413
2911
  });
2414
2912
  log.info(TAG9, `[worker:${workerId}] Auto-formatted worktree before commit/push`);
2415
2913
  } catch (err) {
@@ -2427,7 +2925,8 @@ function runLint(worktreePath, timeout) {
2427
2925
  cwd: worktreePath,
2428
2926
  timeout,
2429
2927
  stdio: "pipe",
2430
- maxBuffer: MAX_OUTPUT_BUFFER2
2928
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2929
+ env: containedEnv()
2431
2930
  });
2432
2931
  return [];
2433
2932
  } catch (err) {
@@ -2445,7 +2944,8 @@ async function runDeepReview(worktreePath, config, workerId) {
2445
2944
  const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
2446
2945
  devServer = spawn2(cmd, args, {
2447
2946
  cwd: worktreePath,
2448
- stdio: ["ignore", "pipe", "pipe"]
2947
+ stdio: ["ignore", "pipe", "pipe"],
2948
+ env: containedEnv()
2449
2949
  });
2450
2950
  try {
2451
2951
  await waitForDevServer(devServer, 30000);
@@ -2456,7 +2956,7 @@ async function runDeepReview(worktreePath, config, workerId) {
2456
2956
  }
2457
2957
  let diff = "";
2458
2958
  try {
2459
- diff = execFileSync4("git", ["diff", `origin/${config.worktree.baseBranch}..HEAD`], {
2959
+ diff = execFileSync4("git", [...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`], {
2460
2960
  cwd: worktreePath,
2461
2961
  encoding: "utf-8",
2462
2962
  timeout: 30000,
@@ -2477,14 +2977,16 @@ async function runDeepReview(worktreePath, config, workerId) {
2477
2977
  "```"
2478
2978
  ].join(`
2479
2979
  `);
2480
- const leanSources = config.claude.leanSettingSources;
2481
2980
  const output = execFileSync4("claude", [
2482
2981
  "--print",
2483
2982
  "--model",
2484
2983
  "sonnet",
2485
2984
  "--max-turns",
2486
2985
  "10",
2487
- ...leanSources ? ["--setting-sources", leanSources] : [],
2986
+ ...implementRunContainmentCliArgs({
2987
+ worktree: worktreePath,
2988
+ readOnly: true
2989
+ }),
2488
2990
  "--",
2489
2991
  reviewPrompt
2490
2992
  ], {
@@ -2492,7 +2994,8 @@ async function runDeepReview(worktreePath, config, workerId) {
2492
2994
  encoding: "utf-8",
2493
2995
  timeout: config.verification.timeout,
2494
2996
  stdio: "pipe",
2495
- maxBuffer: MAX_OUTPUT_BUFFER2
2997
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2998
+ env: containedEnv()
2496
2999
  });
2497
3000
  return parseReviewFindings(output);
2498
3001
  } catch (err) {
@@ -2522,7 +3025,6 @@ function attemptAutoFix(worktreePath, config, errors) {
2522
3025
  "```"
2523
3026
  ].join(`
2524
3027
  `);
2525
- const leanSources = config.claude.leanSettingSources;
2526
3028
  const args = [
2527
3029
  "--print",
2528
3030
  "--model",
@@ -2531,7 +3033,7 @@ function attemptAutoFix(worktreePath, config, errors) {
2531
3033
  "50",
2532
3034
  "--allowedTools",
2533
3035
  "Bash,Read,Write,Edit,Glob,Grep",
2534
- ...leanSources ? ["--setting-sources", leanSources] : [],
3036
+ ...implementRunContainmentCliArgs({ worktree: worktreePath }),
2535
3037
  "--",
2536
3038
  fixPrompt
2537
3039
  ];
@@ -2540,7 +3042,8 @@ function attemptAutoFix(worktreePath, config, errors) {
2540
3042
  cwd: worktreePath,
2541
3043
  timeout: config.verification.timeout,
2542
3044
  stdio: "pipe",
2543
- maxBuffer: MAX_OUTPUT_BUFFER2
3045
+ maxBuffer: MAX_OUTPUT_BUFFER2,
3046
+ env: containedEnv()
2544
3047
  });
2545
3048
  }
2546
3049
  async function reportFindings(client, cardId, result, recovery) {
@@ -2641,7 +3144,7 @@ class DevServerReadinessError extends Error {
2641
3144
  }
2642
3145
  }
2643
3146
  function waitForDevServer(proc, timeout) {
2644
- return new Promise((resolve2, reject) => {
3147
+ return new Promise((resolve3, reject) => {
2645
3148
  let settled = false;
2646
3149
  const cleanup = () => {
2647
3150
  proc.stdout?.off("data", onData);
@@ -2655,7 +3158,7 @@ function waitForDevServer(proc, timeout) {
2655
3158
  return;
2656
3159
  settled = true;
2657
3160
  cleanup();
2658
- resolve2();
3161
+ resolve3();
2659
3162
  };
2660
3163
  const settleReject = (err) => {
2661
3164
  if (settled)
@@ -3009,52 +3512,7 @@ function relayAgentEvent(draft) {
3009
3512
 
3010
3513
  // src/stage-cli.ts
3011
3514
  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
3515
+ init_runner();
3058
3516
  var STAGE_RUN_USAGE = "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>]";
3059
3517
  function readFlag(argv, name) {
3060
3518
  const index = argv.indexOf(`--${name}`);
@@ -3340,8 +3798,8 @@ ${STAGE_RUN_USAGE}
3340
3798
  const { cardId, stageId, workspaceId, repoPath, sessionId, metricsPath } = parsed.args;
3341
3799
  let driverMetrics = {};
3342
3800
  if (metricsPath !== null) {
3343
- const { readFileSync: readFileSync3 } = await import("node:fs");
3344
- driverMetrics = parseMetricsAllowlist(readFileSync3(metricsPath, "utf8"), metricsPath);
3801
+ const { readFileSync: readFileSync4 } = await import("node:fs");
3802
+ driverMetrics = parseMetricsAllowlist(readFileSync4(metricsPath, "utf8"), metricsPath);
3345
3803
  }
3346
3804
  const client = new HarmonyClient(readClientConfig(process.env));
3347
3805
  const card = await client.fetchStageCard(cardId);