@bridge4dev/runner 0.11.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. package/package.json +52 -0
@@ -0,0 +1,300 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { journalDir } from './paths.js';
4
+ /**
5
+ * Compact once the file grows past this — a chatty agent (Codex streams far
6
+ * more item events than Claude) otherwise leaves a file that both the replay on
7
+ * open and every reconnect have to read in full (QA-99 MINOR-6).
8
+ */
9
+ const COMPACT_THRESHOLD_BYTES = 512 * 1024;
10
+ export class SessionJournal {
11
+ sessionId;
12
+ file;
13
+ nextSeq = 1;
14
+ unackedBySeq = new Map();
15
+ /** Messages accepted from the API but not yet handed to an agent. */
16
+ pendingById = new Map();
17
+ pendingCounter = 0;
18
+ /** Bytes written since the file was last rewritten from live state. */
19
+ bytesOnDisk = 0;
20
+ /** Last status reported for this session — replayed after a reconnect. */
21
+ lastStatus = null;
22
+ constructor(sessionId, dir = journalDir()) {
23
+ this.sessionId = sessionId;
24
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ this.file = path.join(dir, `${sessionId}.ndjson`);
26
+ this.replay();
27
+ }
28
+ replay() {
29
+ if (!fs.existsSync(this.file))
30
+ return;
31
+ const raw = fs.readFileSync(this.file, 'utf8');
32
+ this.bytesOnDisk = Buffer.byteLength(raw);
33
+ for (const line of raw.split('\n')) {
34
+ if (!line.trim())
35
+ continue;
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(line);
39
+ }
40
+ catch {
41
+ continue; // torn tail write after a crash — ignore
42
+ }
43
+ if (parsed.kind === 'event') {
44
+ this.unackedBySeq.set(parsed.seq, {
45
+ seq: parsed.seq,
46
+ eventType: parsed.eventType,
47
+ payload: parsed.payload,
48
+ });
49
+ if (parsed.seq >= this.nextSeq)
50
+ this.nextSeq = parsed.seq + 1;
51
+ }
52
+ else if (parsed.kind === 'ack') {
53
+ this.unackedBySeq.delete(parsed.seq);
54
+ }
55
+ else if (parsed.kind === 'status') {
56
+ this.lastStatus = {
57
+ status: parsed.status,
58
+ ...(parsed.extra ? { extra: parsed.extra } : {}),
59
+ ...(parsed.epoch === undefined ? {} : { epoch: parsed.epoch }),
60
+ };
61
+ }
62
+ else if (parsed.kind === 'pending') {
63
+ this.pendingById.set(parsed.id, {
64
+ id: parsed.id,
65
+ text: parsed.text,
66
+ ...(parsed.attachments?.length ? { attachments: parsed.attachments } : {}),
67
+ });
68
+ // Ids are `p<n>`; keep the counter above whatever the file holds so a
69
+ // restarted runner cannot mint an id that is already in flight.
70
+ const n = Number.parseInt(parsed.id.slice(1), 10);
71
+ if (Number.isFinite(n) && n >= this.pendingCounter)
72
+ this.pendingCounter = n + 1;
73
+ }
74
+ else if (parsed.kind === 'pending_done') {
75
+ this.pendingById.delete(parsed.id);
76
+ }
77
+ else if (parsed.kind === 'seq') {
78
+ if (parsed.next > this.nextSeq)
79
+ this.nextSeq = parsed.next;
80
+ }
81
+ }
82
+ }
83
+ write(line) {
84
+ const encoded = JSON.stringify(line) + '\n';
85
+ fs.appendFileSync(this.file, encoded);
86
+ this.bytesOnDisk += Buffer.byteLength(encoded);
87
+ }
88
+ /**
89
+ * Rewrite the file from live state: the seq counter, the events still waiting
90
+ * for an ack, and the last reported status. Everything else is history the
91
+ * runner never reads again.
92
+ *
93
+ * Atomic (tmp + rename) so a crash mid-compaction leaves the previous file
94
+ * intact rather than a truncated one.
95
+ */
96
+ compact() {
97
+ const lines = [{ kind: 'seq', next: this.nextSeq }];
98
+ for (const event of this.unacked()) {
99
+ lines.push({ kind: 'event', ...event, ts: new Date().toISOString() });
100
+ }
101
+ if (this.lastStatus) {
102
+ lines.push({
103
+ kind: 'status',
104
+ status: this.lastStatus.status,
105
+ ...(this.lastStatus.extra ? { extra: this.lastStatus.extra } : {}),
106
+ ...(this.lastStatus.epoch === undefined ? {} : { epoch: this.lastStatus.epoch }),
107
+ ts: new Date().toISOString(),
108
+ });
109
+ }
110
+ // Undelivered messages are live state too — compaction that dropped them
111
+ // would lose exactly what this queue exists to protect.
112
+ for (const record of this.pendingById.values()) {
113
+ lines.push({
114
+ kind: 'pending',
115
+ id: record.id,
116
+ text: record.text,
117
+ ...(record.attachments?.length ? { attachments: record.attachments } : {}),
118
+ ts: new Date().toISOString(),
119
+ });
120
+ }
121
+ const body = lines.map((line) => JSON.stringify(line) + '\n').join('');
122
+ const tmp = `${this.file}.${process.pid}.tmp`;
123
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
124
+ fs.renameSync(tmp, this.file);
125
+ this.bytesOnDisk = Buffer.byteLength(body);
126
+ }
127
+ compactIfLarge() {
128
+ if (this.bytesOnDisk <= COMPACT_THRESHOLD_BYTES)
129
+ return;
130
+ try {
131
+ this.compact();
132
+ }
133
+ catch {
134
+ // Compaction is an optimisation — a failure must never lose events.
135
+ }
136
+ }
137
+ /**
138
+ * Statuses are fire-and-forget on the wire; journaling the latest one lets
139
+ * the supervisor re-report it after a reconnect (QA-96 F1).
140
+ */
141
+ recordStatus(status, extra, epoch) {
142
+ this.lastStatus = {
143
+ status,
144
+ ...(extra ? { extra } : {}),
145
+ ...(epoch === undefined ? {} : { epoch }),
146
+ };
147
+ this.write({
148
+ kind: 'status',
149
+ status,
150
+ ...(extra ? { extra } : {}),
151
+ ...(epoch === undefined ? {} : { epoch }),
152
+ ts: new Date().toISOString(),
153
+ });
154
+ }
155
+ /** Assign the next seq and persist the event before it is sent. */
156
+ append(eventType, payload) {
157
+ const event = { seq: this.nextSeq++, eventType, payload };
158
+ this.write({ kind: 'event', ...event, ts: new Date().toISOString() });
159
+ this.unackedBySeq.set(event.seq, event);
160
+ return event;
161
+ }
162
+ /**
163
+ * Never reuse a seq the API already stored: after a runner state-dir wipe the
164
+ * local counter restarts at 1 and every replayed event would collide with an
165
+ * existing (sessionId, seq) row and be swallowed as a duplicate — the session
166
+ * would look mute in the dashboard (QA-99 MAJOR-3).
167
+ */
168
+ ensureSeqAbove(lastStoredSeq) {
169
+ if (Number.isFinite(lastStoredSeq) && lastStoredSeq >= this.nextSeq) {
170
+ this.nextSeq = lastStoredSeq + 1;
171
+ }
172
+ }
173
+ ack(seq) {
174
+ if (!this.unackedBySeq.has(seq))
175
+ return;
176
+ this.unackedBySeq.delete(seq);
177
+ this.write({ kind: 'ack', seq });
178
+ // Acks are the point where history becomes dead weight — check here rather
179
+ // than on append so compaction actually drops something.
180
+ this.compactIfLarge();
181
+ }
182
+ unacked() {
183
+ return [...this.unackedBySeq.values()].sort((a, b) => a.seq - b.seq);
184
+ }
185
+ /**
186
+ * Record a message that could not be handed to an agent yet. Persisted before
187
+ * it is queued in memory, so the ordering is "on disk, then held" — a crash
188
+ * between the two costs a duplicate delivery at worst, never a lost message.
189
+ */
190
+ appendPending(text, attachments) {
191
+ const id = `p${this.pendingCounter++}`;
192
+ const record = {
193
+ id,
194
+ text,
195
+ ...(attachments?.length ? { attachments } : {}),
196
+ };
197
+ this.write({
198
+ kind: 'pending',
199
+ id,
200
+ text,
201
+ ...(attachments?.length ? { attachments } : {}),
202
+ ts: new Date().toISOString(),
203
+ });
204
+ this.pendingById.set(id, record);
205
+ return record;
206
+ }
207
+ /** The message reached an agent — stop replaying it after a restart. */
208
+ resolvePending(id) {
209
+ if (!this.pendingById.has(id))
210
+ return;
211
+ this.pendingById.delete(id);
212
+ this.write({ kind: 'pending_done', id });
213
+ }
214
+ /** Messages still waiting, oldest first (ids are minted in order). */
215
+ pending() {
216
+ return [...this.pendingById.values()];
217
+ }
218
+ get lastAssignedSeq() {
219
+ return this.nextSeq - 1;
220
+ }
221
+ /** Delete the journal file — used when a terminal session is fully acked. */
222
+ destroy() {
223
+ fs.rmSync(this.file, { force: true });
224
+ this.unackedBySeq.clear();
225
+ this.pendingById.clear();
226
+ }
227
+ }
228
+ export class JournalStore {
229
+ dir;
230
+ journals = new Map();
231
+ constructor(dir = journalDir()) {
232
+ this.dir = dir;
233
+ }
234
+ open(sessionId) {
235
+ let journal = this.journals.get(sessionId);
236
+ if (!journal) {
237
+ journal = new SessionJournal(sessionId, this.dir);
238
+ this.journals.set(sessionId, journal);
239
+ }
240
+ return journal;
241
+ }
242
+ exists(sessionId) {
243
+ return (this.journals.has(sessionId) || fs.existsSync(path.join(this.dir, `${sessionId}.ndjson`)));
244
+ }
245
+ /** Sessions with journal files on disk (used for redelivery on reconnect). */
246
+ persistedSessionIds() {
247
+ if (!fs.existsSync(this.dir))
248
+ return [];
249
+ return fs
250
+ .readdirSync(this.dir)
251
+ .filter((f) => f.endsWith('.ndjson'))
252
+ .map((f) => f.slice(0, -'.ndjson'.length));
253
+ }
254
+ closeAndDelete(sessionId) {
255
+ const journal = this.journals.get(sessionId);
256
+ if (journal) {
257
+ journal.destroy();
258
+ this.journals.delete(sessionId);
259
+ }
260
+ else {
261
+ fs.rmSync(path.join(this.dir, `${sessionId}.ndjson`), { force: true });
262
+ }
263
+ }
264
+ /**
265
+ * Drop journals of sessions that ended long ago.
266
+ *
267
+ * Two ages, on purpose. A journal with unacked events is the *only* copy of
268
+ * those events, and the recorded terminal status is what gets replayed after
269
+ * a reconnect (QA-96 F1) — so within `maxAgeMs` a non-empty journal is never
270
+ * touched. `hardMaxAgeMs` is the backstop for a session whose events the API
271
+ * will never accept (an org deleted server-side, say), so the directory
272
+ * cannot grow without bound.
273
+ *
274
+ * Returns the ids actually removed.
275
+ */
276
+ prune(options) {
277
+ const now = options.now ?? Date.now();
278
+ const removed = [];
279
+ for (const sessionId of this.persistedSessionIds()) {
280
+ if (options.skip.has(sessionId))
281
+ continue;
282
+ const file = path.join(this.dir, `${sessionId}.ndjson`);
283
+ let ageMs;
284
+ try {
285
+ ageMs = now - fs.statSync(file).mtimeMs;
286
+ }
287
+ catch {
288
+ continue; // vanished under us
289
+ }
290
+ if (ageMs < options.maxAgeMs)
291
+ continue;
292
+ if (ageMs < options.hardMaxAgeMs && this.open(sessionId).unacked().length > 0)
293
+ continue;
294
+ this.closeAndDelete(sessionId);
295
+ removed.push(sessionId);
296
+ }
297
+ return removed;
298
+ }
299
+ }
300
+ //# sourceMappingURL=journal.js.map
package/dist/log.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare const log: {
2
+ debug: (msg: string, extra?: Record<string, unknown>) => void;
3
+ info: (msg: string, extra?: Record<string, unknown>) => void;
4
+ warn: (msg: string, extra?: Record<string, unknown>) => void;
5
+ error: (msg: string, extra?: Record<string, unknown>) => void;
6
+ };
7
+ //# sourceMappingURL=log.d.ts.map
package/dist/log.js ADDED
@@ -0,0 +1,19 @@
1
+ // Minimal stderr logger — the daemon runs under systemd, journald picks stderr up.
2
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
3
+ function currentLevel() {
4
+ const env = (process.env['DEVBRIDGE_RUNNER_LOG'] ?? 'info').toLowerCase();
5
+ return LEVELS[env] ?? LEVELS.info;
6
+ }
7
+ function write(level, msg, extra) {
8
+ if (LEVELS[level] < currentLevel())
9
+ return;
10
+ const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}\n`;
11
+ process.stderr.write(line);
12
+ }
13
+ export const log = {
14
+ debug: (msg, extra) => write('debug', msg, extra),
15
+ info: (msg, extra) => write('info', msg, extra),
16
+ warn: (msg, extra) => write('warn', msg, extra),
17
+ error: (msg, extra) => write('error', msg, extra),
18
+ };
19
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1,7 @@
1
+ export declare function configDir(): string;
2
+ export declare function stateDir(): string;
3
+ export declare function configFilePath(): string;
4
+ export declare function statusFilePath(): string;
5
+ export declare function journalDir(): string;
6
+ export declare function worktreesDir(): string;
7
+ //# sourceMappingURL=paths.d.ts.map
package/dist/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ // XDG-style locations; overridable for tests via DEVBRIDGE_RUNNER_HOME.
4
+ function baseDir() {
5
+ return process.env['DEVBRIDGE_RUNNER_HOME'] ?? null;
6
+ }
7
+ export function configDir() {
8
+ const override = baseDir();
9
+ if (override)
10
+ return path.join(override, 'config');
11
+ const xdg = process.env['XDG_CONFIG_HOME'] ?? path.join(os.homedir(), '.config');
12
+ return path.join(xdg, 'devbridge-runner');
13
+ }
14
+ export function stateDir() {
15
+ const override = baseDir();
16
+ if (override)
17
+ return path.join(override, 'state');
18
+ const xdg = process.env['XDG_STATE_HOME'] ?? path.join(os.homedir(), '.local', 'state');
19
+ return path.join(xdg, 'devbridge-runner');
20
+ }
21
+ export function configFilePath() {
22
+ return path.join(configDir(), 'config.toml');
23
+ }
24
+ export function statusFilePath() {
25
+ return path.join(stateDir(), 'status.json');
26
+ }
27
+ export function journalDir() {
28
+ return path.join(stateDir(), 'journal');
29
+ }
30
+ export function worktreesDir() {
31
+ return path.join(stateDir(), 'worktrees');
32
+ }
33
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,17 @@
1
+ export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
2
+ export interface PolicyContext {
3
+ trustMode: TrustMode;
4
+ /** The session worktree — the only place the agent may write. */
5
+ worktreePath: string;
6
+ }
7
+ export interface PolicyDecision {
8
+ decision: 'allow' | 'deny' | 'ask';
9
+ reason: string;
10
+ }
11
+ export declare function maskString(value: string): string;
12
+ /** Deep-mask every string in a JSON-ish structure (payloads leaving the server). */
13
+ export declare function maskSecrets<T>(value: T): T;
14
+ export declare function isSecretPath(p: string): boolean;
15
+ export declare function isInsideWorktree(p: string, worktreePath: string): boolean;
16
+ export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
17
+ //# sourceMappingURL=policy.d.ts.map
package/dist/policy.js ADDED
@@ -0,0 +1,272 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ // ─── Secret masking (plan §8.7) ──────────────────────────────────────
5
+ const SECRET_PATTERNS = [
6
+ {
7
+ label: 'private-key',
8
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
9
+ },
10
+ { label: 'devbridge-key', re: /db[kr]_[A-Za-z0-9_-]{8,}/g },
11
+ { label: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{8,}/g },
12
+ { label: 'openai-key', re: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
13
+ {
14
+ label: 'github-token',
15
+ re: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}/g,
16
+ },
17
+ { label: 'aws-key-id', re: /\bAKIA[0-9A-Z]{16}\b/g },
18
+ { label: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
19
+ { label: 'bearer', re: /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{16,}=*/g },
20
+ // QA-99 MAJOR-5: registry/CI/cloud tokens that showed up in real repos.
21
+ { label: 'npm-token', re: /\bnpm_[A-Za-z0-9]{20,}\b/g },
22
+ { label: 'gitlab-token', re: /\bglpat-[A-Za-z0-9_-]{16,}\b/g },
23
+ { label: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
24
+ { label: 'google-key', re: /\bAIza[A-Za-z0-9_-]{30,}\b/g },
25
+ { label: 'stripe-key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
26
+ // Credentials embedded in connection strings (postgres://user:pass@host) —
27
+ // the structure is kept so a diff stays readable.
28
+ {
29
+ label: 'url-credentials',
30
+ re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]{3,}@/g,
31
+ to: '$1:[MASKED:url-credentials]@',
32
+ },
33
+ // key=value secrets (.env, config dumps). Deliberately narrow: a value of at
34
+ // least 12 chars behind an obviously secret-looking name.
35
+ {
36
+ label: 'secret-assignment',
37
+ // The negative lookahead keeps an already-masked value from being masked a
38
+ // second time under a less precise label.
39
+ re: /\b([A-Za-z0-9_]*(?:SECRET|PASSWORD|PASSWD|TOKEN|API_?KEY|APIKEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Za-z0-9_]*)(\s*[:=]\s*)['"]?(?!\[MASKED:)[^\s'"]{12,}['"]?/gi,
40
+ to: '$1$2[MASKED:secret-assignment]',
41
+ },
42
+ ];
43
+ export function maskString(value) {
44
+ let masked = value;
45
+ for (const { label, re, to } of SECRET_PATTERNS) {
46
+ masked = masked.replace(re, to ?? `[MASKED:${label}]`);
47
+ }
48
+ return masked;
49
+ }
50
+ /** Deep-mask every string in a JSON-ish structure (payloads leaving the server). */
51
+ export function maskSecrets(value) {
52
+ if (typeof value === 'string')
53
+ return maskString(value);
54
+ if (Array.isArray(value))
55
+ return value.map((v) => maskSecrets(v));
56
+ if (value && typeof value === 'object') {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(value)) {
59
+ out[k] = maskSecrets(v);
60
+ }
61
+ return out;
62
+ }
63
+ return value;
64
+ }
65
+ // ─── Path rules ──────────────────────────────────────────────────────
66
+ // Files that must never be read or written, wherever they live.
67
+ const SECRET_PATH_PATTERNS = [
68
+ /(^|\/)\.env(\.[A-Za-z0-9._-]+)?$/,
69
+ /(^|\/)\.ssh(\/|$)/,
70
+ /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/,
71
+ /\.pem$/,
72
+ /(^|\/)\.aws\/credentials$/,
73
+ /(^|\/)\.claude\/\.credentials\.json$/,
74
+ /(^|\/)\.codex\/auth\.json$/,
75
+ /(^|\/)devbridge-runner\/config\.toml$/,
76
+ /(^|\/)(shadow|passwd|sudoers)$/,
77
+ /(^|\/)\.netrc$/,
78
+ /(^|\/)\.git-credentials$/,
79
+ // QA-99 MAJOR-5: the human-facing Files/Changes panels used to be laxer than
80
+ // the agent's own command denylist — these are now blocked for both.
81
+ /(^|\/)\.npmrc$/,
82
+ /(^|\/)\.yarnrc(\.yml)?$/,
83
+ /(^|\/)\.pypirc$/,
84
+ /(^|\/)\.docker\/config\.json$/,
85
+ /(^|\/)\.kube(\/|$)/,
86
+ /(^|\/)\.gnupg(\/|$)/,
87
+ /(^|\/)\.aws(\/|$)/,
88
+ /(^|\/)\.pgpass$/,
89
+ /(^|\/)\.my\.cnf$/,
90
+ /(^|\/)\.htpasswd$/,
91
+ /(^|\/)\.terraformrc$/,
92
+ /(^|\/)terraform\.tfstate(\.backup)?$/,
93
+ /(^|\/)[^/]*credentials[^/]*\.(json|ya?ml|ini|txt)$/i,
94
+ /\.(key|p12|pfx|jks|keystore)$/i,
95
+ ];
96
+ function normalize(p, cwd) {
97
+ const expanded = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
98
+ const resolved = path.resolve(cwd, expanded);
99
+ // Resolve symlinks so a link inside the worktree can't smuggle a read of
100
+ // ~/.aws & co. past the containment check (QA-96 F13).
101
+ try {
102
+ return fs.realpathSync(resolved);
103
+ }
104
+ catch {
105
+ // Target doesn't exist yet (e.g. a new Write) — still resolve the parent.
106
+ try {
107
+ return path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved));
108
+ }
109
+ catch {
110
+ return resolved;
111
+ }
112
+ }
113
+ }
114
+ export function isSecretPath(p) {
115
+ return SECRET_PATH_PATTERNS.some((re) => re.test(p));
116
+ }
117
+ export function isInsideWorktree(p, worktreePath) {
118
+ const rel = path.relative(path.resolve(worktreePath), p);
119
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
120
+ }
121
+ // ─── Command rules (Bash tool) ───────────────────────────────────────
122
+ // String-level checks can never be a perfect shell parser (QA-96 F8/F9), so
123
+ // the model is: deny patterns run against BOTH the raw command and a
124
+ // "dequoted" copy (quote-splitting like `cat "/etc/pass""wd"` collapses),
125
+ // and the NORMAL-mode safe list applies ONLY to commands with no shell
126
+ // metacharacters at all — anything with ; & | $( ` > < newline falls
127
+ // through to "ask". A prompt-injected agent can therefore at worst ask.
128
+ // `git <flags> push` — flags like -C/-c must not hide the subcommand.
129
+ const GIT_PUSH = String.raw `git\s+(?:-\S+\s+|-C\s+\S+\s+|-c\s+\S+\s+)*push`;
130
+ const DENIED_COMMAND_PATTERNS = [
131
+ // `(`/`$(`/backtick before sudo covers command substitution.
132
+ { reason: 'sudo is not allowed', re: /(^|[\s;&|`(])sudo\b/ },
133
+ {
134
+ reason: 'force-push is not allowed',
135
+ re: new RegExp(`${GIT_PUSH}[^;&|]*(\\s--force\\b|\\s-f\\b|\\s\\+\\S+)`),
136
+ },
137
+ // `[\s:]main` also matches refspec form `git push origin HEAD:main`.
138
+ {
139
+ reason: 'push to a protected branch is not allowed',
140
+ re: new RegExp(`${GIT_PUSH}[^;&|]*[\\s:](main|master)\\b`),
141
+ },
142
+ {
143
+ reason: 'service control is not allowed',
144
+ re: /\b(systemctl|service)\s+(stop|disable|mask|restart|kill)\b/,
145
+ },
146
+ { reason: 'host power control is not allowed', re: /\b(shutdown|reboot|poweroff|halt)\b/ },
147
+ {
148
+ reason: 'recursive delete from root is not allowed',
149
+ re: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+["']?\/(\s|$|["'])/,
150
+ },
151
+ {
152
+ reason: 'disk-level tools are not allowed',
153
+ re: /\b(mkfs|dd\s+[^;&|]*of=\/dev\/|fdisk|parted)\b/,
154
+ },
155
+ { reason: 'cron modification is not allowed', re: /\bcrontab\b|\/etc\/cron/ },
156
+ {
157
+ reason: 'user/permission management is not allowed',
158
+ re: /\b(useradd|userdel|usermod|passwd|chown\s+[^;&|]*root)\b/,
159
+ },
160
+ { reason: 'firewall changes are not allowed', re: /\b(iptables|nft|ufw|firewall-cmd)\b/ },
161
+ {
162
+ reason: 'docker control is not allowed',
163
+ re: /\bdocker\s+(stop|rm|kill|restart|compose\s+down)\b/,
164
+ },
165
+ ];
166
+ // Commands considered safe enough to run without asking under NORMAL trust.
167
+ // Interpreters (node/python/…) and find (-exec) are NOT here: they execute
168
+ // arbitrary code. `env` is not here: it dumps the environment (QA-96 F12).
169
+ const SAFE_COMMAND_PATTERNS = [
170
+ /^git\s+(status|diff|log|show|branch|add|commit|stash|restore|checkout|switch|fetch|pull|worktree)\b/,
171
+ /^(ls|cat|head|tail|grep|rg|wc|pwd|echo|which|tsc)\b/,
172
+ // Package managers: everyday subcommands only — publish/login/adduser must ask.
173
+ /^(npm|pnpm|yarn)\s+(install|ci|add|remove|run|test|ls|list|view|why|outdated|build)\b/,
174
+ ];
175
+ // Shell metacharacters that can smuggle a second command past the safe list.
176
+ const SHELL_METACHARS = /[;&|`$<>\n]/;
177
+ // Claude Code's idiomatic commit body — `$(cat <<'EOF' … EOF\n)` with a
178
+ // QUOTED delimiter runs exactly `cat` on a literal heredoc; whitelisting it
179
+ // keeps `git commit` auto-allowed under NORMAL without opening $() in general.
180
+ const SAFE_COMMIT_HEREDOC = /\$\(cat <<'EOF'\n[\s\S]*?\nEOF\n\s*\)/g;
181
+ function isSafeCommand(command) {
182
+ // `a && b` / `a; b` chains are safe only when EVERY segment is safe on its
183
+ // own; any other metacharacter ( | $( ` > < & \n ) disqualifies outright.
184
+ const stripped = command.trim().replace(SAFE_COMMIT_HEREDOC, '""');
185
+ const segments = stripped
186
+ .split(/&&|;/)
187
+ .map((s) => s.trim())
188
+ .filter(Boolean);
189
+ if (segments.length === 0)
190
+ return false;
191
+ return segments.every((segment) => SAFE_COMMAND_PATTERNS.some((re) => re.test(segment)) && !SHELL_METACHARS.test(segment));
192
+ }
193
+ /** Collapse quotes so `"/etc/pass""wd"` and `'.s'sh` match path rules. */
194
+ function dequote(command) {
195
+ return command.replace(/["']/g, '');
196
+ }
197
+ const SECRET_COMMAND_PATTERNS = [
198
+ /\.env\b/,
199
+ /(^|[\s"'/=~])\.ssh\b/,
200
+ /\.pem\b/,
201
+ /\bid_(rsa|ed25519|ecdsa|dsa)\b/,
202
+ /(^|[\s"'/=~])\.aws\b/,
203
+ /(^|[\s"'/=~])\.kube\b/,
204
+ /\.credentials\.json/,
205
+ /\.codex\/auth\.json/,
206
+ /\.docker\/config\.json/,
207
+ /devbridge-runner\/config\.toml/,
208
+ /\/etc\/(shadow|passwd|sudoers)/,
209
+ /\.git-credentials/,
210
+ /\.netrc\b/,
211
+ ];
212
+ function commandMentionsSecretPath(command) {
213
+ return SECRET_COMMAND_PATTERNS.some((re) => re.test(command));
214
+ }
215
+ // ─── Tool-use evaluation ─────────────────────────────────────────────
216
+ const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
217
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
218
+ export function evaluateToolUse(toolName, input, ctx) {
219
+ // DevBridge MCP tools are the agent's job interface — always fine.
220
+ if (toolName.startsWith('mcp__devbridge__')) {
221
+ return { decision: 'allow', reason: 'devbridge mcp' };
222
+ }
223
+ if (toolName === 'Bash') {
224
+ const command = String(input['command'] ?? '');
225
+ // Deny rules run on the raw AND the dequoted command (quote-splitting).
226
+ for (const variant of [command, dequote(command)]) {
227
+ for (const { re, reason } of DENIED_COMMAND_PATTERNS) {
228
+ if (re.test(variant))
229
+ return { decision: 'deny', reason };
230
+ }
231
+ if (commandMentionsSecretPath(variant)) {
232
+ return { decision: 'deny', reason: 'command touches protected secret paths' };
233
+ }
234
+ }
235
+ if (ctx.trustMode === 'STRICT')
236
+ return { decision: 'ask', reason: 'strict mode' };
237
+ if (ctx.trustMode === 'AUTO')
238
+ return { decision: 'allow', reason: 'auto mode' };
239
+ if (isSafeCommand(command)) {
240
+ return { decision: 'allow', reason: 'safe command' };
241
+ }
242
+ return { decision: 'ask', reason: 'command needs approval' };
243
+ }
244
+ if (READ_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName)) {
245
+ const rawPath = String(input['file_path'] ?? input['path'] ?? input['notebook_path'] ?? '');
246
+ const resolved = rawPath ? normalize(rawPath, ctx.worktreePath) : ctx.worktreePath;
247
+ if (isSecretPath(resolved)) {
248
+ return { decision: 'deny', reason: 'protected secret path' };
249
+ }
250
+ if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
251
+ return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
252
+ }
253
+ if (ctx.trustMode === 'STRICT')
254
+ return { decision: 'ask', reason: 'strict mode' };
255
+ if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
256
+ return ctx.trustMode === 'AUTO'
257
+ ? { decision: 'allow', reason: 'auto mode' }
258
+ : { decision: 'ask', reason: 'read outside the worktree' };
259
+ }
260
+ return { decision: 'allow', reason: 'inside worktree' };
261
+ }
262
+ if (toolName === 'WebFetch' || toolName === 'WebSearch') {
263
+ if (ctx.trustMode === 'STRICT')
264
+ return { decision: 'ask', reason: 'strict mode' };
265
+ return { decision: 'allow', reason: 'network read' };
266
+ }
267
+ // Unknown tools: ask unless the workspace is fully trusted.
268
+ if (ctx.trustMode === 'AUTO')
269
+ return { decision: 'allow', reason: 'auto mode' };
270
+ return { decision: 'ask', reason: `unrecognized tool ${toolName}` };
271
+ }
272
+ //# sourceMappingURL=policy.js.map