harnery 0.28.0 → 0.29.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.
@@ -1 +1 @@
1
- {"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAUnD,OAAO,EAUL,KAAK,WAAW,EAEjB,MAAM,uBAAuB,CAAC;AAsC/B,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA8F7E;AAgND,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAM7D"}
1
+ {"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAUnD,OAAO,EAUL,KAAK,WAAW,EAEjB,MAAM,uBAAuB,CAAC;AAuC/B,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA8F7E;AA0OD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAM7D"}
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { workflowSubscriptionOnly } from "../core/config.js";
2
3
  import { createBuiltinHarnessRegistry, harnessProofInputs, probeBinaryVersion, } from "../core/harnesses/index.js";
3
4
  import { findCoordRoot } from "../core/hooks/resolve/coord-root.js";
@@ -111,6 +112,7 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
111
112
  .option("--allow-api-billing", "Permit API-key override billing")
112
113
  .option("--policy <file>", "Host policy JSON/JSONC")
113
114
  .option("--isolation <mode>", "shared | worktree | sandbox | remote")
115
+ .option("--workspace-root <dir>", "Writable root the isolated workspace is allocated under")
114
116
  .option("--approval-to <address>", "Address durable ASK requests")
115
117
  .option("--actor <name>", "Actor recorded in attempt receipts")
116
118
  .option("--json", "Emit the workflow report or parked result as JSON")
@@ -119,7 +121,21 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
119
121
  if (opts.harness && !registry.get(opts.harness)) {
120
122
  throw new Error(`unknown harness ${JSON.stringify(opts.harness)}`);
121
123
  }
124
+ if (opts.workspaceRoot && opts.isolation !== "worktree") {
125
+ throw new Error("--workspace-root requires --isolation worktree");
126
+ }
122
127
  try {
128
+ // An isolated attempt needs a provider AND the writable root it may
129
+ // allocate under; the engine refuses to guess either. Without them a
130
+ // worktree request degrades to shared, which the proof records as
131
+ // requested vs effective isolation and the reporting below surfaces.
132
+ const { createLocalGitWorktreeProvider } = await import("../core/workflow/index.js");
133
+ const workspace = opts.workspaceRoot
134
+ ? {
135
+ provider: createLocalGitWorktreeProvider({ coordRoot }),
136
+ writableRoots: [resolve(opts.workspaceRoot)],
137
+ }
138
+ : undefined;
123
139
  const report = await runWorkItem({
124
140
  coordRoot,
125
141
  workId,
@@ -138,6 +154,7 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
138
154
  approvalMode: "park",
139
155
  approvalAddressee: opts.approvalTo,
140
156
  isolation: opts.isolation,
157
+ workspace,
141
158
  networkAccess: "enabled",
142
159
  },
143
160
  });
@@ -146,7 +163,16 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
146
163
  emit.data(report);
147
164
  }
148
165
  else {
149
- emit.text(`work ${workId}: run ${report.runId} finished\nproof: ${report.proofPath}\n` +
166
+ // A requested isolation the run could not honour is recorded in the
167
+ // proof as requested vs effective, which nobody reads on a green run.
168
+ // Say it here instead, so a silent downgrade to shared cannot pass for
169
+ // an isolated attempt.
170
+ const degraded = opts.isolation && opts.isolation !== "shared" && !report.workspaceBinding
171
+ ? `note: ran shared; ${opts.isolation} isolation was requested but not allocated` +
172
+ `${opts.workspaceRoot ? "" : " (no --workspace-root given)"}\n`
173
+ : "";
174
+ emit.text(`work ${workId}: run ${report.runId} finished\n${degraded}` +
175
+ `proof: ${report.proofPath}\n` +
150
176
  `next: harn work reconcile ${workId}\n`);
151
177
  }
152
178
  }
@@ -1 +1 @@
1
- {"version":3,"file":"local-git.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/local-git.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAgBV,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EAElB,MAAM,YAAY,CAAC;AASpB,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;CAC1C;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,iBAAiB,CAgBnB;AAED,wBAAsB,KAAK,CACzB,KAAK,EAAE,mBAAmB,EAC1B,wBAAwB,GAAE,MAAM,OAAwC,GACvE,OAAO,CAAC,oBAAoB,CAAC,CAkE/B"}
1
+ {"version":3,"file":"local-git.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/local-git.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAgBV,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EAElB,MAAM,YAAY,CAAC;AASpB,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;CAC1C;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,iBAAiB,CAgBnB;AAED,wBAAsB,KAAK,CACzB,KAAK,EAAE,mBAAmB,EAC1B,wBAAwB,GAAE,MAAM,OAAwC,GACvE,OAAO,CAAC,oBAAoB,CAAC,CA4E/B"}
@@ -44,17 +44,28 @@ export async function probe(input, descriptorPathsSupported = descriptorBackedPa
44
44
  if (input.writable_roots.length === 0) {
45
45
  unsupported.push(reason("writable_roots_required", "explicit writable roots are required"));
46
46
  }
47
- for (const root of input.writable_roots) {
47
+ // Allocation always selects writable_roots[0] (validateRequest pins the selected root
48
+ // to it), so probe's authority-coverage verdict must be judged against that same root.
49
+ let selectedRoot;
50
+ input.writable_roots.forEach((root, index) => {
48
51
  try {
49
- validateConfiguredRoot(root);
52
+ const validated = validateConfiguredRoot(root);
53
+ if (index === 0)
54
+ selectedRoot = validated;
50
55
  }
51
56
  catch (error) {
52
57
  unsupported.push(reason("writable_root_invalid", error.message));
53
58
  }
54
- }
59
+ });
55
60
  if (version.ok) {
56
61
  try {
57
- inspectSourceRepository(input.requested_cwd);
62
+ const repo = inspectSourceRepository(input.requested_cwd);
63
+ if (selectedRoot) {
64
+ const outside = describeAuthorityOutsideRoot(repo, selectedRoot.realpath);
65
+ if (outside) {
66
+ unsupported.push(reason("repository_authority_outside_writable_root", outside));
67
+ }
68
+ }
58
69
  }
59
70
  catch (error) {
60
71
  unsupported.push(reason("repository_unsupported", error.message));
@@ -115,10 +126,9 @@ async function allocate(coordRoot, request) {
115
126
  const repo = inspectSourceRepository(request.requested_cwd);
116
127
  assertAllowedPathAuthority(request.allowed_paths, repo.sourceRoot.realpath, "source repository");
117
128
  assertAllowedPathAuthority(request.allowed_paths, repo.commonDir.realpath, "Git common directory");
118
- if (!containsPath(root.realpath, repo.sourceRoot.realpath) ||
119
- !containsPath(root.realpath, repo.commonDir.realpath)) {
120
- throw new Error("source repository and Git common dir must be inside the writable root");
121
- }
129
+ const authorityOutside = describeAuthorityOutsideRoot(repo, root.realpath);
130
+ if (authorityOutside)
131
+ throw new Error(authorityOutside);
122
132
  const workspaceRoot = candidateUnderRoot(root, ["harnery-workspaces", bindingId]);
123
133
  const activeRoot = resolve(workspaceRoot, repo.requestedRelativePath);
124
134
  if (!containsPath(workspaceRoot, activeRoot)) {
@@ -887,6 +897,17 @@ async function applyIntegrationUnderLease(coordRoot, input) {
887
897
  }
888
898
  revalidateClaimResources(claim, validateConfiguredRoot(plan.binding.writable_root.configured));
889
899
  inspectIntegrationTarget(current.target_root, plan.binding);
900
+ // The fast-forward moves the target branch ref, which for a submodule or linked
901
+ // worktree lives in the common directory outside the checkout tree. allowed_paths is
902
+ // the real write authority (allocate and cleanup already gate the common directory on
903
+ // it), so re-authorize both the target checkout and its common directory here rather
904
+ // than trusting containment alone.
905
+ const applyCommonDir = plan.binding.repository?.common_dir.realpath;
906
+ if (!applyCommonDir) {
907
+ throw new Error("integration binding is missing Git common-dir authority");
908
+ }
909
+ assertAllowedPathAuthority(claim.request.allowed_paths, current.target_root, "integration target path");
910
+ assertAllowedPathAuthority(claim.request.allowed_paths, applyCommonDir, "integration Git common directory");
890
911
  git(current.target_root, ["merge", "--ff-only", expected.source_commit]);
891
912
  const finalCommit = git(current.target_root, ["rev-parse", "HEAD"]);
892
913
  const finalTree = git(current.target_root, ["rev-parse", "HEAD^{tree}"]);
@@ -969,18 +990,15 @@ function inspectSourceRepository(cwd) {
969
990
  if (!containsPath(sourceRoot, requestedCwd.realpath)) {
970
991
  throw new Error("requested working directory is outside the resolved Git worktree");
971
992
  }
972
- if (lstatSync(join(sourceRoot, ".git")).isSymbolicLink()) {
973
- throw new Error("symlink Git directories are unsupported");
974
- }
975
- if (!lstatSync(join(sourceRoot, ".git")).isDirectory()) {
976
- throw new Error("linked worktree and submodule source repositories are unsupported");
977
- }
978
- const requestedGitDir = realpathSync(join(sourceRoot, ".git"));
993
+ const pointedGitDir = resolveGitDirPointer(sourceRoot);
979
994
  const gitDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-dir"])));
980
995
  const commonDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-common-dir"])));
981
- if (gitDir !== requestedGitDir || commonDir !== requestedGitDir) {
996
+ if (pointedGitDir !== gitDir) {
982
997
  throw new Error("resolved Git authority does not match the requested checkout");
983
998
  }
999
+ if (!containsPath(commonDir, gitDir)) {
1000
+ throw new Error("resolved Git directory is not inside its common directory");
1001
+ }
984
1002
  const branch = gitMaybe(sourceRoot, ["symbolic-ref", "-q", "--short", "HEAD"]);
985
1003
  if (!branch.ok || !branch.out)
986
1004
  throw new Error("detached integration targets are unsupported");
@@ -1002,6 +1020,48 @@ function inspectSourceRepository(cwd) {
1002
1020
  requestedRelativePath: relative(sourceRoot, requestedCwd.realpath),
1003
1021
  };
1004
1022
  }
1023
+ // Resolve the `.git` entry at a checkout root to the real Git directory it names,
1024
+ // without trusting the ambient environment. `.git` is a real directory in a plain
1025
+ // checkout, and a `gitdir:` pointer file in a linked worktree, submodule, or
1026
+ // worktree-of-a-submodule. A symlink is refused: the identity model pins a path to a
1027
+ // device and inode, and a symlink lets the target move under a pinned pointer.
1028
+ //
1029
+ // The caller cross-checks the result against `rev-parse --git-dir`. That is a
1030
+ // consistency assertion, not spoof detection: Git honours a `gitdir:` pointer, so a
1031
+ // pointer aimed at an unrelated repository agrees with `rev-parse` and is accepted.
1032
+ // It cannot be otherwise, because a doctored pointer and a submodule's pointer are
1033
+ // the same construct. What the equality buys is that this resolution and Git's own
1034
+ // never silently diverge, which is defence in depth over the `GIT_*` stripping in
1035
+ // `isolatedGitEnvironment`. The real authority check is containment plus
1036
+ // `allowed_paths` on the resolved common directory.
1037
+ function resolveGitDirPointer(sourceRoot) {
1038
+ const dotGit = join(sourceRoot, ".git");
1039
+ let stats;
1040
+ try {
1041
+ stats = lstatSync(dotGit);
1042
+ }
1043
+ catch {
1044
+ throw new Error("requested checkout has no .git entry");
1045
+ }
1046
+ if (stats.isSymbolicLink()) {
1047
+ throw new Error("symlink Git directories are unsupported");
1048
+ }
1049
+ if (stats.isDirectory()) {
1050
+ return realpathSync(dotGit);
1051
+ }
1052
+ if (!stats.isFile()) {
1053
+ throw new Error(".git must be a directory or a gitdir pointer file");
1054
+ }
1055
+ const pointer = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0]?.trim() ?? "";
1056
+ const prefix = "gitdir:";
1057
+ if (!pointer.startsWith(prefix)) {
1058
+ throw new Error(".git file is not a gitdir pointer");
1059
+ }
1060
+ const target = pointer.slice(prefix.length).trim();
1061
+ if (!target)
1062
+ throw new Error(".git gitdir pointer is empty");
1063
+ return realpathSync(resolve(sourceRoot, target));
1064
+ }
1005
1065
  function inspectFrozenSourceAuthority(sourceRoot, commonDir, targetRef) {
1006
1066
  const configured = validateConfiguredRoot(sourceRoot.configured);
1007
1067
  if (configured.realpath !== sourceRoot.realpath ||
@@ -1295,6 +1355,22 @@ function validFrozenFilesystemPath(path) {
1295
1355
  typeof path.identity.device === "string" &&
1296
1356
  typeof path.identity.inode === "string");
1297
1357
  }
1358
+ // Explain, in the caller's terms, why a checkout's Git authority falls outside a
1359
+ // writable root — naming the offending path so a submodule or linked-worktree user
1360
+ // who declared only the inner checkout knows exactly what to widen. Returns undefined
1361
+ // when both the source checkout and its common directory are inside the root.
1362
+ function describeAuthorityOutsideRoot(repo, rootRealpath) {
1363
+ if (!containsPath(rootRealpath, repo.commonDir.realpath)) {
1364
+ return (`Git keeps this checkout's administrative files in ${repo.commonDir.realpath}, ` +
1365
+ `which is outside the writable root ${rootRealpath}. Linked-worktree and submodule ` +
1366
+ `checkouts keep their Git authority in the enclosing repository, and allocating a ` +
1367
+ `worktree writes there; declare that repository (or a parent of it) as the writable root.`);
1368
+ }
1369
+ if (!containsPath(rootRealpath, repo.sourceRoot.realpath)) {
1370
+ return `the source checkout ${repo.sourceRoot.realpath} is outside the writable root ${rootRealpath}`;
1371
+ }
1372
+ return undefined;
1373
+ }
1298
1374
  function assertAllowedPathAuthority(allowedPaths, candidate, label) {
1299
1375
  if (allowedPaths.length > 0 &&
1300
1376
  !allowedPaths.some((allowedPath) => {
@@ -1383,10 +1459,15 @@ function capabilityDigestForClaim(claim) {
1383
1459
  function acquireRepositoryLease(coordRoot, claim) {
1384
1460
  const leaseDir = join(resolve(coordRoot), ".harnery", "workspaces", PROVIDER_ID, ".leases");
1385
1461
  mkdirSync(leaseDir, { recursive: true, mode: 0o700 });
1386
- const key = stableDigest({
1387
- common: claim.repository.common_dir.identity,
1388
- root: claim.writable_root.identity,
1389
- });
1462
+ // Mutual exclusion keys on the Git common directory alone. `git worktree add`,
1463
+ // `prune`, and the shared-`config` migration all write to the common directory's
1464
+ // administrative area, and once worktrees and submodules are allowed, several
1465
+ // distinct source checkouts (a superproject and its linked worktrees; a submodule
1466
+ // and a worktree of it) share one common directory under different — possibly
1467
+ // nested — writable roots. Including the writable root in the key would hand those
1468
+ // agents different locks and let their admin-area writes race. The writable root
1469
+ // stays in the lease metadata for diagnostics, not in the exclusion key.
1470
+ const key = stableDigest({ common: claim.repository.common_dir.identity });
1390
1471
  return acquireLease(coordRoot, join(leaseDir, `repository-${key}.lock`), claim, "repository");
1391
1472
  }
1392
1473
  function acquireLease(coordRoot, path, claim, scope) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import type { Command } from "commander";
2
3
  import type { EmitContext } from "../commander.ts";
3
4
  import { workflowSubscriptionOnly } from "../core/config.ts";
@@ -44,6 +45,7 @@ interface WorkRunOpts {
44
45
  allowApiBilling?: boolean;
45
46
  policy?: string;
46
47
  isolation?: PolicyIsolation;
48
+ workspaceRoot?: string;
47
49
  approvalTo?: string;
48
50
  actor?: string;
49
51
  json?: boolean;
@@ -178,6 +180,7 @@ function registerRunCommand(
178
180
  .option("--allow-api-billing", "Permit API-key override billing")
179
181
  .option("--policy <file>", "Host policy JSON/JSONC")
180
182
  .option("--isolation <mode>", "shared | worktree | sandbox | remote")
183
+ .option("--workspace-root <dir>", "Writable root the isolated workspace is allocated under")
181
184
  .option("--approval-to <address>", "Address durable ASK requests")
182
185
  .option("--actor <name>", "Actor recorded in attempt receipts")
183
186
  .option("--json", "Emit the workflow report or parked result as JSON")
@@ -186,7 +189,21 @@ function registerRunCommand(
186
189
  if (opts.harness && !registry.get(opts.harness)) {
187
190
  throw new Error(`unknown harness ${JSON.stringify(opts.harness)}`);
188
191
  }
192
+ if (opts.workspaceRoot && opts.isolation !== "worktree") {
193
+ throw new Error("--workspace-root requires --isolation worktree");
194
+ }
189
195
  try {
196
+ // An isolated attempt needs a provider AND the writable root it may
197
+ // allocate under; the engine refuses to guess either. Without them a
198
+ // worktree request degrades to shared, which the proof records as
199
+ // requested vs effective isolation and the reporting below surfaces.
200
+ const { createLocalGitWorktreeProvider } = await import("../core/workflow/index.ts");
201
+ const workspace = opts.workspaceRoot
202
+ ? {
203
+ provider: createLocalGitWorktreeProvider({ coordRoot }),
204
+ writableRoots: [resolve(opts.workspaceRoot)],
205
+ }
206
+ : undefined;
190
207
  const report = await runWorkItem({
191
208
  coordRoot,
192
209
  workId,
@@ -209,6 +226,7 @@ function registerRunCommand(
209
226
  approvalMode: "park",
210
227
  approvalAddressee: opts.approvalTo,
211
228
  isolation: opts.isolation,
229
+ workspace,
212
230
  networkAccess: "enabled",
213
231
  },
214
232
  });
@@ -216,8 +234,18 @@ function registerRunCommand(
216
234
  emit.config({ format: "json" });
217
235
  emit.data(report);
218
236
  } else {
237
+ // A requested isolation the run could not honour is recorded in the
238
+ // proof as requested vs effective, which nobody reads on a green run.
239
+ // Say it here instead, so a silent downgrade to shared cannot pass for
240
+ // an isolated attempt.
241
+ const degraded =
242
+ opts.isolation && opts.isolation !== "shared" && !report.workspaceBinding
243
+ ? `note: ran shared; ${opts.isolation} isolation was requested but not allocated` +
244
+ `${opts.workspaceRoot ? "" : " (no --workspace-root given)"}\n`
245
+ : "";
219
246
  emit.text(
220
- `work ${workId}: run ${report.runId} finished\nproof: ${report.proofPath}\n` +
247
+ `work ${workId}: run ${report.runId} finished\n${degraded}` +
248
+ `proof: ${report.proofPath}\n` +
221
249
  `next: harn work reconcile ${workId}\n`,
222
250
  );
223
251
  }
@@ -123,16 +123,26 @@ export async function probe(
123
123
  if (input.writable_roots.length === 0) {
124
124
  unsupported.push(reason("writable_roots_required", "explicit writable roots are required"));
125
125
  }
126
- for (const root of input.writable_roots) {
126
+ // Allocation always selects writable_roots[0] (validateRequest pins the selected root
127
+ // to it), so probe's authority-coverage verdict must be judged against that same root.
128
+ let selectedRoot: ValidatedRoot | undefined;
129
+ input.writable_roots.forEach((root, index) => {
127
130
  try {
128
- validateConfiguredRoot(root);
131
+ const validated = validateConfiguredRoot(root);
132
+ if (index === 0) selectedRoot = validated;
129
133
  } catch (error) {
130
134
  unsupported.push(reason("writable_root_invalid", (error as Error).message));
131
135
  }
132
- }
136
+ });
133
137
  if (version.ok) {
134
138
  try {
135
- inspectSourceRepository(input.requested_cwd);
139
+ const repo = inspectSourceRepository(input.requested_cwd);
140
+ if (selectedRoot) {
141
+ const outside = describeAuthorityOutsideRoot(repo, selectedRoot.realpath);
142
+ if (outside) {
143
+ unsupported.push(reason("repository_authority_outside_writable_root", outside));
144
+ }
145
+ }
136
146
  } catch (error) {
137
147
  unsupported.push(reason("repository_unsupported", (error as Error).message));
138
148
  }
@@ -209,12 +219,8 @@ async function allocate(
209
219
  repo.commonDir.realpath,
210
220
  "Git common directory",
211
221
  );
212
- if (
213
- !containsPath(root.realpath, repo.sourceRoot.realpath) ||
214
- !containsPath(root.realpath, repo.commonDir.realpath)
215
- ) {
216
- throw new Error("source repository and Git common dir must be inside the writable root");
217
- }
222
+ const authorityOutside = describeAuthorityOutsideRoot(repo, root.realpath);
223
+ if (authorityOutside) throw new Error(authorityOutside);
218
224
  const workspaceRoot = candidateUnderRoot(root, ["harnery-workspaces", bindingId]);
219
225
  const activeRoot = resolve(workspaceRoot, repo.requestedRelativePath);
220
226
  if (!containsPath(workspaceRoot, activeRoot)) {
@@ -1192,6 +1198,25 @@ async function applyIntegrationUnderLease(
1192
1198
  }
1193
1199
  revalidateClaimResources(claim, validateConfiguredRoot(plan.binding.writable_root.configured));
1194
1200
  inspectIntegrationTarget(current.target_root, plan.binding);
1201
+ // The fast-forward moves the target branch ref, which for a submodule or linked
1202
+ // worktree lives in the common directory outside the checkout tree. allowed_paths is
1203
+ // the real write authority (allocate and cleanup already gate the common directory on
1204
+ // it), so re-authorize both the target checkout and its common directory here rather
1205
+ // than trusting containment alone.
1206
+ const applyCommonDir = plan.binding.repository?.common_dir.realpath;
1207
+ if (!applyCommonDir) {
1208
+ throw new Error("integration binding is missing Git common-dir authority");
1209
+ }
1210
+ assertAllowedPathAuthority(
1211
+ claim.request.allowed_paths,
1212
+ current.target_root,
1213
+ "integration target path",
1214
+ );
1215
+ assertAllowedPathAuthority(
1216
+ claim.request.allowed_paths,
1217
+ applyCommonDir,
1218
+ "integration Git common directory",
1219
+ );
1195
1220
  git(current.target_root, ["merge", "--ff-only", expected.source_commit]);
1196
1221
  const finalCommit = git(current.target_root, ["rev-parse", "HEAD"]);
1197
1222
  const finalTree = git(current.target_root, ["rev-parse", "HEAD^{tree}"]);
@@ -1287,20 +1312,17 @@ function inspectSourceRepository(cwd: string): {
1287
1312
  if (!containsPath(sourceRoot, requestedCwd.realpath)) {
1288
1313
  throw new Error("requested working directory is outside the resolved Git worktree");
1289
1314
  }
1290
- if (lstatSync(join(sourceRoot, ".git")).isSymbolicLink()) {
1291
- throw new Error("symlink Git directories are unsupported");
1292
- }
1293
- if (!lstatSync(join(sourceRoot, ".git")).isDirectory()) {
1294
- throw new Error("linked worktree and submodule source repositories are unsupported");
1295
- }
1296
- const requestedGitDir = realpathSync(join(sourceRoot, ".git"));
1315
+ const pointedGitDir = resolveGitDirPointer(sourceRoot);
1297
1316
  const gitDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-dir"])));
1298
1317
  const commonDir = realpathSync(
1299
1318
  resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-common-dir"])),
1300
1319
  );
1301
- if (gitDir !== requestedGitDir || commonDir !== requestedGitDir) {
1320
+ if (pointedGitDir !== gitDir) {
1302
1321
  throw new Error("resolved Git authority does not match the requested checkout");
1303
1322
  }
1323
+ if (!containsPath(commonDir, gitDir)) {
1324
+ throw new Error("resolved Git directory is not inside its common directory");
1325
+ }
1304
1326
  const branch = gitMaybe(sourceRoot, ["symbolic-ref", "-q", "--short", "HEAD"]);
1305
1327
  if (!branch.ok || !branch.out) throw new Error("detached integration targets are unsupported");
1306
1328
  const targetRef = `refs/heads/${branch.out}`;
@@ -1321,6 +1343,47 @@ function inspectSourceRepository(cwd: string): {
1321
1343
  };
1322
1344
  }
1323
1345
 
1346
+ // Resolve the `.git` entry at a checkout root to the real Git directory it names,
1347
+ // without trusting the ambient environment. `.git` is a real directory in a plain
1348
+ // checkout, and a `gitdir:` pointer file in a linked worktree, submodule, or
1349
+ // worktree-of-a-submodule. A symlink is refused: the identity model pins a path to a
1350
+ // device and inode, and a symlink lets the target move under a pinned pointer.
1351
+ //
1352
+ // The caller cross-checks the result against `rev-parse --git-dir`. That is a
1353
+ // consistency assertion, not spoof detection: Git honours a `gitdir:` pointer, so a
1354
+ // pointer aimed at an unrelated repository agrees with `rev-parse` and is accepted.
1355
+ // It cannot be otherwise, because a doctored pointer and a submodule's pointer are
1356
+ // the same construct. What the equality buys is that this resolution and Git's own
1357
+ // never silently diverge, which is defence in depth over the `GIT_*` stripping in
1358
+ // `isolatedGitEnvironment`. The real authority check is containment plus
1359
+ // `allowed_paths` on the resolved common directory.
1360
+ function resolveGitDirPointer(sourceRoot: string): string {
1361
+ const dotGit = join(sourceRoot, ".git");
1362
+ let stats: ReturnType<typeof lstatSync>;
1363
+ try {
1364
+ stats = lstatSync(dotGit);
1365
+ } catch {
1366
+ throw new Error("requested checkout has no .git entry");
1367
+ }
1368
+ if (stats.isSymbolicLink()) {
1369
+ throw new Error("symlink Git directories are unsupported");
1370
+ }
1371
+ if (stats.isDirectory()) {
1372
+ return realpathSync(dotGit);
1373
+ }
1374
+ if (!stats.isFile()) {
1375
+ throw new Error(".git must be a directory or a gitdir pointer file");
1376
+ }
1377
+ const pointer = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0]?.trim() ?? "";
1378
+ const prefix = "gitdir:";
1379
+ if (!pointer.startsWith(prefix)) {
1380
+ throw new Error(".git file is not a gitdir pointer");
1381
+ }
1382
+ const target = pointer.slice(prefix.length).trim();
1383
+ if (!target) throw new Error(".git gitdir pointer is empty");
1384
+ return realpathSync(resolve(sourceRoot, target));
1385
+ }
1386
+
1324
1387
  function inspectFrozenSourceAuthority(
1325
1388
  sourceRoot: GitRepositoryBinding["source_root"],
1326
1389
  commonDir: GitRepositoryBinding["common_dir"],
@@ -1680,6 +1743,28 @@ function validFrozenFilesystemPath(
1680
1743
  );
1681
1744
  }
1682
1745
 
1746
+ // Explain, in the caller's terms, why a checkout's Git authority falls outside a
1747
+ // writable root — naming the offending path so a submodule or linked-worktree user
1748
+ // who declared only the inner checkout knows exactly what to widen. Returns undefined
1749
+ // when both the source checkout and its common directory are inside the root.
1750
+ function describeAuthorityOutsideRoot(
1751
+ repo: ReturnType<typeof inspectSourceRepository>,
1752
+ rootRealpath: string,
1753
+ ): string | undefined {
1754
+ if (!containsPath(rootRealpath, repo.commonDir.realpath)) {
1755
+ return (
1756
+ `Git keeps this checkout's administrative files in ${repo.commonDir.realpath}, ` +
1757
+ `which is outside the writable root ${rootRealpath}. Linked-worktree and submodule ` +
1758
+ `checkouts keep their Git authority in the enclosing repository, and allocating a ` +
1759
+ `worktree writes there; declare that repository (or a parent of it) as the writable root.`
1760
+ );
1761
+ }
1762
+ if (!containsPath(rootRealpath, repo.sourceRoot.realpath)) {
1763
+ return `the source checkout ${repo.sourceRoot.realpath} is outside the writable root ${rootRealpath}`;
1764
+ }
1765
+ return undefined;
1766
+ }
1767
+
1683
1768
  function assertAllowedPathAuthority(
1684
1769
  allowedPaths: WorkspaceAllocationRequest["allowed_paths"],
1685
1770
  candidate: string,
@@ -1783,10 +1868,15 @@ function capabilityDigestForClaim(claim: WorkspaceClaim): string {
1783
1868
  function acquireRepositoryLease(coordRoot: string, claim: WorkspaceClaim): () => void {
1784
1869
  const leaseDir = join(resolve(coordRoot), ".harnery", "workspaces", PROVIDER_ID, ".leases");
1785
1870
  mkdirSync(leaseDir, { recursive: true, mode: 0o700 });
1786
- const key = stableDigest({
1787
- common: claim.repository.common_dir.identity,
1788
- root: claim.writable_root.identity,
1789
- });
1871
+ // Mutual exclusion keys on the Git common directory alone. `git worktree add`,
1872
+ // `prune`, and the shared-`config` migration all write to the common directory's
1873
+ // administrative area, and once worktrees and submodules are allowed, several
1874
+ // distinct source checkouts (a superproject and its linked worktrees; a submodule
1875
+ // and a worktree of it) share one common directory under different — possibly
1876
+ // nested — writable roots. Including the writable root in the key would hand those
1877
+ // agents different locks and let their admin-area writes race. The writable root
1878
+ // stays in the lease metadata for diagnostics, not in the exclusion key.
1879
+ const key = stableDigest({ common: claim.repository.common_dir.identity });
1790
1880
  return acquireLease(coordRoot, join(leaseDir, `repository-${key}.lock`), claim, "repository");
1791
1881
  }
1792
1882