@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 +527 -69
- package/dist/index.js +660 -228
- package/package.json +2 -2
- package/src/ci-failure.ts +10 -5
- package/src/exec-types.ts +5 -7
- package/src/git-diff-stat.ts +2 -1
- package/src/git-pr.ts +59 -25
- package/src/index.ts +1 -0
- package/src/pm.ts +8 -3
- package/src/revert-guard.ts +9 -2
- package/src/run-containment.ts +1292 -0
- package/src/sdk-agent-runner.ts +19 -0
- package/src/verification.ts +84 -7
- package/src/worktree.ts +149 -57
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
|
|
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(
|
|
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(
|
|
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", [
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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((
|
|
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
|
-
|
|
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:
|
|
3344
|
-
driverMetrics = parseMetricsAllowlist(
|
|
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);
|