@bridge4dev/runner 0.29.0 → 0.31.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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The project's own prompt file — the one thing on this machine that a project
3
+ * can put into an agent's SYSTEM prompt.
4
+ *
5
+ * ## Why a system prompt and not a file the agent reads
6
+ *
7
+ * Measured on a live request (2026-08-05), not assumed. `CLAUDE.md`, `AGENTS.md`
8
+ * and `SessionStart` hook output all arrive inside the first USER message —
9
+ * Claude Code's own prompt says so in as many words: «Treat feedback from hooks
10
+ * … as coming from the user». `systemPrompt.append` arrives in `system[]`,
11
+ * after the CLI's own text and last. Only the second survives a compaction, and
12
+ * only the second can stand next to a rule the CLI itself states.
13
+ *
14
+ * That difference is the whole feature. An Opus-5 system prompt carries
15
+ * «Do not call the AgentTool unless the user requested it»; a project whose
16
+ * process REQUIRES an independent review round has to be able to say so at the
17
+ * same level, or it is simply outranked and nobody can see why.
18
+ *
19
+ * ## Why this file is paranoid
20
+ *
21
+ * Its contents become system-prompt text verbatim, and the path comes off the
22
+ * wire. So every rule is re-derived here rather than trusted from the API: the
23
+ * runner is the process that opens the file, and it is the only side that can
24
+ * see what the path actually resolves to on this disk.
25
+ */
26
+ /** Big enough for a real process document; small enough to stay a prompt. */
27
+ export declare const AGENT_PROMPT_MAX_BYTES: number;
28
+ export type AgentPromptResult = {
29
+ ok: true;
30
+ text: string;
31
+ relPath: string;
32
+ absPath: string;
33
+ bytes: number;
34
+ sha: string;
35
+ }
36
+ /** Already phrased for a human and safe to show — no raw paths beyond the one they typed. */
37
+ | {
38
+ ok: false;
39
+ reason: string;
40
+ };
41
+ /**
42
+ * Read the project's prompt file, or explain why it cannot be read.
43
+ *
44
+ * @param projectRoot Absolute path of the PROJECT FOLDER (`workspace.path`) —
45
+ * not the session worktree. Deliberate, for two reasons that always hold: the
46
+ * file may be uncommitted (a worktree cut from the base branch would not have
47
+ * it), and every session of the project then reads the same rules whatever
48
+ * branch it is on.
49
+ *
50
+ * A third reason used to be written here and was wrong, so it is worth saying
51
+ * plainly (QA-130 MAJOR-3): this does NOT stop a session from rewriting the
52
+ * file that becomes its own next system prompt. It stops it in `BRANCH` mode,
53
+ * where writes outside the worktree are refused — but in `workMode: DIRECT`,
54
+ * which is the default, the project folder IS the session's working
55
+ * directory. What guards it there is the layer-1 rule in `policy.ts`
56
+ * (`agentPromptFile`), and that rule is a guard rather than a guarantee: it
57
+ * covers the file-writing tools, not a shell redirect, and `full` mode
58
+ * bypasses layer 1 altogether by design. The honest backstop is the `sha` in
59
+ * the session feed, which changes when the file does.
60
+ * @param relPath The configured path, relative to `projectRoot`.
61
+ */
62
+ export declare function readAgentPrompt(projectRoot: string, relPath: string): AgentPromptResult;
63
+ /** How the prompt is announced in the session feed and in the journal. */
64
+ export declare function agentPromptSizeLabel(bytes: number): string;
65
+ /**
66
+ * A configured path, safe to put in a line a person reads.
67
+ *
68
+ * The session feed renders a notice as plain text, so backticks would be shown
69
+ * literally rather than as code — and this string can be a REFUSED path, which
70
+ * means it never passed any of the checks above and is only as clean as the API
71
+ * schema made it. Quoted with guillemets, stripped of anything that could break
72
+ * a line, and bounded (QA-130 NIT-13).
73
+ */
74
+ export declare function quotePath(value: string): string;
75
+ //# sourceMappingURL=agent-prompt.d.ts.map
@@ -0,0 +1,252 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { isGitInternalPath, isSecretPath } from './policy.js';
5
+ /**
6
+ * The project's own prompt file — the one thing on this machine that a project
7
+ * can put into an agent's SYSTEM prompt.
8
+ *
9
+ * ## Why a system prompt and not a file the agent reads
10
+ *
11
+ * Measured on a live request (2026-08-05), not assumed. `CLAUDE.md`, `AGENTS.md`
12
+ * and `SessionStart` hook output all arrive inside the first USER message —
13
+ * Claude Code's own prompt says so in as many words: «Treat feedback from hooks
14
+ * … as coming from the user». `systemPrompt.append` arrives in `system[]`,
15
+ * after the CLI's own text and last. Only the second survives a compaction, and
16
+ * only the second can stand next to a rule the CLI itself states.
17
+ *
18
+ * That difference is the whole feature. An Opus-5 system prompt carries
19
+ * «Do not call the AgentTool unless the user requested it»; a project whose
20
+ * process REQUIRES an independent review round has to be able to say so at the
21
+ * same level, or it is simply outranked and nobody can see why.
22
+ *
23
+ * ## Why this file is paranoid
24
+ *
25
+ * Its contents become system-prompt text verbatim, and the path comes off the
26
+ * wire. So every rule is re-derived here rather than trusted from the API: the
27
+ * runner is the process that opens the file, and it is the only side that can
28
+ * see what the path actually resolves to on this disk.
29
+ */
30
+ /** Big enough for a real process document; small enough to stay a prompt. */
31
+ export const AGENT_PROMPT_MAX_BYTES = 32 * 1024;
32
+ function deny(reason) {
33
+ return { ok: false, reason };
34
+ }
35
+ /**
36
+ * Read the project's prompt file, or explain why it cannot be read.
37
+ *
38
+ * @param projectRoot Absolute path of the PROJECT FOLDER (`workspace.path`) —
39
+ * not the session worktree. Deliberate, for two reasons that always hold: the
40
+ * file may be uncommitted (a worktree cut from the base branch would not have
41
+ * it), and every session of the project then reads the same rules whatever
42
+ * branch it is on.
43
+ *
44
+ * A third reason used to be written here and was wrong, so it is worth saying
45
+ * plainly (QA-130 MAJOR-3): this does NOT stop a session from rewriting the
46
+ * file that becomes its own next system prompt. It stops it in `BRANCH` mode,
47
+ * where writes outside the worktree are refused — but in `workMode: DIRECT`,
48
+ * which is the default, the project folder IS the session's working
49
+ * directory. What guards it there is the layer-1 rule in `policy.ts`
50
+ * (`agentPromptFile`), and that rule is a guard rather than a guarantee: it
51
+ * covers the file-writing tools, not a shell redirect, and `full` mode
52
+ * bypasses layer 1 altogether by design. The honest backstop is the `sha` in
53
+ * the session feed, which changes when the file does.
54
+ * @param relPath The configured path, relative to `projectRoot`.
55
+ */
56
+ export function readAgentPrompt(projectRoot, relPath) {
57
+ const wanted = relPath.trim();
58
+ if (wanted === '')
59
+ return deny('the path is empty');
60
+ if (wanted.length > 300)
61
+ return deny('the path is too long');
62
+ // A NUL truncates the string every syscall below sees, so it must never get
63
+ // as far as a syscall.
64
+ // eslint-disable-next-line no-control-regex -- a control character in a path is never legitimate
65
+ if (/[\u0000-\u001f\u007f]/.test(wanted))
66
+ return deny('the path contains control characters');
67
+ if (path.isAbsolute(wanted) || wanted.startsWith('~')) {
68
+ return deny('the path must be relative to the project folder');
69
+ }
70
+ if (wanted.includes('\\'))
71
+ return deny('the path must use "/" separators');
72
+ if (wanted.endsWith('/'))
73
+ return deny('the path must name a file, not a directory');
74
+ if (wanted.split('/').includes('..'))
75
+ return deny('the path must not contain ".." segments');
76
+ // The project folder itself has to exist and be a directory before anything
77
+ // can be resolved against it — otherwise `realpath` failures below would all
78
+ // read as «no such file», blaming the prompt for a moved project.
79
+ let rootReal;
80
+ try {
81
+ rootReal = fs.realpathSync(path.resolve(projectRoot));
82
+ }
83
+ catch {
84
+ return deny('the project folder is not readable');
85
+ }
86
+ const absolute = path.resolve(rootReal, wanted);
87
+ // `lstat` first, and on the path as written: a SYMLINK is refused outright
88
+ // rather than followed. Following it would mean the setting says one file and
89
+ // the agent's system prompt comes from another, which is exactly the sort of
90
+ // indirection that makes a security rule unreadable.
91
+ let link;
92
+ try {
93
+ link = fs.lstatSync(absolute);
94
+ }
95
+ catch (error) {
96
+ return deny(describeFsError(error));
97
+ }
98
+ if (link.isSymbolicLink())
99
+ return deny('the path is a symlink');
100
+ if (link.isDirectory())
101
+ return deny('the path is a directory');
102
+ if (!link.isFile())
103
+ return deny('the path is not a regular file');
104
+ // Containment is checked AFTER symlinks are resolved, because a symlinked
105
+ // PARENT directory would otherwise pass a purely textual check and land the
106
+ // read outside the project entirely (the QA-96 F13 lesson, applied here).
107
+ let realPath;
108
+ try {
109
+ realPath = fs.realpathSync(absolute);
110
+ }
111
+ catch (error) {
112
+ return deny(describeFsError(error));
113
+ }
114
+ const rel = path.relative(rootReal, realPath);
115
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
116
+ return deny('the path leaves the project folder');
117
+ }
118
+ // The same two lists layer 1 uses for every tool call. A setting must not be
119
+ // a way around them: «read .env into the system prompt» is precisely the
120
+ // shape of request this would otherwise grant.
121
+ if (isSecretPath(realPath))
122
+ return deny('that file is on the protected list');
123
+ if (isGitInternalPath(realPath))
124
+ return deny('that file is inside .git');
125
+ // Read with a hard ceiling on BYTES ACTUALLY READ rather than trusting the
126
+ // size from `stat`: a file can grow between the two calls, and «we checked
127
+ // and then read whatever was there» is not a limit.
128
+ //
129
+ // The open re-checks the file through its own descriptor rather than trusting
130
+ // the path a second time, and that closes two holes the path checks cannot
131
+ // (QA-130 MINOR-4):
132
+ //
133
+ // - **the window between `realpath` and `open`.** Every rule above was
134
+ // decided about a path; `open` resolves the name again. Replacing the last
135
+ // component with a symlink in between would hand back a file nothing here
136
+ // ever looked at. `O_NOFOLLOW` refuses that outright, and comparing
137
+ // dev/ino catches a plain swap.
138
+ // - **hard links**, which are the same capability under another name and
139
+ // which `realpath` cannot see: a link has no target to resolve. Without
140
+ // this, `ln /etc/hostname docs/prompt.md` walks past both «inside the
141
+ // project folder» and «not on the protected list», because both were
142
+ // decided about the NAME.
143
+ let buffer;
144
+ try {
145
+ buffer = readCapped(realPath, AGENT_PROMPT_MAX_BYTES + 1, link);
146
+ }
147
+ catch (error) {
148
+ if (error instanceof PromptFileRefused)
149
+ return deny(error.reason);
150
+ return deny(describeFsError(error));
151
+ }
152
+ if (buffer.length > AGENT_PROMPT_MAX_BYTES) {
153
+ return deny(`the file is larger than ${Math.floor(AGENT_PROMPT_MAX_BYTES / 1024)} KB`);
154
+ }
155
+ if (buffer.includes(0))
156
+ return deny('the file is not text');
157
+ const sha = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 12);
158
+ // Strip a UTF-8 BOM: it is invisible in every editor and would otherwise be
159
+ // the first character of the agent's system prompt.
160
+ const text = buffer
161
+ .toString('utf8')
162
+ .replace(/^\uFEFF/, '')
163
+ .trim();
164
+ if (text === '')
165
+ return deny('the file is empty');
166
+ return { ok: true, text, relPath: wanted, absPath: realPath, bytes: buffer.length, sha };
167
+ }
168
+ /** A refusal decided after the file was already open — carried out by throwing. */
169
+ class PromptFileRefused extends Error {
170
+ reason;
171
+ constructor(reason) {
172
+ super(reason);
173
+ this.reason = reason;
174
+ this.name = 'PromptFileRefused';
175
+ }
176
+ }
177
+ /**
178
+ * Read at most `limit` bytes from a file that is proved, through its own
179
+ * descriptor, to be the same plain file the checks above approved.
180
+ *
181
+ * @param expected The `lstat` taken before the path checks — the identity every
182
+ * rule above was decided about.
183
+ */
184
+ function readCapped(file, limit, expected) {
185
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
186
+ try {
187
+ const opened = fs.fstatSync(fd);
188
+ if (!opened.isFile())
189
+ throw new PromptFileRefused('the path is not a regular file');
190
+ // A second name for the same inode is a second way in, and it is the one
191
+ // `realpath` cannot see. One link is the only shape whose name and contents
192
+ // are the same fact.
193
+ if (opened.nlink !== 1)
194
+ throw new PromptFileRefused('the file has more than one name');
195
+ if (opened.dev !== expected.dev || opened.ino !== expected.ino) {
196
+ throw new PromptFileRefused('the file changed while it was being opened');
197
+ }
198
+ const buffer = Buffer.alloc(limit);
199
+ let read = 0;
200
+ while (read < limit) {
201
+ const n = fs.readSync(fd, buffer, read, limit - read, null);
202
+ if (n === 0)
203
+ break;
204
+ read += n;
205
+ }
206
+ return buffer.subarray(0, read);
207
+ }
208
+ finally {
209
+ fs.closeSync(fd);
210
+ }
211
+ }
212
+ /**
213
+ * Turn an fs error into a sentence a human can act on.
214
+ *
215
+ * Never echoes the error's own message: it carries the absolute path, and this
216
+ * text is shown in the session feed of a dashboard that may be open on somebody
217
+ * else's screen.
218
+ */
219
+ function describeFsError(error) {
220
+ const code = error?.code;
221
+ if (code === 'ENOENT')
222
+ return 'no such file in the project folder';
223
+ if (code === 'EACCES' || code === 'EPERM')
224
+ return 'the runner may not read that file';
225
+ if (code === 'EISDIR')
226
+ return 'the path is a directory';
227
+ if (code === 'ELOOP')
228
+ return 'the path is a symlink loop';
229
+ if (code === 'ENAMETOOLONG')
230
+ return 'the path is too long';
231
+ return 'the file could not be read';
232
+ }
233
+ /** How the prompt is announced in the session feed and in the journal. */
234
+ export function agentPromptSizeLabel(bytes) {
235
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
236
+ }
237
+ /**
238
+ * A configured path, safe to put in a line a person reads.
239
+ *
240
+ * The session feed renders a notice as plain text, so backticks would be shown
241
+ * literally rather than as code — and this string can be a REFUSED path, which
242
+ * means it never passed any of the checks above and is only as clean as the API
243
+ * schema made it. Quoted with guillemets, stripped of anything that could break
244
+ * a line, and bounded (QA-130 NIT-13).
245
+ */
246
+ export function quotePath(value) {
247
+ const clean = Array.from(value)
248
+ .filter((ch) => ch >= ' ' && ch !== '\u007f' && ch !== '`')
249
+ .join('');
250
+ return `«${clean.length > 120 ? `${clean.slice(0, 117)}…` : clean}»`;
251
+ }
252
+ //# sourceMappingURL=agent-prompt.js.map
package/dist/index.js CHANGED
@@ -249,6 +249,16 @@ function runnerCapabilities(apiUrlOverride) {
249
249
  */
250
250
  contextRewind: true,
251
251
  contextCompaction: true,
252
+ /**
253
+ * Reads the project's own prompt file and hands it to the agent as
254
+ * SYSTEM-prompt text, for both Claude and Codex.
255
+ *
256
+ * Announced so the dashboard can refuse to offer the setting on a server
257
+ * that would ignore it. That refusal is the point of the flag: this is the
258
+ * one setting whose value is that it cannot be silently outranked, so a
259
+ * version of it that is silently ignored would be worse than none.
260
+ */
261
+ agentPrompt: true,
252
262
  /**
253
263
  * Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
254
264
  * the panel can say what is blocking an Apply instead of offering a button
package/dist/policy.d.ts CHANGED
@@ -1,6 +1,20 @@
1
+ import type { AgentMode } from './adapters/types.js';
1
2
  export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
2
3
  export interface PolicyContext {
3
4
  trustMode: TrustMode;
5
+ /**
6
+ * The interaction mode the SESSION is in — the dial in the composer, as
7
+ * opposed to `trustMode`, which is the workspace's (ticket #156).
8
+ *
9
+ * It was missing here for four sessions, and that is the whole of #156: the
10
+ * user switched to «Auto» and the agent went on asking, because the function
11
+ * that decides whether to ask had never heard of the mode. A control that
12
+ * moves a value nothing reads is worse than a missing one.
13
+ *
14
+ * `undefined` means an older API/runner pair that does not send it, and there
15
+ * the answer stays exactly what it has always been — see `effectiveTrust`.
16
+ */
17
+ mode?: AgentMode;
4
18
  /** The session worktree — the only place the agent may write. */
5
19
  worktreePath: string;
6
20
  /**
@@ -16,6 +30,27 @@ export interface PolicyContext {
16
30
  * than this release — and there the answer stays what it has always been.
17
31
  */
18
32
  agentAutoCommit?: boolean;
33
+ /**
34
+ * Absolute path of the project's own prompt file, when this session was given
35
+ * one (session 17).
36
+ *
37
+ * Same shape and the same reason as the `.git` rule below it: this file has
38
+ * stopped being data. Its contents are the session's SYSTEM prompt, so a
39
+ * write to it is a rewrite of the rules the next process of this session will
40
+ * be given — including the rules that say what the agent may do.
41
+ *
42
+ * It only became reachable when `workMode: DIRECT` made the project folder
43
+ * the agent's own working directory (session 16, and the default). In
44
+ * `BRANCH` mode «writes outside the worktree are not allowed» already covered
45
+ * it, which is why the first version of session 17 believed it was safe
46
+ * everywhere (QA-130 MAJOR-3).
47
+ *
48
+ * A guard, not a guarantee, and the difference is worth stating: it covers
49
+ * the file-writing tools, not `sh -c 'echo … > prompt.md'`, and `full` mode
50
+ * does not consult layer 1 at all. The `sha` printed in the session feed is
51
+ * what makes a change visible when this cannot prevent it.
52
+ */
53
+ agentPromptFile?: string;
19
54
  }
20
55
  export interface PolicyDecision {
21
56
  decision: 'allow' | 'deny' | 'ask';
@@ -76,5 +111,31 @@ export interface RecipeCommandDecision {
76
111
  * nowhere else. Without the name, the refusal stands and says why.
77
112
  */
78
113
  export declare function evaluateRecipeCommand(command: string, ctx?: RecipeCommandContext): RecipeCommandDecision;
114
+ /**
115
+ * Fold the workspace's trust level and the session's mode into the one value
116
+ * the rules below actually consult (ticket #156).
117
+ *
118
+ * Three rules, and each is a sentence:
119
+ *
120
+ * 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
121
+ * button mean what it says: Claude's own Auto does not stop to ask about
122
+ * `grep … | head`, and neither does this one any more. The hard denials
123
+ * below — `sudo`, `git push`, docker control, secret paths, writes outside
124
+ * the worktree, anything inside `.git` — are not part of the deal and are
125
+ * checked before this value is ever read.
126
+ * 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
127
+ * «Ask first» used to ask about nothing at all, which is a label that lies.
128
+ * This is the one direction that is STRICTER than before, and it is strict
129
+ * only where the old behaviour contradicted the word on the control.
130
+ * 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
131
+ * STRICT exists: a manager sets it on the workspace, and no session-level
132
+ * dial may spend it. `full` is refused outright on such a workspace — see
133
+ * `availableModes` in the adapters, because in `bypassPermissions` this
134
+ * function is never called at all and a shield nobody consults is no shield.
135
+ *
136
+ * `mode === undefined` (an API or runner from before this release) returns the
137
+ * workspace trust unchanged, which is exactly the old behaviour.
138
+ */
139
+ export declare function effectiveTrust(trustMode: TrustMode, mode?: AgentMode): TrustMode;
79
140
  export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
80
141
  //# sourceMappingURL=policy.d.ts.map
package/dist/policy.js CHANGED
@@ -602,7 +602,46 @@ const AGENT_COMMIT = new RegExp(String.raw `\bgit\s+${FLAGS}commit\b`);
602
602
  // ─── Tool-use evaluation ─────────────────────────────────────────────
603
603
  const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
604
604
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
605
+ /**
606
+ * Fold the workspace's trust level and the session's mode into the one value
607
+ * the rules below actually consult (ticket #156).
608
+ *
609
+ * Three rules, and each is a sentence:
610
+ *
611
+ * 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
612
+ * button mean what it says: Claude's own Auto does not stop to ask about
613
+ * `grep … | head`, and neither does this one any more. The hard denials
614
+ * below — `sudo`, `git push`, docker control, secret paths, writes outside
615
+ * the worktree, anything inside `.git` — are not part of the deal and are
616
+ * checked before this value is ever read.
617
+ * 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
618
+ * «Ask first» used to ask about nothing at all, which is a label that lies.
619
+ * This is the one direction that is STRICTER than before, and it is strict
620
+ * only where the old behaviour contradicted the word on the control.
621
+ * 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
622
+ * STRICT exists: a manager sets it on the workspace, and no session-level
623
+ * dial may spend it. `full` is refused outright on such a workspace — see
624
+ * `availableModes` in the adapters, because in `bypassPermissions` this
625
+ * function is never called at all and a shield nobody consults is no shield.
626
+ *
627
+ * `mode === undefined` (an API or runner from before this release) returns the
628
+ * workspace trust unchanged, which is exactly the old behaviour.
629
+ */
630
+ export function effectiveTrust(trustMode, mode) {
631
+ if (trustMode === 'STRICT')
632
+ return 'STRICT';
633
+ if (mode === undefined)
634
+ return trustMode;
635
+ if (mode === 'auto' || mode === 'full')
636
+ return 'AUTO';
637
+ // ask / plan — at most NORMAL, never AUTO.
638
+ return trustMode === 'AUTO' ? 'NORMAL' : trustMode;
639
+ }
605
640
  export function evaluateToolUse(toolName, input, ctx) {
641
+ // Read ONCE, here, and never `ctx.trustMode` again below: the branches that
642
+ // follow are the whole of layer 1, and a single one still reading the raw
643
+ // workspace value would be a hole in exactly the shape of #156.
644
+ const trust = effectiveTrust(ctx.trustMode, ctx.mode);
606
645
  // DevBridge MCP tools are the agent's job interface — always fine.
607
646
  if (toolName.startsWith('mcp__devbridge__')) {
608
647
  return { decision: 'allow', reason: 'devbridge mcp' };
@@ -633,9 +672,9 @@ export function evaluateToolUse(toolName, input, ctx) {
633
672
  }
634
673
  }
635
674
  }
636
- if (ctx.trustMode === 'STRICT')
675
+ if (trust === 'STRICT')
637
676
  return { decision: 'ask', reason: 'strict mode' };
638
- if (ctx.trustMode === 'AUTO')
677
+ if (trust === 'AUTO')
639
678
  return { decision: 'allow', reason: 'auto mode' };
640
679
  if (isSafeCommand(command)) {
641
680
  return { decision: 'allow', reason: 'safe command' };
@@ -667,22 +706,33 @@ export function evaluateToolUse(toolName, input, ctx) {
667
706
  if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
668
707
  return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
669
708
  }
670
- if (ctx.trustMode === 'STRICT')
709
+ // The project's own prompt file — the same rule as `.git` above, for the
710
+ // same reason: what is written here is not data, it is the instructions the
711
+ // next process of this session will be started with.
712
+ if (WRITE_TOOLS.has(toolName) &&
713
+ ctx.agentPromptFile &&
714
+ resolved === path.resolve(ctx.agentPromptFile)) {
715
+ return {
716
+ decision: 'deny',
717
+ reason: 'this file is the system prompt of this session — a human edits it, not the agent it instructs',
718
+ };
719
+ }
720
+ if (trust === 'STRICT')
671
721
  return { decision: 'ask', reason: 'strict mode' };
672
722
  if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
673
- return ctx.trustMode === 'AUTO'
723
+ return trust === 'AUTO'
674
724
  ? { decision: 'allow', reason: 'auto mode' }
675
725
  : { decision: 'ask', reason: 'read outside the worktree' };
676
726
  }
677
727
  return { decision: 'allow', reason: 'inside worktree' };
678
728
  }
679
729
  if (toolName === 'WebFetch' || toolName === 'WebSearch') {
680
- if (ctx.trustMode === 'STRICT')
730
+ if (trust === 'STRICT')
681
731
  return { decision: 'ask', reason: 'strict mode' };
682
732
  return { decision: 'allow', reason: 'network read' };
683
733
  }
684
734
  // Unknown tools: ask unless the workspace is fully trusted.
685
- if (ctx.trustMode === 'AUTO')
735
+ if (trust === 'AUTO')
686
736
  return { decision: 'allow', reason: 'auto mode' };
687
737
  return { decision: 'ask', reason: `unrecognized tool ${toolName}` };
688
738
  }