@bridge4dev/runner 0.27.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +391 -16
- package/dist/adapters/codex.js +187 -6
- package/dist/adapters/types.d.ts +115 -4
- package/dist/adapters/types.js +31 -0
- package/dist/attachments.d.ts +27 -0
- package/dist/attachments.js +150 -8
- package/dist/checkpoints.d.ts +175 -0
- package/dist/checkpoints.js +816 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -0
- package/dist/index.js +30 -0
- package/dist/journal.d.ts +34 -1
- package/dist/journal.js +51 -2
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +12 -0
- package/dist/policy.d.ts +40 -0
- package/dist/policy.js +60 -6
- package/dist/protocol.d.ts +5 -5
- package/dist/supervisor.d.ts +90 -0
- package/dist/supervisor.js +692 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -64,6 +64,25 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
64
64
|
}, {
|
|
65
65
|
enabled?: boolean | undefined;
|
|
66
66
|
}>>;
|
|
67
|
+
/**
|
|
68
|
+
* Ticket #126: the machine owner's veto over restore points.
|
|
69
|
+
*
|
|
70
|
+
* A restore point is a copy of the working tree, kept on this machine, in an
|
|
71
|
+
* object store of the runner's own. That is disk this server's owner pays
|
|
72
|
+
* for and content they may not want duplicated at all — so the same rule as
|
|
73
|
+
* `[verify]` applies: `enabled = false` means the capability is not
|
|
74
|
+
* announced, the dashboard draws no rewind action, and no snapshot is ever
|
|
75
|
+
* taken. Secrets are excluded from a checkpoint in any case (`policy.ts`
|
|
76
|
+
* decides what counts), but "excluded" is a promise, and switching the whole
|
|
77
|
+
* thing off is a fact.
|
|
78
|
+
*/
|
|
79
|
+
checkpoints: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
81
|
+
}, "strip", z.ZodTypeAny, {
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
}, {
|
|
84
|
+
enabled?: boolean | undefined;
|
|
85
|
+
}>>;
|
|
67
86
|
}, "strip", z.ZodTypeAny, {
|
|
68
87
|
api: {
|
|
69
88
|
url: string;
|
|
@@ -74,6 +93,9 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
74
93
|
name: string;
|
|
75
94
|
token: string;
|
|
76
95
|
};
|
|
96
|
+
checkpoints?: {
|
|
97
|
+
enabled: boolean;
|
|
98
|
+
} | undefined;
|
|
77
99
|
mcp?: {
|
|
78
100
|
url: string;
|
|
79
101
|
token: string;
|
|
@@ -97,6 +119,9 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
97
119
|
name: string;
|
|
98
120
|
token: string;
|
|
99
121
|
};
|
|
122
|
+
checkpoints?: {
|
|
123
|
+
enabled?: boolean | undefined;
|
|
124
|
+
} | undefined;
|
|
100
125
|
mcp?: {
|
|
101
126
|
url: string;
|
|
102
127
|
token: string;
|
package/dist/config.js
CHANGED
|
@@ -59,6 +59,23 @@ const ConfigSchema = z.object({
|
|
|
59
59
|
enabled: z.boolean().default(true),
|
|
60
60
|
})
|
|
61
61
|
.optional(),
|
|
62
|
+
/**
|
|
63
|
+
* Ticket #126: the machine owner's veto over restore points.
|
|
64
|
+
*
|
|
65
|
+
* A restore point is a copy of the working tree, kept on this machine, in an
|
|
66
|
+
* object store of the runner's own. That is disk this server's owner pays
|
|
67
|
+
* for and content they may not want duplicated at all — so the same rule as
|
|
68
|
+
* `[verify]` applies: `enabled = false` means the capability is not
|
|
69
|
+
* announced, the dashboard draws no rewind action, and no snapshot is ever
|
|
70
|
+
* taken. Secrets are excluded from a checkpoint in any case (`policy.ts`
|
|
71
|
+
* decides what counts), but "excluded" is a promise, and switching the whole
|
|
72
|
+
* thing off is a fact.
|
|
73
|
+
*/
|
|
74
|
+
checkpoints: z
|
|
75
|
+
.object({
|
|
76
|
+
enabled: z.boolean().default(true),
|
|
77
|
+
})
|
|
78
|
+
.optional(),
|
|
62
79
|
});
|
|
63
80
|
export function loadConfig() {
|
|
64
81
|
const file = configFilePath();
|
package/dist/index.js
CHANGED
|
@@ -116,6 +116,8 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
116
116
|
// feature is that a DevBridge manager approves every command first — but the
|
|
117
117
|
// person who owns the server gets the last word on whether it exists here.
|
|
118
118
|
const verifyEnabled = config?.verify?.enabled !== false;
|
|
119
|
+
// Ticket #126: the same veto, for restore points. Default on.
|
|
120
|
+
const checkpointsEnabled = config?.checkpoints?.enabled !== false;
|
|
119
121
|
return {
|
|
120
122
|
agents: installedAgents(),
|
|
121
123
|
git: true,
|
|
@@ -231,6 +233,22 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
231
233
|
...(verifyEnabled ? { previewWorktrees: true } : {}),
|
|
232
234
|
/** Session 14: one-shot agent-written commit messages. */
|
|
233
235
|
commitMessages: true,
|
|
236
|
+
/**
|
|
237
|
+
* Ticket #126: restore points for the working tree, and the rewind that
|
|
238
|
+
* uses them. Withheld — not reported as false — when the machine's owner
|
|
239
|
+
* switched them off, by the same rule as `verifyRuns`.
|
|
240
|
+
*/
|
|
241
|
+
...(checkpointsEnabled
|
|
242
|
+
? { sessionCheckpoints: true }
|
|
243
|
+
: { checkpointsBlocked: 'disabled-by-config' }),
|
|
244
|
+
/**
|
|
245
|
+
* Ticket #126: rewinding what the AGENT remembers, and `/compact` on
|
|
246
|
+
* demand. Both agents can do both — Claude through `resumeSessionAt` +
|
|
247
|
+
* `forkSession`, Codex through the stable `thread/fork { lastTurnId }` and
|
|
248
|
+
* `thread/compact/start` of app-server 0.145.0.
|
|
249
|
+
*/
|
|
250
|
+
contextRewind: true,
|
|
251
|
+
contextCompaction: true,
|
|
234
252
|
/**
|
|
235
253
|
* Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
|
|
236
254
|
* the panel can say what is blocking an Apply instead of offering a button
|
|
@@ -321,6 +339,17 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
321
339
|
'git_merge_abort',
|
|
322
340
|
'recipe_state',
|
|
323
341
|
'propose_commit_message',
|
|
342
|
+
'recall_message',
|
|
343
|
+
'compact_context',
|
|
344
|
+
...(checkpointsEnabled
|
|
345
|
+
? [
|
|
346
|
+
'session_checkpoint',
|
|
347
|
+
'session_checkpoints',
|
|
348
|
+
'session_rewind_preview',
|
|
349
|
+
'session_rewind_files',
|
|
350
|
+
'context_rewind',
|
|
351
|
+
]
|
|
352
|
+
: []),
|
|
324
353
|
...(verifyEnabled
|
|
325
354
|
? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
|
|
326
355
|
: []),
|
|
@@ -485,6 +514,7 @@ async function cmdDaemon() {
|
|
|
485
514
|
// The same veto the capability list honours — announced AND enforced, so a
|
|
486
515
|
// frame from an API that has not noticed still cannot start a run.
|
|
487
516
|
verifyEnabled: config.verify?.enabled !== false,
|
|
517
|
+
checkpointsEnabled: config.checkpoints?.enabled !== false,
|
|
488
518
|
apiUrl: config.api.url,
|
|
489
519
|
// Used to fetch the files a user attaches to a message (session 10) — the
|
|
490
520
|
// same token the WS connection authenticates with, never passed onwards.
|
package/dist/journal.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface PendingMessage {
|
|
|
20
20
|
* session worktree is guaranteed to exist.
|
|
21
21
|
*/
|
|
22
22
|
attachments?: PendingAttachment[];
|
|
23
|
+
/**
|
|
24
|
+
* Feed seq of the `message` event this text was echoed under (ticket #125).
|
|
25
|
+
*
|
|
26
|
+
* The only identifier the queue and the browser share: the API never sees
|
|
27
|
+
* `id` (it is minted here), and the `session_message` frame carries no id of
|
|
28
|
+
* its own — so the seq of the bubble the user is looking at is what a recall
|
|
29
|
+
* has to name. Absent on records written by a runner older than 0.28.0:
|
|
30
|
+
* those cannot be recalled, which is correct — they predate the feature.
|
|
31
|
+
*/
|
|
32
|
+
seq?: number;
|
|
23
33
|
}
|
|
24
34
|
export interface PendingAttachment {
|
|
25
35
|
id: string;
|
|
@@ -43,6 +53,11 @@ export declare class SessionJournal {
|
|
|
43
53
|
extra?: Record<string, unknown>;
|
|
44
54
|
epoch?: number;
|
|
45
55
|
} | null;
|
|
56
|
+
/** The agent's conversation tip, and the provider session it lives in. */
|
|
57
|
+
lastAnchor: {
|
|
58
|
+
anchor: string;
|
|
59
|
+
providerSessionId: string;
|
|
60
|
+
} | null;
|
|
46
61
|
constructor(sessionId: string, dir?: string);
|
|
47
62
|
private replay;
|
|
48
63
|
private write;
|
|
@@ -60,6 +75,14 @@ export declare class SessionJournal {
|
|
|
60
75
|
* Statuses are fire-and-forget on the wire; journaling the latest one lets
|
|
61
76
|
* the supervisor re-report it after a reconnect (QA-96 F1).
|
|
62
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* Remember the conversation tip across a process gap (ticket #126).
|
|
80
|
+
*
|
|
81
|
+
* Written only when it actually moves: an agent that says nothing writes
|
|
82
|
+
* nothing, and the same line repeated every turn would grow the file for no
|
|
83
|
+
* reason.
|
|
84
|
+
*/
|
|
85
|
+
recordAnchor(anchor: string, providerSessionId: string): void;
|
|
63
86
|
recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
|
|
64
87
|
/** Assign the next seq and persist the event before it is sent. */
|
|
65
88
|
append(eventType: string, payload: Record<string, unknown>): JournalEvent;
|
|
@@ -77,9 +100,19 @@ export declare class SessionJournal {
|
|
|
77
100
|
* it is queued in memory, so the ordering is "on disk, then held" — a crash
|
|
78
101
|
* between the two costs a duplicate delivery at worst, never a lost message.
|
|
79
102
|
*/
|
|
80
|
-
appendPending(text: string, attachments?: PendingAttachment[]): PendingMessage;
|
|
103
|
+
appendPending(text: string, attachments?: PendingAttachment[], seq?: number): PendingMessage;
|
|
81
104
|
/** The message reached an agent — stop replaying it after a restart. */
|
|
82
105
|
resolvePending(id: string): void;
|
|
106
|
+
/**
|
|
107
|
+
* The user recalled the message before any agent saw it (ticket #125).
|
|
108
|
+
*
|
|
109
|
+
* Returns false when the record is already gone — which is the answer to
|
|
110
|
+
* "did I win the race", and the only safe way to ask it: the caller must
|
|
111
|
+
* report `already_delivered` rather than a success it cannot back up.
|
|
112
|
+
*/
|
|
113
|
+
cancelPending(id: string): boolean;
|
|
114
|
+
/** The queued record echoed under this feed seq, if it is still queued. */
|
|
115
|
+
findPendingBySeq(seq: number): PendingMessage | undefined;
|
|
83
116
|
/** Messages still waiting, oldest first (ids are minted in order). */
|
|
84
117
|
pending(): PendingMessage[];
|
|
85
118
|
get lastAssignedSeq(): number;
|
package/dist/journal.js
CHANGED
|
@@ -19,6 +19,8 @@ export class SessionJournal {
|
|
|
19
19
|
bytesOnDisk = 0;
|
|
20
20
|
/** Last status reported for this session — replayed after a reconnect. */
|
|
21
21
|
lastStatus = null;
|
|
22
|
+
/** The agent's conversation tip, and the provider session it lives in. */
|
|
23
|
+
lastAnchor = null;
|
|
22
24
|
constructor(sessionId, dir = journalDir()) {
|
|
23
25
|
this.sessionId = sessionId;
|
|
24
26
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -52,6 +54,9 @@ export class SessionJournal {
|
|
|
52
54
|
else if (parsed.kind === 'ack') {
|
|
53
55
|
this.unackedBySeq.delete(parsed.seq);
|
|
54
56
|
}
|
|
57
|
+
else if (parsed.kind === 'anchor') {
|
|
58
|
+
this.lastAnchor = { anchor: parsed.anchor, providerSessionId: parsed.providerSessionId };
|
|
59
|
+
}
|
|
55
60
|
else if (parsed.kind === 'status') {
|
|
56
61
|
this.lastStatus = {
|
|
57
62
|
status: parsed.status,
|
|
@@ -64,6 +69,7 @@ export class SessionJournal {
|
|
|
64
69
|
id: parsed.id,
|
|
65
70
|
text: parsed.text,
|
|
66
71
|
...(parsed.attachments?.length ? { attachments: parsed.attachments } : {}),
|
|
72
|
+
...(typeof parsed.seq === 'number' ? { seq: parsed.seq } : {}),
|
|
67
73
|
});
|
|
68
74
|
// Ids are `p<n>`; keep the counter above whatever the file holds so a
|
|
69
75
|
// restarted runner cannot mint an id that is already in flight.
|
|
@@ -71,7 +77,7 @@ export class SessionJournal {
|
|
|
71
77
|
if (Number.isFinite(n) && n >= this.pendingCounter)
|
|
72
78
|
this.pendingCounter = n + 1;
|
|
73
79
|
}
|
|
74
|
-
else if (parsed.kind === 'pending_done') {
|
|
80
|
+
else if (parsed.kind === 'pending_done' || parsed.kind === 'pending_cancelled') {
|
|
75
81
|
this.pendingById.delete(parsed.id);
|
|
76
82
|
}
|
|
77
83
|
else if (parsed.kind === 'seq') {
|
|
@@ -98,6 +104,9 @@ export class SessionJournal {
|
|
|
98
104
|
for (const event of this.unacked()) {
|
|
99
105
|
lines.push({ kind: 'event', ...event, ts: new Date().toISOString() });
|
|
100
106
|
}
|
|
107
|
+
if (this.lastAnchor) {
|
|
108
|
+
lines.push({ kind: 'anchor', ...this.lastAnchor });
|
|
109
|
+
}
|
|
101
110
|
if (this.lastStatus) {
|
|
102
111
|
lines.push({
|
|
103
112
|
kind: 'status',
|
|
@@ -115,6 +124,7 @@ export class SessionJournal {
|
|
|
115
124
|
id: record.id,
|
|
116
125
|
text: record.text,
|
|
117
126
|
...(record.attachments?.length ? { attachments: record.attachments } : {}),
|
|
127
|
+
...(record.seq === undefined ? {} : { seq: record.seq }),
|
|
118
128
|
ts: new Date().toISOString(),
|
|
119
129
|
});
|
|
120
130
|
}
|
|
@@ -138,6 +148,21 @@ export class SessionJournal {
|
|
|
138
148
|
* Statuses are fire-and-forget on the wire; journaling the latest one lets
|
|
139
149
|
* the supervisor re-report it after a reconnect (QA-96 F1).
|
|
140
150
|
*/
|
|
151
|
+
/**
|
|
152
|
+
* Remember the conversation tip across a process gap (ticket #126).
|
|
153
|
+
*
|
|
154
|
+
* Written only when it actually moves: an agent that says nothing writes
|
|
155
|
+
* nothing, and the same line repeated every turn would grow the file for no
|
|
156
|
+
* reason.
|
|
157
|
+
*/
|
|
158
|
+
recordAnchor(anchor, providerSessionId) {
|
|
159
|
+
if (this.lastAnchor?.anchor === anchor &&
|
|
160
|
+
this.lastAnchor.providerSessionId === providerSessionId) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.lastAnchor = { anchor, providerSessionId };
|
|
164
|
+
this.write({ kind: 'anchor', anchor, providerSessionId });
|
|
165
|
+
}
|
|
141
166
|
recordStatus(status, extra, epoch) {
|
|
142
167
|
this.lastStatus = {
|
|
143
168
|
status,
|
|
@@ -187,18 +212,20 @@ export class SessionJournal {
|
|
|
187
212
|
* it is queued in memory, so the ordering is "on disk, then held" — a crash
|
|
188
213
|
* between the two costs a duplicate delivery at worst, never a lost message.
|
|
189
214
|
*/
|
|
190
|
-
appendPending(text, attachments) {
|
|
215
|
+
appendPending(text, attachments, seq) {
|
|
191
216
|
const id = `p${this.pendingCounter++}`;
|
|
192
217
|
const record = {
|
|
193
218
|
id,
|
|
194
219
|
text,
|
|
195
220
|
...(attachments?.length ? { attachments } : {}),
|
|
221
|
+
...(seq === undefined ? {} : { seq }),
|
|
196
222
|
};
|
|
197
223
|
this.write({
|
|
198
224
|
kind: 'pending',
|
|
199
225
|
id,
|
|
200
226
|
text,
|
|
201
227
|
...(attachments?.length ? { attachments } : {}),
|
|
228
|
+
...(seq === undefined ? {} : { seq }),
|
|
202
229
|
ts: new Date().toISOString(),
|
|
203
230
|
});
|
|
204
231
|
this.pendingById.set(id, record);
|
|
@@ -211,6 +238,28 @@ export class SessionJournal {
|
|
|
211
238
|
this.pendingById.delete(id);
|
|
212
239
|
this.write({ kind: 'pending_done', id });
|
|
213
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The user recalled the message before any agent saw it (ticket #125).
|
|
243
|
+
*
|
|
244
|
+
* Returns false when the record is already gone — which is the answer to
|
|
245
|
+
* "did I win the race", and the only safe way to ask it: the caller must
|
|
246
|
+
* report `already_delivered` rather than a success it cannot back up.
|
|
247
|
+
*/
|
|
248
|
+
cancelPending(id) {
|
|
249
|
+
if (!this.pendingById.has(id))
|
|
250
|
+
return false;
|
|
251
|
+
this.pendingById.delete(id);
|
|
252
|
+
this.write({ kind: 'pending_cancelled', id });
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/** The queued record echoed under this feed seq, if it is still queued. */
|
|
256
|
+
findPendingBySeq(seq) {
|
|
257
|
+
for (const record of this.pendingById.values()) {
|
|
258
|
+
if (record.seq === seq)
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
214
263
|
/** Messages still waiting, oldest first (ids are minted in order). */
|
|
215
264
|
pending() {
|
|
216
265
|
return [...this.pendingById.values()];
|
package/dist/paths.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export declare function worktreesDir(): string;
|
|
|
22
22
|
export declare function knownWorkspacesPath(): string;
|
|
23
23
|
/** Session 14: the single preview checkout per repository. */
|
|
24
24
|
export declare function previewsDir(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Ticket #126: git object stores holding the sessions' restore points.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately under `devbridge-runner/`, and deliberately NOT under
|
|
29
|
+
* `worktrees/` or `previews/` — those two are excluded from the runner-tree
|
|
30
|
+
* rule in `SECRET_PATH_PATTERNS` (the agent has to be able to read its own
|
|
31
|
+
* workspace), so anything that must stay unreadable to the agent belongs
|
|
32
|
+
* exactly here.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkpointsDir(): string;
|
|
25
35
|
/**
|
|
26
36
|
* Per-session MCP config files (ticket #119).
|
|
27
37
|
*
|
package/dist/paths.js
CHANGED
|
@@ -54,6 +54,18 @@ export function knownWorkspacesPath() {
|
|
|
54
54
|
export function previewsDir() {
|
|
55
55
|
return path.join(stateDir(), 'previews');
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Ticket #126: git object stores holding the sessions' restore points.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately under `devbridge-runner/`, and deliberately NOT under
|
|
61
|
+
* `worktrees/` or `previews/` — those two are excluded from the runner-tree
|
|
62
|
+
* rule in `SECRET_PATH_PATTERNS` (the agent has to be able to read its own
|
|
63
|
+
* workspace), so anything that must stay unreadable to the agent belongs
|
|
64
|
+
* exactly here.
|
|
65
|
+
*/
|
|
66
|
+
export function checkpointsDir() {
|
|
67
|
+
return path.join(stateDir(), 'checkpoints');
|
|
68
|
+
}
|
|
57
69
|
/**
|
|
58
70
|
* Per-session MCP config files (ticket #119).
|
|
59
71
|
*
|
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
|
/**
|
|
@@ -76,5 +90,31 @@ export interface RecipeCommandDecision {
|
|
|
76
90
|
* nowhere else. Without the name, the refusal stands and says why.
|
|
77
91
|
*/
|
|
78
92
|
export declare function evaluateRecipeCommand(command: string, ctx?: RecipeCommandContext): RecipeCommandDecision;
|
|
93
|
+
/**
|
|
94
|
+
* Fold the workspace's trust level and the session's mode into the one value
|
|
95
|
+
* the rules below actually consult (ticket #156).
|
|
96
|
+
*
|
|
97
|
+
* Three rules, and each is a sentence:
|
|
98
|
+
*
|
|
99
|
+
* 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
|
|
100
|
+
* button mean what it says: Claude's own Auto does not stop to ask about
|
|
101
|
+
* `grep … | head`, and neither does this one any more. The hard denials
|
|
102
|
+
* below — `sudo`, `git push`, docker control, secret paths, writes outside
|
|
103
|
+
* the worktree, anything inside `.git` — are not part of the deal and are
|
|
104
|
+
* checked before this value is ever read.
|
|
105
|
+
* 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
|
|
106
|
+
* «Ask first» used to ask about nothing at all, which is a label that lies.
|
|
107
|
+
* This is the one direction that is STRICTER than before, and it is strict
|
|
108
|
+
* only where the old behaviour contradicted the word on the control.
|
|
109
|
+
* 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
|
|
110
|
+
* STRICT exists: a manager sets it on the workspace, and no session-level
|
|
111
|
+
* dial may spend it. `full` is refused outright on such a workspace — see
|
|
112
|
+
* `availableModes` in the adapters, because in `bypassPermissions` this
|
|
113
|
+
* function is never called at all and a shield nobody consults is no shield.
|
|
114
|
+
*
|
|
115
|
+
* `mode === undefined` (an API or runner from before this release) returns the
|
|
116
|
+
* workspace trust unchanged, which is exactly the old behaviour.
|
|
117
|
+
*/
|
|
118
|
+
export declare function effectiveTrust(trustMode: TrustMode, mode?: AgentMode): TrustMode;
|
|
79
119
|
export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
|
|
80
120
|
//# sourceMappingURL=policy.d.ts.map
|
package/dist/policy.js
CHANGED
|
@@ -97,6 +97,16 @@ const SECRET_PATH_PATTERNS = [
|
|
|
97
97
|
// its own workspace — caught by the existing suite the moment it was tried.
|
|
98
98
|
/(^|\/)devbridge-mcp\.[^/]*\.json$/,
|
|
99
99
|
/(^|\/)devbridge-runner(\/(?!worktrees(\/|$)|previews(\/|$))|$)/,
|
|
100
|
+
// Ticket #126: the object store holding this session's restore points — a
|
|
101
|
+
// full copy of the working tree as it was at every turn.
|
|
102
|
+
//
|
|
103
|
+
// The runner-directory rule above already covers it on an ordinary install.
|
|
104
|
+
// This one is for the case that rule cannot see: under a
|
|
105
|
+
// `DEVBRIDGE_RUNNER_HOME` override the path carries no `devbridge-runner`
|
|
106
|
+
// segment at all, which is exactly the gap the MCP-config entry was added
|
|
107
|
+
// for. Matching the distinctive `checkpoints/<hash>.git` shape closes it
|
|
108
|
+
// wherever the state directory ends up.
|
|
109
|
+
/(^|\/)checkpoints\/[0-9a-f]{8,}\.git(\/|$)/,
|
|
100
110
|
/(^|\/)(shadow|passwd|sudoers)$/,
|
|
101
111
|
/(^|\/)\.netrc$/,
|
|
102
112
|
/(^|\/)\.git-credentials$/,
|
|
@@ -440,6 +450,11 @@ const SECRET_COMMAND_PATTERNS = [
|
|
|
440
450
|
// never contains the suffix.
|
|
441
451
|
/devbridge-mcp/,
|
|
442
452
|
/devbridge-runner\/(?!worktrees|previews)/,
|
|
453
|
+
// Ticket #126 — the command form of the restore-point entry above. Written
|
|
454
|
+
// as its own rule for the same reason gotcha #110 exists: a path in one
|
|
455
|
+
// secret table and not the other is theatre, and `git --git-dir=…` /
|
|
456
|
+
// `cat …/checkpoints/<hash>.git/…` walks past a Read-tool guard entirely.
|
|
457
|
+
/checkpoints\/[0-9a-f]{8,}\.git/,
|
|
443
458
|
/devbridge-runner(?=\s|$|['"])/,
|
|
444
459
|
/\/etc\/(shadow|passwd|sudoers)/,
|
|
445
460
|
/\.git-credentials/,
|
|
@@ -587,7 +602,46 @@ const AGENT_COMMIT = new RegExp(String.raw `\bgit\s+${FLAGS}commit\b`);
|
|
|
587
602
|
// ─── Tool-use evaluation ─────────────────────────────────────────────
|
|
588
603
|
const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
|
|
589
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
|
+
}
|
|
590
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);
|
|
591
645
|
// DevBridge MCP tools are the agent's job interface — always fine.
|
|
592
646
|
if (toolName.startsWith('mcp__devbridge__')) {
|
|
593
647
|
return { decision: 'allow', reason: 'devbridge mcp' };
|
|
@@ -618,9 +672,9 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
618
672
|
}
|
|
619
673
|
}
|
|
620
674
|
}
|
|
621
|
-
if (
|
|
675
|
+
if (trust === 'STRICT')
|
|
622
676
|
return { decision: 'ask', reason: 'strict mode' };
|
|
623
|
-
if (
|
|
677
|
+
if (trust === 'AUTO')
|
|
624
678
|
return { decision: 'allow', reason: 'auto mode' };
|
|
625
679
|
if (isSafeCommand(command)) {
|
|
626
680
|
return { decision: 'allow', reason: 'safe command' };
|
|
@@ -652,22 +706,22 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
652
706
|
if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
653
707
|
return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
|
|
654
708
|
}
|
|
655
|
-
if (
|
|
709
|
+
if (trust === 'STRICT')
|
|
656
710
|
return { decision: 'ask', reason: 'strict mode' };
|
|
657
711
|
if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
658
|
-
return
|
|
712
|
+
return trust === 'AUTO'
|
|
659
713
|
? { decision: 'allow', reason: 'auto mode' }
|
|
660
714
|
: { decision: 'ask', reason: 'read outside the worktree' };
|
|
661
715
|
}
|
|
662
716
|
return { decision: 'allow', reason: 'inside worktree' };
|
|
663
717
|
}
|
|
664
718
|
if (toolName === 'WebFetch' || toolName === 'WebSearch') {
|
|
665
|
-
if (
|
|
719
|
+
if (trust === 'STRICT')
|
|
666
720
|
return { decision: 'ask', reason: 'strict mode' };
|
|
667
721
|
return { decision: 'allow', reason: 'network read' };
|
|
668
722
|
}
|
|
669
723
|
// Unknown tools: ask unless the workspace is fully trusted.
|
|
670
|
-
if (
|
|
724
|
+
if (trust === 'AUTO')
|
|
671
725
|
return { decision: 'allow', reason: 'auto mode' };
|
|
672
726
|
return { decision: 'ask', reason: `unrecognized tool ${toolName}` };
|
|
673
727
|
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -126,8 +126,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
126
126
|
model: string | null;
|
|
127
127
|
effort: string | null;
|
|
128
128
|
prompt: string;
|
|
129
|
-
epoch: number;
|
|
130
129
|
providerSessionId: string | null;
|
|
130
|
+
epoch: number;
|
|
131
131
|
lastSeq: number;
|
|
132
132
|
costUsd: number;
|
|
133
133
|
activeMsBase: number;
|
|
@@ -328,8 +328,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
328
328
|
model: string | null;
|
|
329
329
|
effort: string | null;
|
|
330
330
|
prompt: string;
|
|
331
|
-
epoch: number;
|
|
332
331
|
providerSessionId: string | null;
|
|
332
|
+
epoch: number;
|
|
333
333
|
lastSeq: number;
|
|
334
334
|
costUsd: number;
|
|
335
335
|
activeMsBase: number;
|
|
@@ -410,8 +410,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
410
410
|
model: string | null;
|
|
411
411
|
effort: string | null;
|
|
412
412
|
prompt: string;
|
|
413
|
-
epoch: number;
|
|
414
413
|
providerSessionId: string | null;
|
|
414
|
+
epoch: number;
|
|
415
415
|
lastSeq: number;
|
|
416
416
|
costUsd: number;
|
|
417
417
|
activeMsBase: number;
|
|
@@ -642,8 +642,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
642
642
|
model: string | null;
|
|
643
643
|
effort: string | null;
|
|
644
644
|
prompt: string;
|
|
645
|
-
epoch: number;
|
|
646
645
|
providerSessionId: string | null;
|
|
646
|
+
epoch: number;
|
|
647
647
|
lastSeq: number;
|
|
648
648
|
costUsd: number;
|
|
649
649
|
activeMsBase: number;
|
|
@@ -722,8 +722,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
722
722
|
model: string | null;
|
|
723
723
|
effort: string | null;
|
|
724
724
|
prompt: string;
|
|
725
|
-
epoch: number;
|
|
726
725
|
providerSessionId: string | null;
|
|
726
|
+
epoch: number;
|
|
727
727
|
lastSeq: number;
|
|
728
728
|
costUsd: number;
|
|
729
729
|
activeMsBase: number;
|