@bridge4dev/runner 0.61.0 → 0.63.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/git.js CHANGED
@@ -94,6 +94,44 @@ export class WorktreePrepareError extends Error {
94
94
  this.baseSha = base.baseSha;
95
95
  }
96
96
  }
97
+ /**
98
+ * A repository with no commits cannot hand out a copy of itself (#137).
99
+ *
100
+ * The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
101
+ * package cannot import — it is published to npm on its own, and the import
102
+ * would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
103
+ * Same arrangement as the level-event constants and the session limits: the
104
+ * text lives twice and a test pins the two copies together, because the API
105
+ * refuses this case before a session exists and the runner refuses it again if
106
+ * one ever gets that far — and the person must read one sentence, not two.
107
+ *
108
+ * `git.test.ts` compares this against the shared copy byte for byte.
109
+ */
110
+ // #region no-commits-yet-mirror
111
+ export const NO_COMMITS_YET_MESSAGE = 'This repository has no commits yet — start a session in the project folder itself; a branch of its own becomes possible after the first commit';
112
+ export async function headState(workspacePath) {
113
+ const [symbolic, commit] = await Promise.all([
114
+ git(workspacePath, 'symbolic-ref', '--short', 'HEAD').catch(() => null),
115
+ // `--verify --quiet` is the whole point: an unborn HEAD is exit 1 and
116
+ // SILENCE here, where the plain form is exit 128 and three lines of git.
117
+ //
118
+ // And the exit code is read rather than caught away, the same way the
119
+ // branch probe in `looksHalfCreated` reads it and for the same reason:
120
+ // exit 1 is «there is no commit», while 128, a signal or a
121
+ // dubious-ownership fatal is git failing to LOOK. `unborn` authorises
122
+ // real things — it is what offers the project folder for work and what
123
+ // makes a restore point start from the empty tree — so «could not look»
124
+ // must never arrive here dressed as «no commits yet».
125
+ git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => ({ sha, absent: false }), (error) => ({
126
+ sha: null,
127
+ absent: error.code === 1,
128
+ })),
129
+ ]);
130
+ const branch = symbolic ? symbolic.trim() : '';
131
+ if (!branch)
132
+ return { branch: null, unborn: false, detached: Boolean(commit.sha) };
133
+ return { branch, unborn: commit.absent, detached: false };
134
+ }
97
135
  /**
98
136
  * Can this runner actually work in this directory — as the user it runs as?
99
137
  *
@@ -190,17 +228,27 @@ export async function validateWorkspacePath(workspacePath) {
190
228
  `Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
191
229
  };
192
230
  }
193
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
231
+ // #137: read through `headState`, not `rev-parse --abbrev-ref HEAD`. The old
232
+ // call was the LAST thing this function did and the only one of six branch
233
+ // reads in this file left uncaught — so a directory with `git init` and no
234
+ // commit in it failed binding with git's manual page forwarded to a browser,
235
+ // after every real check above had already passed.
236
+ const head = await headState(workspacePath);
194
237
  // Binding is the one moment we are guaranteed to be looking at this repository
195
238
  // with somebody waiting for the answer, so it is where the main branch is
196
239
  // learned (ADR 0004). `branch` above is «what the folder is on right now» — a
197
240
  // drifting value, and the reason #361 exists; these two must not be confused.
241
+ //
242
+ // On a repository with no commits there is no branch to learn: nothing has
243
+ // been pushed anywhere and no conventional branch exists yet, so the binding's
244
+ // `main_branch` stays «not known yet» and learns itself later (ADR 0004).
198
245
  const defaultBranch = await remoteDefaultBranch(workspacePath);
199
246
  return {
200
247
  ok: true,
201
248
  exists: true,
202
249
  isGitRepo: true,
203
- branch,
250
+ ...(head.branch ? { branch: head.branch } : {}),
251
+ ...(head.unborn ? { unborn: true } : {}),
204
252
  ...(defaultBranch ? { defaultBranch } : {}),
205
253
  };
206
254
  }
@@ -308,6 +356,18 @@ export function sanitizeBranch(hint) {
308
356
  * in the workspace repo, so the work survives worktree cleanup.
309
357
  */
310
358
  export async function ensureSessionWorktree(workspacePath, sessionId, branchHint, options = {}) {
359
+ // #137: a repository with no commits cannot have a copy of itself, and this
360
+ // is the only honest place to say so. Git refuses both halves of the
361
+ // arrangement — `worktree add -b x main|HEAD` is «invalid reference», and
362
+ // `merge --squash` into an unborn HEAD is «Squash commit into empty head not
363
+ // supported yet» — so a session started this way would be created, would
364
+ // fail here in git's own words, and, had it survived, could never be applied.
365
+ // The plan's decision (S3a) is to refuse early rather than to make the copy
366
+ // work: nobody makes the person's first commit for them.
367
+ const head = await headState(workspacePath);
368
+ if (head.unborn) {
369
+ throw new Error(NO_COMMITS_YET_MESSAGE);
370
+ }
311
371
  const short = sessionShortId(sessionId);
312
372
  // A ticket group gets a branch named after its tickets so the work reads as
313
373
  // one thing in git history. The worktree DIRECTORY stays id-derived — it is
@@ -743,16 +803,27 @@ export async function prepareDirectWorkspace(workspacePath) {
743
803
  if (inside !== 'true') {
744
804
  throw new Error(`${workspacePath} is not a git work tree`);
745
805
  }
746
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
747
- if (!branch || branch === 'HEAD') {
806
+ // #137: `rev-parse --abbrev-ref HEAD` answered the literal word `HEAD` for a
807
+ // detached checkout AND threw for a repository with no commits, so caught it
808
+ // collapsed both into the detached refusal below — and a folder sitting
809
+ // perfectly well on `main`, one `git init` old, was told to «check a branch
810
+ // out there», which it already had. `headState` tells the two apart.
811
+ const head = await headState(workspacePath);
812
+ if (!head.branch) {
748
813
  throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
749
814
  }
750
815
  const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
751
- const baseSha = await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
816
+ // No commits yet means no fork point, and that is not a failure: DIRECT mode
817
+ // forks from nothing anyway — the session's workplace IS this folder and this
818
+ // branch. The field simply stays absent, which is what it already did for
819
+ // every other reason a base sha could be missing.
820
+ const baseSha = head.unborn
821
+ ? undefined
822
+ : await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
752
823
  return {
753
- branch,
824
+ branch: head.branch,
754
825
  worktreePath: top || workspacePath,
755
- baseBranch: branch,
826
+ baseBranch: head.branch,
756
827
  ...(baseSha ? { baseSha } : {}),
757
828
  };
758
829
  }
package/dist/gitops.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * git's own name for «nothing», and the only thing a repository without a
3
+ * single commit can be diffed against. Constant in every git repository ever
4
+ * made — it is the SHA-1 of the empty tree object.
5
+ */
6
+ export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1
7
  export interface GitFileEntry {
2
8
  path: string;
3
9
  /** A | M | D | R (git name-status letter). */
@@ -324,6 +330,22 @@ export interface GitBranchesResult {
324
330
  branches: GitBranchEntry[];
325
331
  /** What the project folder is on right now. */
326
332
  currentBranch: string | null;
333
+ /**
334
+ * #137: `currentBranch` above is a branch with no commit on it yet, so it is
335
+ * in no list here — `for-each-ref refs/heads/` has nothing to list until the
336
+ * first commit exists. Without this field the API sees a branch name that is
337
+ * absent from the branches and refuses the session as «Branch main does not
338
+ * exist in this project»; with it, the folder is offered and a copy of its
339
+ * own is refused early and in words.
340
+ */
341
+ unborn: boolean;
342
+ /**
343
+ * #137: HEAD is a commit rather than a branch. `currentBranch` is null for
344
+ * this AND for «the runner could not read the folder», and the wizard has to
345
+ * tell them apart: one is fixed by checking a branch out, the other is not
346
+ * the person's to fix at all.
347
+ */
348
+ detached: boolean;
327
349
  currentSha: string | null;
328
350
  /**
329
351
  * The repository's own main branch (ADR 0004) — not the same question as
@@ -537,6 +559,13 @@ export declare function gitRefs(workspacePath: string): Promise<GitRefsResult>;
537
559
  export interface WorkspaceStateResult {
538
560
  /** The branch the project folder is on; null when HEAD is detached. */
539
561
  branch: string | null;
562
+ /**
563
+ * #137: that branch has no commit on it yet. `branch` is still its name and
564
+ * the folder is still perfectly workable — this is what stops every reader
565
+ * downstream from turning «no sha» into «detached HEAD», which is what the
566
+ * run's precondition and the session feed were both doing.
567
+ */
568
+ unborn: boolean;
540
569
  sha: string | null;
541
570
  subject: string;
542
571
  /** Committer date of that commit, ISO-8601. */
package/dist/gitops.js CHANGED
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { firstConventionalBranch, remoteDefaultBranch, sanitizeBranch } from './git.js';
5
+ import { firstConventionalBranch, headState, remoteDefaultBranch, sanitizeBranch } from './git.js';
6
6
  import { isSecretPath, maskString } from './policy.js';
7
7
  const execFileAsync = promisify(execFile);
8
8
  const GIT_TIMEOUT_MS = 30_000;
@@ -13,7 +13,7 @@ const DIFF_CAP_BYTES = 200_000;
13
13
  * single commit can be diffed against. Constant in every git repository ever
14
14
  * made — it is the SHA-1 of the empty tree object.
15
15
  */
16
- const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
16
+ export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
17
17
  /**
18
18
  * Global git switches every call in this module needs.
19
19
  *
@@ -246,8 +246,21 @@ async function resolveBase(workspacePath, sessionBranch, pinnedBase) {
246
246
  return { ...(await guessBase(workspacePath, sessionBranch)), pinned: false, baseMissing: false };
247
247
  }
248
248
  async function guessBase(workspacePath, sessionBranch) {
249
- const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
250
- if (current !== sessionBranch && current !== 'HEAD') {
249
+ // #137: this read had no `.catch` at all, so on a repository with no commits
250
+ // git's own manual page («ambiguous argument 'HEAD'») travelled out of the
251
+ // runner, through the API and into a 409 toast. Every refusal that can be
252
+ // reached from a screen leaves here as a sentence somebody can act on.
253
+ const head = await headState(workspacePath);
254
+ // #137: NOT the branch-picker's sentence. That one says «start a session in
255
+ // the project folder itself», which is advice for somebody choosing where a
256
+ // session will work — and whoever reads THIS is already inside one, looking
257
+ // at a file list or a history tab. True but unactionable is the exact shape
258
+ // this ticket is about, so each place says the thing its own reader can do.
259
+ if (head.unborn) {
260
+ throw new Error('This repository has no commits yet, so there is nothing to compare this session against. Make the first commit and it will start working.');
261
+ }
262
+ const current = head.branch;
263
+ if (current && current !== sessionBranch) {
251
264
  return { baseBranch: current, baseRef: current };
252
265
  }
253
266
  // One list, one order, one place — the same helper the binding uses to learn
@@ -255,7 +268,9 @@ async function guessBase(workspacePath, sessionBranch) {
255
268
  const conventional = await firstConventionalBranch(workspacePath);
256
269
  if (conventional)
257
270
  return { baseBranch: conventional, baseRef: conventional };
258
- throw new Error(`Cannot determine the base branch (workspace is on ${current})`);
271
+ throw new Error(current
272
+ ? `Cannot determine the base branch (the project folder is on ${current}, the session's own branch)`
273
+ : 'Cannot determine the base branch: the project folder is not on a branch (a detached HEAD) — check one out there');
259
274
  }
260
275
  /** How far apart two refs are, in one call: `[behind, ahead]`. */
261
276
  async function aheadBehind(cwd, baseRef, branchRef) {
@@ -1136,7 +1151,27 @@ async function applySessionImpl(input) {
1136
1151
  error: WORKSPACE_DIRTY_MESSAGE,
1137
1152
  };
1138
1153
  }
1139
- const workspaceBranch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
1154
+ // #137: read through `headState`. The bare call threw git's manual page into
1155
+ // the «Apply» toast on a repository with no commits, and answered the literal
1156
+ // word `HEAD` on a detached one — a value that then went on to be compared
1157
+ // with a branch name and merged into.
1158
+ const workspaceHead = await headState(workspacePath);
1159
+ if (workspaceHead.unborn) {
1160
+ // Its own sentence, for the same reason as `guessBase` above: whoever
1161
+ // pressed «Apply» is not choosing where to work, and telling them to start
1162
+ // a session in the project folder answers a question they did not ask.
1163
+ return {
1164
+ applied: false,
1165
+ error: 'The project folder has no commits yet, and git cannot fold work into a branch that has none. Make the first commit there, then apply.',
1166
+ };
1167
+ }
1168
+ const workspaceBranch = workspaceHead.branch;
1169
+ if (!workspaceBranch) {
1170
+ return {
1171
+ applied: false,
1172
+ error: 'The project folder is not on a branch (a detached HEAD), so there is nowhere to apply this to — check a branch out there first',
1173
+ };
1174
+ }
1140
1175
  if (workspaceBranch === sessionBranch) {
1141
1176
  return { applied: false, error: 'The workspace is checked out on the session branch itself' };
1142
1177
  }
@@ -1156,6 +1191,7 @@ async function applySessionImpl(input) {
1156
1191
  await git(worktreePath, 'commit', '-m', 'chore(devbridge): session changes before apply');
1157
1192
  }
1158
1193
  const branchSha = await git(workspacePath, 'rev-parse', sessionBranch);
1194
+ // Safe unconditionally now: an unborn HEAD left through the refusal above.
1159
1195
  const baseShaBefore = await git(workspacePath, 'rev-parse', 'HEAD');
1160
1196
  // Captured BEFORE anything is written, so a failed merge can tell its own
1161
1197
  // leftovers from somebody's work that appeared meanwhile.
@@ -1339,10 +1375,15 @@ const MAX_BRANCHES = 200;
1339
1375
  * not after the worktree command fails.
1340
1376
  */
1341
1377
  export async function gitBranches(workspacePath) {
1342
- const [rawBranches, rawWorktrees, currentBranch, remotesRaw] = await Promise.all([
1378
+ const [rawBranches, rawWorktrees, head, remotesRaw] = await Promise.all([
1343
1379
  git(workspacePath, 'for-each-ref', `--count=${MAX_BRANCHES + 1}`, '--sort=-committerdate', '--format=%(refname:short)%00%(objectname)%00%(committerdate:iso-strict)%00%(subject)', 'refs/heads/'),
1344
1380
  git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
1345
- git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
1381
+ // #137: through `headState`, not `rev-parse --abbrev-ref HEAD`. Caught, that
1382
+ // call answered `null` for a folder one `git init` old and the literal word
1383
+ // `HEAD` for a detached one — so the wizard could not tell a repository
1384
+ // waiting for its first commit from a checkout nobody should commit into,
1385
+ // and greyed out «In the project folder» for both.
1386
+ headState(workspacePath),
1346
1387
  git(workspacePath, 'remote').catch(() => ''),
1347
1388
  ]);
1348
1389
  // `worktree list --porcelain` emits stanzas: `worktree <path>` … `branch <ref>`.
@@ -1380,8 +1421,12 @@ export async function gitBranches(workspacePath) {
1380
1421
  branch.merged = mergedSet.has(branch.name);
1381
1422
  return {
1382
1423
  branches,
1383
- currentBranch: currentBranch === 'HEAD' ? null : currentBranch,
1384
- currentSha: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
1424
+ currentBranch: head.branch,
1425
+ unborn: head.unborn,
1426
+ detached: head.detached,
1427
+ currentSha: head.unborn
1428
+ ? null
1429
+ : await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
1385
1430
  defaultBranch: await remoteDefaultBranch(workspacePath).catch(() => null),
1386
1431
  remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
1387
1432
  truncated,
@@ -1886,11 +1931,16 @@ const MAX_REFS = 500;
1886
1931
  * short name.
1887
1932
  */
1888
1933
  export async function gitRefs(workspacePath) {
1889
- const [raw, rawWorktrees, currentBranch, currentSha, remotesRaw] = await Promise.all([
1934
+ const [raw, rawWorktrees, head, currentSha, remotesRaw] = await Promise.all([
1890
1935
  git(workspacePath, 'for-each-ref', `--count=${MAX_REFS + 1}`, '--sort=-committerdate', '--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)%00%(upstream:short)%00%(subject)', 'refs/heads', 'refs/remotes', 'refs/tags'),
1891
1936
  git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
1892
- git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
1893
- git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
1937
+ // #137: the fifth reader of the same question, and the one left behind by
1938
+ // the first pass. Caught, so no raw git escapes — but it answered «no
1939
+ // branch» for a folder the wizard, the run's precondition and the session
1940
+ // feed all report as standing on `main`, and one screen disagreeing with
1941
+ // three about the same folder is how this class of bug starts again.
1942
+ headState(workspacePath),
1943
+ git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
1894
1944
  git(workspacePath, 'remote').catch(() => ''),
1895
1945
  ]);
1896
1946
  const checkedOut = new Map();
@@ -1925,7 +1975,7 @@ export async function gitRefs(workspacePath) {
1925
1975
  refs.push({
1926
1976
  name: name.slice(0, 200),
1927
1977
  type,
1928
- isHead: type === 'head' && name === currentBranch,
1978
+ isHead: type === 'head' && name === head.branch,
1929
1979
  sha,
1930
1980
  subject: maskString(subjectParts.join('\0')).slice(0, 300),
1931
1981
  date: (date ?? '').slice(0, 40),
@@ -1935,7 +1985,7 @@ export async function gitRefs(workspacePath) {
1935
1985
  }
1936
1986
  return {
1937
1987
  refs,
1938
- currentBranch: currentBranch === 'HEAD' ? null : currentBranch,
1988
+ currentBranch: head.branch,
1939
1989
  currentSha,
1940
1990
  remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
1941
1991
  truncated,
@@ -1949,21 +1999,26 @@ export async function gitRefs(workspacePath) {
1949
1999
  * question behind «how do I know docker was not rebuilt». Read-only.
1950
2000
  */
1951
2001
  export async function workspaceState(workspacePath) {
1952
- const [branchRaw, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
1953
- git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
1954
- git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
2002
+ const [head, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
2003
+ // #137: `rev-parse --abbrev-ref HEAD` could not tell «no commits yet» from
2004
+ // «detached HEAD» — both arrived here as `null` — and this value is what the
2005
+ // run's precondition reads, so a fresh `git init` was refused with a
2006
+ // sentence about checking a branch out that it could not act on.
2007
+ headState(workspacePath),
2008
+ git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
1955
2009
  git(workspacePath, 'status', '--porcelain').catch(() => ''),
1956
2010
  git(workspacePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null),
1957
2011
  git(workspacePath, 'remote').catch(() => ''),
1958
2012
  git(workspacePath, 'log', '-1', '--format=%s%x00%cI', 'HEAD', '--').catch(() => ''),
1959
2013
  ]);
1960
- const branch = branchRaw === 'HEAD' ? null : branchRaw;
2014
+ const branch = head.branch;
1961
2015
  const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
1962
2016
  const distance = upstream ? await aheadBehind(workspacePath, upstream, 'HEAD') : null;
1963
2017
  const [subject, date] = meta.split('\0');
1964
2018
  const dirtyLines = porcelain.split('\n').filter(Boolean);
1965
2019
  return {
1966
2020
  branch,
2021
+ unborn: head.unborn,
1967
2022
  sha,
1968
2023
  subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
1969
2024
  date: (date ?? '').slice(0, 40),
package/dist/index.js CHANGED
@@ -381,6 +381,31 @@ function runnerCapabilities(apiUrlOverride) {
381
381
  * been handed a worse problem.
382
382
  */
383
383
  agentGitPolicy: true,
384
+ /**
385
+ * #418: «the agent may work outside the project folder» is a setting of the
386
+ * project, and this runner honours it.
387
+ *
388
+ * Its own flag rather than a second meaning for `agentGitPolicy`: the two
389
+ * ship a release apart, and a server on 0.61.x announces the git policy
390
+ * perfectly honestly while ignoring this field completely. Announced so the
391
+ * settings card can say so — warn, not refuse, for the same reason as the
392
+ * git policy: a manager must be able to record the decision before the last
393
+ * machine in the fleet is up to date.
394
+ */
395
+ agentOutsideFolder: true,
396
+ /**
397
+ * #137: this runner can read a repository that has no commits yet.
398
+ *
399
+ * Announced for one reason, and it is a message rather than a refusal: when
400
+ * an OLDER runner meets that repository it answers with git's own manual
401
+ * page, and the API turns anything it cannot parse into «run `git status`
402
+ * on that server and see». For this particular case that advice is a dead
403
+ * end — `git status` there prints «On branch main / No commits yet», which
404
+ * looks perfectly healthy, and the person is none the wiser. With this flag
405
+ * the API can say the true thing instead: the runner on that machine is too
406
+ * old for this, update it.
407
+ */
408
+ unbornHead: true,
384
409
  /**
385
410
  * Session 16: git is git.
386
411
  *
package/dist/policy.d.ts CHANGED
@@ -40,6 +40,33 @@ export interface AgentGitPolicy {
40
40
  agentAllowForcePush?: boolean;
41
41
  /** `git reset --hard` and `git clean`. `undefined` and `false` mean no. */
42
42
  agentAllowDestructiveGit?: boolean;
43
+ /**
44
+ * #418: may the agent read and write OUTSIDE the folder this session works
45
+ * in — other projects on the same machine included?
46
+ *
47
+ * READ AS `=== true`. `undefined` means an API too old to send the field, or
48
+ * a value the protocol schema threw away, and there the answer must be what
49
+ * it has always been: confined to the folder. The same direction as
50
+ * `agentAllowForcePush` two fields up, the opposite of `agentPushBan` at the
51
+ * top — one names a permission, the other names a ban, and silence refuses
52
+ * in both readings (gotcha 193).
53
+ *
54
+ * **Not a git setting, and it lives in a git-named object on purpose.** The
55
+ * whole delivery pipe for the project's per-agent policy already exists —
56
+ * descriptor → `gitPolicyOf` → `SessionSpec` → `policyContextFor` →
57
+ * `PolicyContext`, with `workspace_settings` for a live change — and it moves
58
+ * this object WHOLE at every hop. A field of its own beside it would have
59
+ * been a second branch in the supervisor, a second one in the live frame and
60
+ * a second chance to forget one of them (plan R8). The name is the price;
61
+ * this comment is the receipt.
62
+ *
63
+ * What it does NOT lift: `isSecretPath`, `isGitInternalPath`,
64
+ * `ctx.agentPromptFile`, `DENIED_COMMAND_PATTERNS`, `SECRET_COMMAND_PATTERNS`,
65
+ * the git policy above, the auto-commit refusal and the heap ceiling. It
66
+ * lifts exactly one rule — «this path is not inside the folder» — and leaves
67
+ * every other reason to refuse standing.
68
+ */
69
+ agentAllowOutsideFolder?: boolean;
43
70
  }
44
71
  export interface PolicyContext extends AgentGitPolicy {
45
72
  trustMode: TrustMode;
package/dist/policy.js CHANGED
@@ -11,6 +11,8 @@ function resolveGitPolicy(ctx) {
11
11
  protectedBranches: ctx.agentProtectedBranches ?? DEFAULT_PROTECTED_BRANCHES,
12
12
  allowForcePush: ctx.agentAllowForcePush === true,
13
13
  allowDestructiveGit: ctx.agentAllowDestructiveGit === true,
14
+ // `=== true`: silence keeps the agent in its folder (#418).
15
+ allowOutsideFolder: ctx.agentAllowOutsideFolder === true,
14
16
  };
15
17
  }
16
18
  // ─── Secret masking (plan §8.7) ──────────────────────────────────────
@@ -1309,6 +1311,18 @@ export function evaluateToolUse(toolName, input, ctx) {
1309
1311
  if (READ_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName)) {
1310
1312
  const rawPath = String(input['file_path'] ?? input['path'] ?? input['notebook_path'] ?? '');
1311
1313
  const resolved = rawPath ? normalize(rawPath, ctx.worktreePath) : ctx.worktreePath;
1314
+ /**
1315
+ * #418: this project answered «yes» to «may the agent work outside the
1316
+ * project folder», so «not inside the folder» stops being a reason on its
1317
+ * own. Resolved through `resolveGitPolicy` rather than read off `ctx` here,
1318
+ * because that function is the one place in this file where an absent field
1319
+ * is given its safe meaning — and «unknown» here has to mean «stay in».
1320
+ *
1321
+ * It moves exactly one rule out of the way. Everything checked above and
1322
+ * below this line — secret paths, `.git` internals, the session's own
1323
+ * prompt file — is checked in the same order and refuses the same things.
1324
+ */
1325
+ const outsideAllowed = resolveGitPolicy(ctx).allowOutsideFolder;
1312
1326
  if (isSecretPath(resolved)) {
1313
1327
  return { decision: 'deny', reason: 'protected secret path' };
1314
1328
  }
@@ -1328,7 +1342,9 @@ export function evaluateToolUse(toolName, input, ctx) {
1328
1342
  reason: 'writing inside .git is not allowed — a hook or a config entry is code git runs on its own, past every rule here',
1329
1343
  };
1330
1344
  }
1331
- if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
1345
+ if (WRITE_TOOLS.has(toolName) &&
1346
+ !outsideAllowed &&
1347
+ !isInsideWorktree(resolved, ctx.worktreePath)) {
1332
1348
  return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
1333
1349
  }
1334
1350
  // The project's own prompt file — the same rule as `.git` above, for the
@@ -1344,10 +1360,27 @@ export function evaluateToolUse(toolName, input, ctx) {
1344
1360
  }
1345
1361
  if (trust === 'STRICT')
1346
1362
  return { decision: 'ask', reason: 'strict mode' };
1347
- if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
1348
- return trust === 'AUTO'
1349
- ? { decision: 'allow', reason: 'auto mode' }
1350
- : { decision: 'ask', reason: 'read outside the worktree' };
1363
+ if (!isInsideWorktree(resolved, ctx.worktreePath)) {
1364
+ /**
1365
+ * #418. Below STRICT — which asked one line up and goes on asking, which
1366
+ * is the whole of «Strict still asks» — a path outside the folder is now
1367
+ * an ORDINARY path for a project that allowed it: no card, no refusal,
1368
+ * and the same answer for a read and for a write.
1369
+ *
1370
+ * Its own reason string rather than falling through to «inside worktree»
1371
+ * below: that sentence goes to the log, and about a file in another
1372
+ * folder it would simply be false.
1373
+ */
1374
+ if (outsideAllowed) {
1375
+ return { decision: 'allow', reason: 'outside the project folder, allowed by this project' };
1376
+ }
1377
+ // Reads only: a write outside was refused above unless the project
1378
+ // allowed it, so nothing else reaches this line.
1379
+ if (READ_TOOLS.has(toolName)) {
1380
+ return trust === 'AUTO'
1381
+ ? { decision: 'allow', reason: 'auto mode' }
1382
+ : { decision: 'ask', reason: 'read outside the worktree' };
1383
+ }
1351
1384
  }
1352
1385
  return { decision: 'allow', reason: 'inside worktree' };
1353
1386
  }