@bridge4dev/runner 0.61.0 → 0.62.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/README.md CHANGED
@@ -49,12 +49,18 @@ it. If you would rather run it in the foreground, or under your own supervisor,
49
49
 
50
50
  ## What it does on your machine
51
51
 
52
- - Creates a **git worktree and a branch per session**, so an agent never works in your
53
- working copy and never touches your current branch.
54
- - Enforces a local policy layer the dashboard **cannot** raise: an allow/deny list for
55
- commands, secret masking in everything it streams, and its own ceiling on how many
56
- sessions may run at once. Anything the server asks for is bounded by what this config
57
- permits.
52
+ - Runs a session either **in the project folder itself** — the default: the branch it is
53
+ already on, nothing to apply afterwards — or in a **git worktree on a session branch of
54
+ its own**, which DevBridge asks for when the folder is busy or a branch was named. Which
55
+ one a session gets is decided per session, in the dashboard.
56
+ - Enforces a local policy layer. Part of it the dashboard **cannot** raise: secret files and
57
+ keys are never read, nothing is written into any repository's `.git`, and `sudo`, service
58
+ control, docker and the firewall are refused in every trust level. Part of it is the bound
59
+ project's own setting, chosen by a manager in DevBridge: whether the agent may `git push`
60
+ and to which branches, and whether it may read and change files **outside the project
61
+ folder** — other projects on the same machine included. Out of the box the agent pushes
62
+ nothing and stays in its folder. Plus secret masking in everything it streams, and its own
63
+ ceiling on how many sessions may run at once.
58
64
  - Streams the session journal with sequence numbers and acknowledgements, so a restart of
59
65
  either side resumes instead of losing work.
60
66
  - Never uploads your source. Diffs and file views are requested per file, and files
@@ -8,7 +8,7 @@ import { mcpConfigPath } from '../paths.js';
8
8
  import { lowerPriority } from '../process-priority.js';
9
9
  import { cageSpawn, noteSessionAgentPid, memoryDeathSentence, releaseSessionScope, sessionMemoryEnv, sessionMemoryPromptLine, } from '../session-cage.js';
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
- import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
11
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
13
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
14
  import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
@@ -166,14 +166,19 @@ const MODE_TO_PERMISSION = {
166
166
  * no line: the agent plans around a restriction that is not there, or walks
167
167
  * into one it was told did not exist.
168
168
  */
169
- function systemAppendFor(spec) {
169
+ function systemAppendFor(spec, mode) {
170
170
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
171
+ // #418: which folder line this session gets, if any — see `folderRuleFor`.
172
+ const folderRule = folderRuleFor(spec, mode);
171
173
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
172
174
  const memoryLine = sessionMemoryPromptLine(spec.sessionId);
173
175
  return [
174
176
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
175
177
  'Rules:',
176
- '- Work ONLY inside the current working directory.',
178
+ // #418. The three cases live in `folderRuleFor` — one copy for both
179
+ // adapters, and the Codex adapter asks the same function whether a mode
180
+ // switch would change what the agent was told.
181
+ ...(folderRule === null ? [] : [folderRule]),
177
182
  // Session 13: pushing was refused outright by layer 1 in every trust mode,
178
183
  // and saying so here saved the agent a turn spent discovering it. Session
179
184
  // 18 makes it the project's decision — so the sentence has to follow the
@@ -203,8 +208,8 @@ function systemAppendFor(spec) {
203
208
  ].join('\n');
204
209
  }
205
210
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
206
- function composeSystemAppend(spec) {
207
- const base = systemAppendFor(spec);
211
+ function composeSystemAppend(spec, mode) {
212
+ const base = systemAppendFor(spec, mode);
208
213
  return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
209
214
  }
210
215
  /**
@@ -585,7 +590,7 @@ class ClaudeSession {
585
590
  systemPrompt: {
586
591
  type: 'preset',
587
592
  preset: 'claude_code',
588
- append: composeSystemAppend(spec),
593
+ append: composeSystemAppend(spec, this.mode),
589
594
  },
590
595
  canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
591
596
  // Ticket #113: a running subagent forks its own conversation every ~30s
@@ -6,7 +6,7 @@ import { RUNNER_VERSION } from '../version.js';
6
6
  import { repairCodexAuth } from './codex-home.js';
7
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
8
8
  import { truncate } from './claude.js';
9
- import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
10
10
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
11
11
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
12
12
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
@@ -60,6 +60,37 @@ const MODE_POLICY = {
60
60
  // exactly like Claude's bypassPermissions. The dashboard says so.
61
61
  full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
62
62
  };
63
+ /**
64
+ * Where a `workspace-write` turn may write (#418).
65
+ *
66
+ * Codex confines writes in the KERNEL, not in our policy: under `auto` the
67
+ * sandbox is `workspace-write` with `writableRoots: [cwd]`, so a write outside
68
+ * fails inside the CLI, comes back as a «retry without sandbox?» approval, and
69
+ * only then reaches layer 1. With the project's permission on, that round trip
70
+ * is exactly the refusal the ticket exists to remove — so the ROOT is widened.
71
+ *
72
+ * **The sandbox is widened, never dropped**, and that is the whole safety of
73
+ * this function. `danger-full-access` looks like the simpler answer and is the
74
+ * wrong one: for Codex the kernel sandbox refusing something IS the only
75
+ * channel that reaches `evaluateToolUse` (the adapter calls it from
76
+ * `onApproval` and nowhere else). Remove the sandbox and nothing ever
77
+ * escalates, so `sudo`, docker, the firewall, secret paths, any repository's
78
+ * `.git`, the project's prompt file and the project's own git policy would all
79
+ * stop being enforced for Codex — the very list the setting promises to keep.
80
+ * Keeping `workspaceWrite` keeps that channel, and keeps `networkAccess: false`
81
+ * with it, so the permission moves one boundary and no other.
82
+ *
83
+ * **STRICT does not widen.** There a step outside must still raise a card, and
84
+ * the only way to raise one for Codex is to let the sandbox refuse it first.
85
+ *
86
+ * Read on every turn rather than captured at launch, because the setting can be
87
+ * switched while the session runs.
88
+ */
89
+ function writableRootsFor(spec) {
90
+ if (spec.trustMode === 'STRICT')
91
+ return [spec.cwd];
92
+ return spec.gitPolicy?.agentAllowOutsideFolder === true ? ['/'] : [spec.cwd];
93
+ }
63
94
  /**
64
95
  * DevBridge's own rules — composed per session since session 18, and kept
65
96
  * deliberately in step with `systemAppendFor` in the Claude adapter.
@@ -69,14 +100,19 @@ const MODE_POLICY = {
69
100
  * `workMode: DIRECT` (the default since session 16), and the push sentence is
70
101
  * only true when the project has «Принудительно запретить push» switched on.
71
102
  */
72
- function systemAppendFor(spec) {
103
+ function systemAppendFor(spec, mode) {
73
104
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
105
+ // #418: which folder line this session gets, if any — see `folderRuleFor`.
106
+ const folderRule = folderRuleFor(spec, mode);
74
107
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
75
108
  const memoryLine = sessionMemoryPromptLine(spec.sessionId);
76
109
  return [
77
110
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
78
111
  'Rules:',
79
- '- Work ONLY inside the current working directory.',
112
+ // #418. The three cases live in `folderRuleFor` — one copy for both
113
+ // adapters, and the Codex adapter asks the same function whether a mode
114
+ // switch would change what the agent was told.
115
+ ...(folderRule === null ? [] : [folderRule]),
80
116
  pushBanned
81
117
  ? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
82
118
  : guarded.length > 0
@@ -96,8 +132,8 @@ function systemAppendFor(spec) {
96
132
  ].join('\n');
97
133
  }
98
134
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
99
- function composeSystemAppend(spec) {
100
- const base = systemAppendFor(spec);
135
+ function composeSystemAppend(spec, mode) {
136
+ const base = systemAppendFor(spec, mode);
101
137
  return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
102
138
  }
103
139
  // Allowlist, not denylist: whatever secrets live in the daemon's environment
@@ -534,8 +570,14 @@ class CodexSession {
534
570
  return {
535
571
  cwd: this.spec.cwd,
536
572
  approvalPolicy: policy.approvalPolicy,
573
+ // #418 does NOT change this one, and that is deliberate. The thread-level
574
+ // channel takes the CLI-style MODE string, which has no way to say «this
575
+ // root»; the mode itself is unchanged (`workspace-write` stays
576
+ // `workspace-write`), and the roots travel with every turn below. Both
577
+ // channels therefore still describe the same sandbox, which is what R10
578
+ // of the plan asks for — there is no second shape to keep in step.
537
579
  sandbox: policy.sandbox,
538
- developerInstructions: composeSystemAppend(this.spec),
580
+ developerInstructions: composeSystemAppend(this.spec, this.mode),
539
581
  ...(this.model ? { model: this.model } : {}),
540
582
  ...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
541
583
  };
@@ -582,7 +624,8 @@ class CodexSession {
582
624
  // [] is accepted, null is rejected outright.
583
625
  input: [{ type: 'text', text, text_elements: [] }],
584
626
  approvalPolicy: policy.approvalPolicy,
585
- sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec.cwd),
627
+ // #418: the roots live here and only here — see `writableRootsFor`.
628
+ sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec),
586
629
  ...(this.model ? { model: this.model } : {}),
587
630
  // "Override the reasoning effort for this turn and subsequent turns" —
588
631
  // the same sticky-override channel the model uses (there is still no
@@ -793,12 +836,29 @@ class CodexSession {
793
836
  this.refreshCapabilities();
794
837
  }
795
838
  /**
796
- * Codex needs no new process for any mode: `approvalPolicy` and the sandbox
797
- * policy travel with the next `turn/start`, so the change is in force from
798
- * the next turn whatever it is (ticket #156).
839
+ * Codex needs no new process for its POLICY: `approvalPolicy` and the sandbox
840
+ * travel with the next `turn/start`, so the change is in force from the next
841
+ * turn whatever it is (ticket #156).
842
+ *
843
+ * Its RULES are the other half, and they cannot follow (#418). DevBridge's
844
+ * own instructions reach Codex ONLY as the thread's `developerInstructions`,
845
+ * fixed when the thread opens: this protocol version has no `settings/update`,
846
+ * and putting the text on the turn instead is the duplicated-prompt defect of
847
+ * ticket #179. So a line that has to DISAPPEAR in «Unrestricted» — where
848
+ * layer 1 is never consulted and the sentence would be the only thing left
849
+ * enforcing it — can only disappear with a new thread.
850
+ *
851
+ * Asked precisely rather than by hard-coding the `full` boundary: would the
852
+ * folder line actually change? With «may work outside the project folder» ON
853
+ * the answer is never — the sentence is then the same in every mode — so
854
+ * those projects keep Codex's free, instant mode switching. Only a session
855
+ * that would be told something different pays for a new process, and it pays
856
+ * exactly what a Claude session has always paid for the same move.
799
857
  */
800
- modeSwitchNeedsRelaunch(_mode) {
801
- return false;
858
+ modeSwitchNeedsRelaunch(mode) {
859
+ if (!availableModes(this.spec.trustMode).includes(mode))
860
+ return false;
861
+ return folderRuleFor(this.spec, mode) !== folderRuleFor(this.spec, this.mode);
802
862
  }
803
863
  async setMode(mode) {
804
864
  if (!availableModes(this.spec.trustMode).includes(mode)) {
@@ -2255,9 +2315,10 @@ class CodexSession {
2255
2315
  }
2256
2316
  class ResumeFailed extends Error {
2257
2317
  }
2258
- function sandboxPolicyFor(mode, cwd) {
2318
+ function sandboxPolicyFor(mode, spec) {
2259
2319
  // turn/start takes the structured SandboxPolicy, while thread/start takes the
2260
- // CLI-style SandboxMode string. Same intent, two shapes.
2320
+ // CLI-style SandboxMode string. Same intent, two shapes — and only this one
2321
+ // can name the writable roots, which is why #418 lives here.
2261
2322
  switch (mode) {
2262
2323
  case 'read-only':
2263
2324
  return { type: 'readOnly', networkAccess: false };
@@ -2266,7 +2327,11 @@ function sandboxPolicyFor(mode, cwd) {
2266
2327
  default:
2267
2328
  return {
2268
2329
  type: 'workspaceWrite',
2269
- writableRoots: [cwd],
2330
+ writableRoots: writableRootsFor(spec),
2331
+ // Unchanged by #418, on purpose: the permission is about the file
2332
+ // system, and letting the network out with it would be a second
2333
+ // boundary nobody asked to move. A network command still fails in the
2334
+ // sandbox, still escalates, and still ends at layer 1.
2270
2335
  networkAccess: false,
2271
2336
  excludeTmpdirEnvVar: false,
2272
2337
  excludeSlashTmp: false,
@@ -322,6 +322,56 @@ export declare function cardDescription(verdict: PolicyDecision, own: string | u
322
322
  * into a card.
323
323
  */
324
324
  export declare const DIRECT_BRANCH_RULE = "- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.";
325
+ /**
326
+ * «Stay in this folder» — the line layer 1 used to back up (#418).
327
+ *
328
+ * Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
329
+ * `systemAppendFor` texts are hand-synced copies of each other, and a rule that
330
+ * has to appear in one of them and not the other is the kind of thing that
331
+ * drifts silently. Exported so the tests can name the exact string rather than
332
+ * a substring of it.
333
+ *
334
+ * NOT written when the session is «Unrestricted» — there the CLI never asks
335
+ * layer 1 anything, so this sentence was the only thing left enforcing it, and
336
+ * a rule with nothing underneath is a rule the agent refuses itself by — nor
337
+ * when the project has switched «may work outside the project folder» on, where
338
+ * `outsideFolderRule` takes its place.
339
+ */
340
+ export declare const WORKING_DIRECTORY_RULE = "- Work ONLY inside the current working directory.";
341
+ /**
342
+ * What replaces the line above when the project allows work outside the folder
343
+ * (#418).
344
+ *
345
+ * It says three things and no more: where the agent is, that it may go
346
+ * elsewhere when the task needs it, and what it gives up by doing so — nothing
347
+ * outside the folder reaches «Changes», «Apply» or a restore point. That last
348
+ * half is the part the agent cannot discover for itself and the part a person
349
+ * would otherwise find out from a diff that is missing a file.
350
+ */
351
+ export declare function outsideFolderRule(cwd: string): string;
352
+ /**
353
+ * Which of the two lines above this session gets, or neither (#418).
354
+ *
355
+ * One function rather than the same three-way choice written out in both
356
+ * `systemAppendFor` texts — and it has a second caller that made it worth
357
+ * extracting: the Codex adapter asks it whether a MODE SWITCH would change what
358
+ * the agent was told, because Codex's developer instructions are fixed when the
359
+ * thread opens and cannot be edited afterwards.
360
+ *
361
+ * The three cases, in order:
362
+ * - the project allows work outside the folder → say where the folder is and
363
+ * what leaving it costs, in EVERY mode (the permission does not depend on
364
+ * the mode, so neither does the sentence);
365
+ * - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
366
+ * so this line would be the only thing enforcing the rule: the agent refuses
367
+ * itself by a sentence nothing backs up, which is the complaint #418 came
368
+ * from;
369
+ * - otherwise → the rule as it has always been, with layer 1 behind it.
370
+ *
371
+ * The mode passed in must be the one the session is actually IN, not the one it
372
+ * was asked for: a `full` refused on a STRICT workspace keeps the line.
373
+ */
374
+ export declare function folderRuleFor(spec: SessionSpec, mode: AgentMode | undefined): string | null;
325
375
  /**
326
376
  * One question inside an agent's question call (session 12).
327
377
  *
@@ -100,6 +100,65 @@ export function cardDescription(verdict, own) {
100
100
  * into a card.
101
101
  */
102
102
  export const DIRECT_BRANCH_RULE = '- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.';
103
+ /**
104
+ * «Stay in this folder» — the line layer 1 used to back up (#418).
105
+ *
106
+ * Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
107
+ * `systemAppendFor` texts are hand-synced copies of each other, and a rule that
108
+ * has to appear in one of them and not the other is the kind of thing that
109
+ * drifts silently. Exported so the tests can name the exact string rather than
110
+ * a substring of it.
111
+ *
112
+ * NOT written when the session is «Unrestricted» — there the CLI never asks
113
+ * layer 1 anything, so this sentence was the only thing left enforcing it, and
114
+ * a rule with nothing underneath is a rule the agent refuses itself by — nor
115
+ * when the project has switched «may work outside the project folder» on, where
116
+ * `outsideFolderRule` takes its place.
117
+ */
118
+ export const WORKING_DIRECTORY_RULE = '- Work ONLY inside the current working directory.';
119
+ /**
120
+ * What replaces the line above when the project allows work outside the folder
121
+ * (#418).
122
+ *
123
+ * It says three things and no more: where the agent is, that it may go
124
+ * elsewhere when the task needs it, and what it gives up by doing so — nothing
125
+ * outside the folder reaches «Changes», «Apply» or a restore point. That last
126
+ * half is the part the agent cannot discover for itself and the part a person
127
+ * would otherwise find out from a diff that is missing a file.
128
+ */
129
+ export function outsideFolderRule(cwd) {
130
+ return (`- Your working directory is ${cwd}; you may read and change files elsewhere on this server ` +
131
+ `when the task needs it. Only what is inside ${cwd} shows up in the DevBridge diff and restore points.`);
132
+ }
133
+ /**
134
+ * Which of the two lines above this session gets, or neither (#418).
135
+ *
136
+ * One function rather than the same three-way choice written out in both
137
+ * `systemAppendFor` texts — and it has a second caller that made it worth
138
+ * extracting: the Codex adapter asks it whether a MODE SWITCH would change what
139
+ * the agent was told, because Codex's developer instructions are fixed when the
140
+ * thread opens and cannot be edited afterwards.
141
+ *
142
+ * The three cases, in order:
143
+ * - the project allows work outside the folder → say where the folder is and
144
+ * what leaving it costs, in EVERY mode (the permission does not depend on
145
+ * the mode, so neither does the sentence);
146
+ * - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
147
+ * so this line would be the only thing enforcing the rule: the agent refuses
148
+ * itself by a sentence nothing backs up, which is the complaint #418 came
149
+ * from;
150
+ * - otherwise → the rule as it has always been, with layer 1 behind it.
151
+ *
152
+ * The mode passed in must be the one the session is actually IN, not the one it
153
+ * was asked for: a `full` refused on a STRICT workspace keeps the line.
154
+ */
155
+ export function folderRuleFor(spec, mode) {
156
+ // `=== true` — silence keeps the agent in, exactly as `policy.ts` resolves the
157
+ // same field.
158
+ if (spec.gitPolicy?.agentAllowOutsideFolder === true)
159
+ return outsideFolderRule(spec.cwd);
160
+ return mode === 'full' ? null : WORKING_DIRECTORY_RULE;
161
+ }
103
162
  /**
104
163
  * The name our MCP server is registered under inside an agent session.
105
164
  *
package/dist/index.js CHANGED
@@ -381,6 +381,18 @@ 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,
384
396
  /**
385
397
  * Session 16: git is git.
386
398
  *
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
  }
@@ -113,6 +113,18 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
113
113
  agentProtectedBranches: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
114
114
  agentAllowForcePush: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
115
115
  agentAllowDestructiveGit: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
116
+ /**
117
+ * #418: may the agent read and write OUTSIDE the project folder?
118
+ *
119
+ * `.optional().catch(undefined)` for the QA-100 MAJOR-1 reason above, and
120
+ * safe here for the same reason as `agentPushBan` — though the polarity
121
+ * runs the other way. `policy.ts` resolves this one with `=== true`, so a
122
+ * value this schema throws away leaves the session confined to its folder:
123
+ * strictly more restricted, never less. Rubbish in this field therefore
124
+ * costs a project its permission, which is the direction a dropped value is
125
+ * allowed to fail in.
126
+ */
127
+ agentAllowOutsideFolder: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
116
128
  budgetUsd: z.ZodNullable<z.ZodNumber>;
117
129
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
118
130
  }, "strip", z.ZodTypeAny, {
@@ -128,6 +140,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
128
140
  agentProtectedBranches?: string[] | undefined;
129
141
  agentAllowForcePush?: boolean | undefined;
130
142
  agentAllowDestructiveGit?: boolean | undefined;
143
+ agentAllowOutsideFolder?: boolean | undefined;
131
144
  }, {
132
145
  path: string;
133
146
  id: string;
@@ -141,6 +154,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
141
154
  agentProtectedBranches?: unknown;
142
155
  agentAllowForcePush?: unknown;
143
156
  agentAllowDestructiveGit?: unknown;
157
+ agentAllowOutsideFolder?: unknown;
144
158
  }>;
145
159
  /**
146
160
  * «Run this one without the project's agent prompt.» Absent means no — both
@@ -248,6 +262,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
248
262
  agentProtectedBranches?: string[] | undefined;
249
263
  agentAllowForcePush?: boolean | undefined;
250
264
  agentAllowDestructiveGit?: boolean | undefined;
265
+ agentAllowOutsideFolder?: boolean | undefined;
251
266
  };
252
267
  tickets: {
253
268
  number: number;
@@ -290,6 +305,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
290
305
  agentProtectedBranches?: unknown;
291
306
  agentAllowForcePush?: unknown;
292
307
  agentAllowDestructiveGit?: unknown;
308
+ agentAllowOutsideFolder?: unknown;
293
309
  };
294
310
  tickets: {
295
311
  number: number;
@@ -547,6 +563,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
547
563
  agentProtectedBranches: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
548
564
  agentAllowForcePush: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
549
565
  agentAllowDestructiveGit: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
566
+ /**
567
+ * #418: may the agent read and write OUTSIDE the project folder?
568
+ *
569
+ * `.optional().catch(undefined)` for the QA-100 MAJOR-1 reason above, and
570
+ * safe here for the same reason as `agentPushBan` — though the polarity
571
+ * runs the other way. `policy.ts` resolves this one with `=== true`, so a
572
+ * value this schema throws away leaves the session confined to its folder:
573
+ * strictly more restricted, never less. Rubbish in this field therefore
574
+ * costs a project its permission, which is the direction a dropped value is
575
+ * allowed to fail in.
576
+ */
577
+ agentAllowOutsideFolder: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
550
578
  budgetUsd: z.ZodNullable<z.ZodNumber>;
551
579
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
552
580
  }, "strip", z.ZodTypeAny, {
@@ -562,6 +590,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
562
590
  agentProtectedBranches?: string[] | undefined;
563
591
  agentAllowForcePush?: boolean | undefined;
564
592
  agentAllowDestructiveGit?: boolean | undefined;
593
+ agentAllowOutsideFolder?: boolean | undefined;
565
594
  }, {
566
595
  path: string;
567
596
  id: string;
@@ -575,6 +604,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
575
604
  agentProtectedBranches?: unknown;
576
605
  agentAllowForcePush?: unknown;
577
606
  agentAllowDestructiveGit?: unknown;
607
+ agentAllowOutsideFolder?: unknown;
578
608
  }>;
579
609
  /**
580
610
  * «Run this one without the project's agent prompt.» Absent means no — both
@@ -682,6 +712,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
682
712
  agentProtectedBranches?: string[] | undefined;
683
713
  agentAllowForcePush?: boolean | undefined;
684
714
  agentAllowDestructiveGit?: boolean | undefined;
715
+ agentAllowOutsideFolder?: boolean | undefined;
685
716
  };
686
717
  tickets: {
687
718
  number: number;
@@ -724,6 +755,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
724
755
  agentProtectedBranches?: unknown;
725
756
  agentAllowForcePush?: unknown;
726
757
  agentAllowDestructiveGit?: unknown;
758
+ agentAllowOutsideFolder?: unknown;
727
759
  };
728
760
  tickets: {
729
761
  number: number;
@@ -779,6 +811,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
779
811
  agentProtectedBranches?: string[] | undefined;
780
812
  agentAllowForcePush?: boolean | undefined;
781
813
  agentAllowDestructiveGit?: boolean | undefined;
814
+ agentAllowOutsideFolder?: boolean | undefined;
782
815
  };
783
816
  tickets: {
784
817
  number: number;
@@ -827,6 +860,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
827
860
  agentProtectedBranches?: unknown;
828
861
  agentAllowForcePush?: unknown;
829
862
  agentAllowDestructiveGit?: unknown;
863
+ agentAllowOutsideFolder?: unknown;
830
864
  };
831
865
  tickets: {
832
866
  number: number;
@@ -998,6 +1032,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
998
1032
  agentProtectedBranches: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
999
1033
  agentAllowForcePush: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
1000
1034
  agentAllowDestructiveGit: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
1035
+ /**
1036
+ * #418: may the agent read and write OUTSIDE the project folder?
1037
+ *
1038
+ * `.optional().catch(undefined)` for the QA-100 MAJOR-1 reason above, and
1039
+ * safe here for the same reason as `agentPushBan` — though the polarity
1040
+ * runs the other way. `policy.ts` resolves this one with `=== true`, so a
1041
+ * value this schema throws away leaves the session confined to its folder:
1042
+ * strictly more restricted, never less. Rubbish in this field therefore
1043
+ * costs a project its permission, which is the direction a dropped value is
1044
+ * allowed to fail in.
1045
+ */
1046
+ agentAllowOutsideFolder: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
1001
1047
  budgetUsd: z.ZodNullable<z.ZodNumber>;
1002
1048
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
1003
1049
  }, "strip", z.ZodTypeAny, {
@@ -1013,6 +1059,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1013
1059
  agentProtectedBranches?: string[] | undefined;
1014
1060
  agentAllowForcePush?: boolean | undefined;
1015
1061
  agentAllowDestructiveGit?: boolean | undefined;
1062
+ agentAllowOutsideFolder?: boolean | undefined;
1016
1063
  }, {
1017
1064
  path: string;
1018
1065
  id: string;
@@ -1026,6 +1073,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1026
1073
  agentProtectedBranches?: unknown;
1027
1074
  agentAllowForcePush?: unknown;
1028
1075
  agentAllowDestructiveGit?: unknown;
1076
+ agentAllowOutsideFolder?: unknown;
1029
1077
  }>;
1030
1078
  /**
1031
1079
  * «Run this one without the project's agent prompt.» Absent means no — both
@@ -1133,6 +1181,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1133
1181
  agentProtectedBranches?: string[] | undefined;
1134
1182
  agentAllowForcePush?: boolean | undefined;
1135
1183
  agentAllowDestructiveGit?: boolean | undefined;
1184
+ agentAllowOutsideFolder?: boolean | undefined;
1136
1185
  };
1137
1186
  tickets: {
1138
1187
  number: number;
@@ -1175,6 +1224,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1175
1224
  agentProtectedBranches?: unknown;
1176
1225
  agentAllowForcePush?: unknown;
1177
1226
  agentAllowDestructiveGit?: unknown;
1227
+ agentAllowOutsideFolder?: unknown;
1178
1228
  };
1179
1229
  tickets: {
1180
1230
  number: number;
@@ -1230,6 +1280,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1230
1280
  agentProtectedBranches?: string[] | undefined;
1231
1281
  agentAllowForcePush?: boolean | undefined;
1232
1282
  agentAllowDestructiveGit?: boolean | undefined;
1283
+ agentAllowOutsideFolder?: boolean | undefined;
1233
1284
  };
1234
1285
  tickets: {
1235
1286
  number: number;
@@ -1275,6 +1326,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1275
1326
  agentProtectedBranches?: unknown;
1276
1327
  agentAllowForcePush?: unknown;
1277
1328
  agentAllowDestructiveGit?: unknown;
1329
+ agentAllowOutsideFolder?: unknown;
1278
1330
  };
1279
1331
  tickets: {
1280
1332
  number: number;
@@ -1457,6 +1509,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1457
1509
  agentProtectedBranches: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1458
1510
  agentAllowForcePush: z.ZodOptional<z.ZodBoolean>;
1459
1511
  agentAllowDestructiveGit: z.ZodOptional<z.ZodBoolean>;
1512
+ agentAllowOutsideFolder: z.ZodOptional<z.ZodBoolean>;
1460
1513
  }, "strip", z.ZodTypeAny, {
1461
1514
  type: "workspace_settings";
1462
1515
  workspaceId: string;
@@ -1466,6 +1519,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1466
1519
  agentProtectedBranches?: string[] | undefined;
1467
1520
  agentAllowForcePush?: boolean | undefined;
1468
1521
  agentAllowDestructiveGit?: boolean | undefined;
1522
+ agentAllowOutsideFolder?: boolean | undefined;
1469
1523
  }, {
1470
1524
  type: "workspace_settings";
1471
1525
  workspaceId: string;
@@ -1475,6 +1529,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1475
1529
  agentProtectedBranches?: string[] | undefined;
1476
1530
  agentAllowForcePush?: boolean | undefined;
1477
1531
  agentAllowDestructiveGit?: boolean | undefined;
1532
+ agentAllowOutsideFolder?: boolean | undefined;
1478
1533
  }>, z.ZodObject<{
1479
1534
  type: z.ZodLiteral<"session_settings">;
1480
1535
  sessionId: z.ZodString;
package/dist/protocol.js CHANGED
@@ -151,6 +151,18 @@ export const SessionDescriptorSchema = z.object({
151
151
  .catch(undefined),
152
152
  agentAllowForcePush: z.boolean().optional().catch(undefined),
153
153
  agentAllowDestructiveGit: z.boolean().optional().catch(undefined),
154
+ /**
155
+ * #418: may the agent read and write OUTSIDE the project folder?
156
+ *
157
+ * `.optional().catch(undefined)` for the QA-100 MAJOR-1 reason above, and
158
+ * safe here for the same reason as `agentPushBan` — though the polarity
159
+ * runs the other way. `policy.ts` resolves this one with `=== true`, so a
160
+ * value this schema throws away leaves the session confined to its folder:
161
+ * strictly more restricted, never less. Rubbish in this field therefore
162
+ * costs a project its permission, which is the direction a dropped value is
163
+ * allowed to fail in.
164
+ */
165
+ agentAllowOutsideFolder: z.boolean().optional().catch(undefined),
154
166
  budgetUsd: z.number().nullable(),
155
167
  budgetMinutes: z.number().nullable(),
156
168
  }),
@@ -391,6 +403,9 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
391
403
  .optional(),
392
404
  agentAllowForcePush: z.boolean().optional(),
393
405
  agentAllowDestructiveGit: z.boolean().optional(),
406
+ // #418 — read on every file tool call, so it belongs in the live frame with
407
+ // its neighbours: switching it back off must not mean stopping the agent.
408
+ agentAllowOutsideFolder: z.boolean().optional(),
394
409
  }),
395
410
  z.object({
396
411
  type: z.literal('session_settings'),
@@ -67,6 +67,12 @@ function gitPolicyOf(descriptor) {
67
67
  ...(w.agentAllowDestructiveGit !== undefined
68
68
  ? { agentAllowDestructiveGit: w.agentAllowDestructiveGit }
69
69
  : {}),
70
+ // #418 — «may the agent work outside the project folder». Not a git
71
+ // question; it rides in this object because the object is the pipe (plan
72
+ // R8, and the comment on `AgentGitPolicy` in `policy.ts`).
73
+ ...(w.agentAllowOutsideFolder !== undefined
74
+ ? { agentAllowOutsideFolder: w.agentAllowOutsideFolder }
75
+ : {}),
70
76
  };
71
77
  }
72
78
  function freshLevels() {
@@ -1759,7 +1765,11 @@ export class Supervisor {
1759
1765
  const policyChanged = frame.agentPushBan !== undefined ||
1760
1766
  frame.agentProtectedBranches !== undefined ||
1761
1767
  frame.agentAllowForcePush !== undefined ||
1762
- frame.agentAllowDestructiveGit !== undefined;
1768
+ frame.agentAllowDestructiveGit !== undefined ||
1769
+ // #418 travels in the same frame and the same object: it is read on
1770
+ // every file tool call, so taking the permission away has to reach
1771
+ // a running agent without stopping it.
1772
+ frame.agentAllowOutsideFolder !== undefined;
1763
1773
  if (policyChanged) {
1764
1774
  const next = {
1765
1775
  ...(frame.agentPushBan !== undefined ? { agentPushBan: frame.agentPushBan } : {}),
@@ -1772,6 +1782,9 @@ export class Supervisor {
1772
1782
  ...(frame.agentAllowDestructiveGit !== undefined
1773
1783
  ? { agentAllowDestructiveGit: frame.agentAllowDestructiveGit }
1774
1784
  : {}),
1785
+ ...(frame.agentAllowOutsideFolder !== undefined
1786
+ ? { agentAllowOutsideFolder: frame.agentAllowOutsideFolder }
1787
+ : {}),
1775
1788
  };
1776
1789
  Object.assign(running.descriptor.workspace, next);
1777
1790
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.61.0";
1
+ export declare const RUNNER_VERSION = "0.62.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.61.0';
2
+ export const RUNNER_VERSION = '0.62.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",