@gethmy/harness 1.3.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
@@ -473,6 +473,13 @@ var init_playbookStage = __esm(() => {
473
473
 
474
474
  // ../harmony-shared/dist/projectTemplates.js
475
475
  var init_projectTemplates = () => {};
476
+
477
+ // ../harmony-shared/dist/realtimeChannel.js
478
+ var inFlightDetach;
479
+ var init_realtimeChannel = __esm(() => {
480
+ init_logger();
481
+ inFlightDetach = new WeakMap;
482
+ });
476
483
  // ../harmony-shared/dist/reviewTools.js
477
484
  var REVIEW_DISALLOWED_TOOLS;
478
485
  var init_reviewTools = __esm(() => {
@@ -494,7 +501,6 @@ var init_stageHandoff = __esm(() => {
494
501
 
495
502
  // ../harmony-shared/dist/types.js
496
503
  var init_types = () => {};
497
-
498
504
  // ../harmony-shared/dist/index.js
499
505
  var init_dist = __esm(() => {
500
506
  init_agentStaleness();
@@ -514,6 +520,7 @@ var init_dist = __esm(() => {
514
520
  init_playbookCatalog();
515
521
  init_playbookStage();
516
522
  init_projectTemplates();
523
+ init_realtimeChannel();
517
524
  init_reviewTools();
518
525
  init_stageHandoff();
519
526
  init_types();
@@ -603,6 +610,492 @@ var init_log = __esm(() => {
603
610
  };
604
611
  });
605
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
+
606
1099
  // src/cli.ts
607
1100
  init_dist();
608
1101
 
@@ -888,6 +1381,7 @@ class SdkAgentRunner {
888
1381
  ...this.cfg.settingSources ? { settingSources: this.cfg.settingSources } : {},
889
1382
  ...this.cfg.mcpServers ? { mcpServers: this.cfg.mcpServers } : {},
890
1383
  ...this.cfg.strictMcpConfig ? { strictMcpConfig: true } : {},
1384
+ ...this.cfg.sandbox ? { sandbox: this.cfg.sandbox } : {},
891
1385
  stderr: (data) => {
892
1386
  this.capturedStderr += data;
893
1387
  },
@@ -2027,6 +2521,7 @@ import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_proce
2027
2521
 
2028
2522
  // src/pm.ts
2029
2523
  init_log();
2524
+ init_run_containment();
2030
2525
  import { execFileSync } from "node:child_process";
2031
2526
  import { existsSync } from "node:fs";
2032
2527
  var TAG6 = "pm";
@@ -2036,7 +2531,7 @@ function detectPackageManager() {
2036
2531
  return cached;
2037
2532
  let repoRoot;
2038
2533
  try {
2039
- repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
2534
+ repoRoot = execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"], {
2040
2535
  encoding: "utf-8"
2041
2536
  }).trim();
2042
2537
  } catch {
@@ -2079,7 +2574,7 @@ function spawnRunArgs(script, ...extra) {
2079
2574
  // src/project-type.ts
2080
2575
  init_log();
2081
2576
  import { execFileSync as execFileSync2 } from "node:child_process";
2082
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
2577
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
2083
2578
  var TAG7 = "project-type";
2084
2579
  var _cache = new Map;
2085
2580
  function _resetCache() {
@@ -2182,7 +2677,7 @@ var NPM_PLACEHOLDER_TEST = /no test specified/i;
2182
2677
  function hasNodeTestScript(dir) {
2183
2678
  let script;
2184
2679
  try {
2185
- const pkg = JSON.parse(readFileSync2(`${dir}/package.json`, "utf-8"));
2680
+ const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2186
2681
  script = pkg.scripts?.test;
2187
2682
  } catch (err) {
2188
2683
  log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
@@ -2199,7 +2694,7 @@ function hasNodeTestScript(dir) {
2199
2694
  function firstNodeScript(dir, candidates) {
2200
2695
  let scripts;
2201
2696
  try {
2202
- const pkg = JSON.parse(readFileSync2(`${dir}/package.json`, "utf-8"));
2697
+ const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2203
2698
  scripts = pkg.scripts ?? {};
2204
2699
  } catch (err) {
2205
2700
  log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
@@ -2256,6 +2751,7 @@ function resolveXcodeScheme(pt) {
2256
2751
 
2257
2752
  // src/revert-guard.ts
2258
2753
  init_log();
2754
+ init_run_containment();
2259
2755
  import { execFileSync as execFileSync3 } from "node:child_process";
2260
2756
  var TAG8 = "revert-guard";
2261
2757
  var TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
@@ -2267,7 +2763,7 @@ function filterTestFiles(paths) {
2267
2763
  }
2268
2764
  function refetchBase(worktreePath, baseBranch) {
2269
2765
  try {
2270
- execFileSync3("git", ["fetch", "origin", baseBranch], {
2766
+ execFileSync3("git", [...GIT_NO_HOOKS, "fetch", "origin", baseBranch], {
2271
2767
  cwd: worktreePath,
2272
2768
  stdio: "pipe"
2273
2769
  });
@@ -2277,7 +2773,13 @@ function refetchBase(worktreePath, baseBranch) {
2277
2773
  }
2278
2774
  function listDeletedFilesAgainstBase(worktreePath, baseBranch) {
2279
2775
  try {
2280
- 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" });
2281
2783
  return out.split(`
2282
2784
  `).map((l) => l.trim()).filter((l) => l.length > 0);
2283
2785
  } catch (err) {
@@ -2291,6 +2793,7 @@ function findDeletedTestFiles(worktreePath, baseBranch) {
2291
2793
  }
2292
2794
 
2293
2795
  // src/verification.ts
2796
+ init_run_containment();
2294
2797
  var TAG9 = "verification";
2295
2798
  var MAX_OUTPUT_BUFFER2 = 64 * 1024 * 1024;
2296
2799
  async function runVerification(worktreePath, config, workerId) {
@@ -2364,7 +2867,8 @@ function runBuild(worktreePath, timeout) {
2364
2867
  cwd: worktreePath,
2365
2868
  timeout,
2366
2869
  stdio: "pipe",
2367
- maxBuffer: MAX_OUTPUT_BUFFER2
2870
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2871
+ env: containedEnv()
2368
2872
  });
2369
2873
  return [];
2370
2874
  } catch (err) {
@@ -2382,7 +2886,8 @@ function runTests(worktreePath, timeout) {
2382
2886
  cwd: worktreePath,
2383
2887
  timeout,
2384
2888
  stdio: "pipe",
2385
- maxBuffer: MAX_OUTPUT_BUFFER2
2889
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2890
+ env: containedEnv()
2386
2891
  });
2387
2892
  return [];
2388
2893
  } catch (err) {
@@ -2401,7 +2906,8 @@ function runFormatFix(worktreePath, timeout, workerId) {
2401
2906
  cwd: worktreePath,
2402
2907
  timeout,
2403
2908
  stdio: "pipe",
2404
- maxBuffer: MAX_OUTPUT_BUFFER2
2909
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2910
+ env: containedEnv()
2405
2911
  });
2406
2912
  log.info(TAG9, `[worker:${workerId}] Auto-formatted worktree before commit/push`);
2407
2913
  } catch (err) {
@@ -2419,7 +2925,8 @@ function runLint(worktreePath, timeout) {
2419
2925
  cwd: worktreePath,
2420
2926
  timeout,
2421
2927
  stdio: "pipe",
2422
- maxBuffer: MAX_OUTPUT_BUFFER2
2928
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2929
+ env: containedEnv()
2423
2930
  });
2424
2931
  return [];
2425
2932
  } catch (err) {
@@ -2437,7 +2944,8 @@ async function runDeepReview(worktreePath, config, workerId) {
2437
2944
  const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
2438
2945
  devServer = spawn2(cmd, args, {
2439
2946
  cwd: worktreePath,
2440
- stdio: ["ignore", "pipe", "pipe"]
2947
+ stdio: ["ignore", "pipe", "pipe"],
2948
+ env: containedEnv()
2441
2949
  });
2442
2950
  try {
2443
2951
  await waitForDevServer(devServer, 30000);
@@ -2448,7 +2956,7 @@ async function runDeepReview(worktreePath, config, workerId) {
2448
2956
  }
2449
2957
  let diff = "";
2450
2958
  try {
2451
- diff = execFileSync4("git", ["diff", `origin/${config.worktree.baseBranch}..HEAD`], {
2959
+ diff = execFileSync4("git", [...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`], {
2452
2960
  cwd: worktreePath,
2453
2961
  encoding: "utf-8",
2454
2962
  timeout: 30000,
@@ -2469,14 +2977,16 @@ async function runDeepReview(worktreePath, config, workerId) {
2469
2977
  "```"
2470
2978
  ].join(`
2471
2979
  `);
2472
- const leanSources = config.claude.leanSettingSources;
2473
2980
  const output = execFileSync4("claude", [
2474
2981
  "--print",
2475
2982
  "--model",
2476
2983
  "sonnet",
2477
2984
  "--max-turns",
2478
2985
  "10",
2479
- ...leanSources ? ["--setting-sources", leanSources] : [],
2986
+ ...implementRunContainmentCliArgs({
2987
+ worktree: worktreePath,
2988
+ readOnly: true
2989
+ }),
2480
2990
  "--",
2481
2991
  reviewPrompt
2482
2992
  ], {
@@ -2484,7 +2994,8 @@ async function runDeepReview(worktreePath, config, workerId) {
2484
2994
  encoding: "utf-8",
2485
2995
  timeout: config.verification.timeout,
2486
2996
  stdio: "pipe",
2487
- maxBuffer: MAX_OUTPUT_BUFFER2
2997
+ maxBuffer: MAX_OUTPUT_BUFFER2,
2998
+ env: containedEnv()
2488
2999
  });
2489
3000
  return parseReviewFindings(output);
2490
3001
  } catch (err) {
@@ -2514,7 +3025,6 @@ function attemptAutoFix(worktreePath, config, errors) {
2514
3025
  "```"
2515
3026
  ].join(`
2516
3027
  `);
2517
- const leanSources = config.claude.leanSettingSources;
2518
3028
  const args = [
2519
3029
  "--print",
2520
3030
  "--model",
@@ -2523,7 +3033,7 @@ function attemptAutoFix(worktreePath, config, errors) {
2523
3033
  "50",
2524
3034
  "--allowedTools",
2525
3035
  "Bash,Read,Write,Edit,Glob,Grep",
2526
- ...leanSources ? ["--setting-sources", leanSources] : [],
3036
+ ...implementRunContainmentCliArgs({ worktree: worktreePath }),
2527
3037
  "--",
2528
3038
  fixPrompt
2529
3039
  ];
@@ -2532,7 +3042,8 @@ function attemptAutoFix(worktreePath, config, errors) {
2532
3042
  cwd: worktreePath,
2533
3043
  timeout: config.verification.timeout,
2534
3044
  stdio: "pipe",
2535
- maxBuffer: MAX_OUTPUT_BUFFER2
3045
+ maxBuffer: MAX_OUTPUT_BUFFER2,
3046
+ env: containedEnv()
2536
3047
  });
2537
3048
  }
2538
3049
  async function reportFindings(client, cardId, result, recovery) {
@@ -2633,7 +3144,7 @@ class DevServerReadinessError extends Error {
2633
3144
  }
2634
3145
  }
2635
3146
  function waitForDevServer(proc, timeout) {
2636
- return new Promise((resolve2, reject) => {
3147
+ return new Promise((resolve3, reject) => {
2637
3148
  let settled = false;
2638
3149
  const cleanup = () => {
2639
3150
  proc.stdout?.off("data", onData);
@@ -2647,7 +3158,7 @@ function waitForDevServer(proc, timeout) {
2647
3158
  return;
2648
3159
  settled = true;
2649
3160
  cleanup();
2650
- resolve2();
3161
+ resolve3();
2651
3162
  };
2652
3163
  const settleReject = (err) => {
2653
3164
  if (settled)
@@ -3001,52 +3512,7 @@ function relayAgentEvent(draft) {
3001
3512
 
3002
3513
  // src/stage-cli.ts
3003
3514
  init_dist();
3004
-
3005
- // src/runner.ts
3006
- init_dist();
3007
- import { getConfigDir } from "@gethmy/mcp/src/config.js";
3008
- var HARMONY_CREDENTIAL_KEYS = [
3009
- "HARMONY_API_KEY",
3010
- "HARMONY_API_URL",
3011
- "HARMONY_WORKSPACE_ID",
3012
- "SUPABASE_ANON_KEY",
3013
- "SUPABASE_SERVICE_ROLE_KEY",
3014
- "SUPABASE_URL"
3015
- ];
3016
- function mayHoldCredentials(role) {
3017
- return role === "author" || role === "reviewer";
3018
- }
3019
- function credentialReadDeny() {
3020
- return `Read(/${getConfigDir()}/**)`;
3021
- }
3022
- function credentialAccessDeny() {
3023
- const dir = `/${getConfigDir()}/**`;
3024
- return [`Read(${dir})`, `Grep(${dir})`, `Glob(${dir})`];
3025
- }
3026
- function buildRoleLaunch(args) {
3027
- const role = normalizeStageRole(args.role);
3028
- const keep = mayHoldCredentials(role);
3029
- const env = {};
3030
- for (const [key, value] of Object.entries(args.parentEnv)) {
3031
- if (value === undefined)
3032
- continue;
3033
- if (!keep && HARMONY_CREDENTIAL_KEYS.includes(key))
3034
- continue;
3035
- env[key] = value;
3036
- }
3037
- return {
3038
- role,
3039
- prompt: args.prompt,
3040
- repoPath: args.repoPath,
3041
- env,
3042
- disallowedTools: keep ? [] : [credentialReadDeny()]
3043
- };
3044
- }
3045
- function envKeysDroppedByLaunch(parentEnv, launch) {
3046
- return Object.keys(parentEnv).filter((key) => parentEnv[key] !== undefined && !Object.hasOwn(launch.env, key));
3047
- }
3048
-
3049
- // src/stage-cli.ts
3515
+ init_runner();
3050
3516
  var STAGE_RUN_USAGE = "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>]";
3051
3517
  function readFlag(argv, name) {
3052
3518
  const index = argv.indexOf(`--${name}`);
@@ -3332,8 +3798,8 @@ ${STAGE_RUN_USAGE}
3332
3798
  const { cardId, stageId, workspaceId, repoPath, sessionId, metricsPath } = parsed.args;
3333
3799
  let driverMetrics = {};
3334
3800
  if (metricsPath !== null) {
3335
- const { readFileSync: readFileSync3 } = await import("node:fs");
3336
- driverMetrics = parseMetricsAllowlist(readFileSync3(metricsPath, "utf8"), metricsPath);
3801
+ const { readFileSync: readFileSync4 } = await import("node:fs");
3802
+ driverMetrics = parseMetricsAllowlist(readFileSync4(metricsPath, "utf8"), metricsPath);
3337
3803
  }
3338
3804
  const client = new HarmonyClient(readClientConfig(process.env));
3339
3805
  const card = await client.fetchStageCard(cardId);