@bridge4dev/runner 0.30.0 → 0.33.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,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
@@ -265,6 +275,19 @@ function runnerCapabilities(apiUrlOverride) {
265
275
  * runner incapable of the setting it honours.
266
276
  */
267
277
  agentAutoCommit: true,
278
+ /**
279
+ * Session 18: the agent's git rules come from the PROJECT, not from this
280
+ * binary.
281
+ *
282
+ * `agentPushBan`, `agentProtectedBranches`, `agentAllowForcePush` and
283
+ * `agentAllowDestructiveGit` are read off the workspace and applied on
284
+ * every tool call. Announced so the settings card can warn that a server
285
+ * without it will go on refusing every push whatever the switch says —
286
+ * warn, not refuse: the fleet updates one machine at a time and a manager
287
+ * who cannot record the intention until the last of them is current has
288
+ * been handed a worse problem.
289
+ */
290
+ agentGitPolicy: true,
268
291
  /**
269
292
  * Session 16: git is git.
270
293
  *
package/dist/policy.d.ts CHANGED
@@ -1,6 +1,47 @@
1
1
  import type { AgentMode } from './adapters/types.js';
2
2
  export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
3
- export interface PolicyContext {
3
+ /**
4
+ * The project's git policy for agents (session 18) — one shape, carried
5
+ * unchanged from the API's descriptor through the supervisor and the adapters
6
+ * into `PolicyContext`.
7
+ *
8
+ * A named interface rather than four fields repeated in five places, because
9
+ * these are the fields whose DIRECTION matters: see the polarity note on
10
+ * `agentPushBan`. A copy that drifts is a copy that eventually gets one of
11
+ * them the wrong way round.
12
+ */
13
+ export interface AgentGitPolicy {
14
+ /**
15
+ * «Принудительно запретить push» — the project's switch.
16
+ *
17
+ * READ AS `!== false`. Not `=== true`, and not truthiness.
18
+ *
19
+ * `undefined` means an API too old to send the field, and there the answer
20
+ * must be what it has always been: refused. This is the OPPOSITE polarity to
21
+ * `agentAutoCommit`, where silence permits — and they sit close together on
22
+ * purpose so that whoever copies one reads why the other differs. Getting it
23
+ * backwards does not fail a test, it silently hands every runner on an old
24
+ * API permission to push.
25
+ */
26
+ agentPushBan?: boolean;
27
+ /**
28
+ * Branches the agent may never push to, whatever `agentPushBan` says.
29
+ *
30
+ * `undefined` means «nobody said», which is `['main', 'master']` — the pair
31
+ * that was hardcoded until this release. An EMPTY ARRAY is a real answer and
32
+ * means «nothing is protected»; the two must not be conflated, which is why
33
+ * nothing here writes `list?.length ? … : DEFAULT`.
34
+ */
35
+ agentProtectedBranches?: string[];
36
+ /**
37
+ * `undefined` and `false` both mean no. Force into a protected branch is
38
+ * refused whatever this says — the two rules are AND-ed, not OR-ed.
39
+ */
40
+ agentAllowForcePush?: boolean;
41
+ /** `git reset --hard` and `git clean`. `undefined` and `false` mean no. */
42
+ agentAllowDestructiveGit?: boolean;
43
+ }
44
+ export interface PolicyContext extends AgentGitPolicy {
4
45
  trustMode: TrustMode;
5
46
  /**
6
47
  * The interaction mode the SESSION is in — the dial in the composer, as
@@ -30,6 +71,27 @@ export interface PolicyContext {
30
71
  * than this release — and there the answer stays what it has always been.
31
72
  */
32
73
  agentAutoCommit?: boolean;
74
+ /**
75
+ * Absolute path of the project's own prompt file, when this session was given
76
+ * one (session 17).
77
+ *
78
+ * Same shape and the same reason as the `.git` rule below it: this file has
79
+ * stopped being data. Its contents are the session's SYSTEM prompt, so a
80
+ * write to it is a rewrite of the rules the next process of this session will
81
+ * be given — including the rules that say what the agent may do.
82
+ *
83
+ * It only became reachable when `workMode: DIRECT` made the project folder
84
+ * the agent's own working directory (session 16, and the default). In
85
+ * `BRANCH` mode «writes outside the worktree are not allowed» already covered
86
+ * it, which is why the first version of session 17 believed it was safe
87
+ * everywhere (QA-130 MAJOR-3).
88
+ *
89
+ * A guard, not a guarantee, and the difference is worth stating: it covers
90
+ * the file-writing tools, not `sh -c 'echo … > prompt.md'`, and `full` mode
91
+ * does not consult layer 1 at all. The `sha` printed in the session feed is
92
+ * what makes a change visible when this cannot prevent it.
93
+ */
94
+ agentPromptFile?: string;
33
95
  }
34
96
  export interface PolicyDecision {
35
97
  decision: 'allow' | 'deny' | 'ask';
@@ -55,6 +117,16 @@ export declare function isInsideWorktree(p: string, worktreePath: string): boole
55
117
  * next git invocation. Neither is ever ordinary agent work.
56
118
  */
57
119
  export declare function isGitInternalPath(p: string): boolean;
120
+ /**
121
+ * The project's git rules, applied to one Bash command.
122
+ *
123
+ * Returns a refusal, or null when the command has nothing to do with them.
124
+ * Every branch of this function is reachable only when the project has
125
+ * DELIBERATELY switched something on — with the shipped defaults the first
126
+ * check refuses every push and the rest never run, which is byte for byte the
127
+ * behaviour of the four regexes it replaces.
128
+ */
129
+ export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
58
130
  export interface RecipeCommandContext {
59
131
  /**
60
132
  * The docker compose project this preview owns, from `preview.project` in the