@yolo-labs/yolobridge 0.18.0 → 0.19.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/approved-paths.js +277 -0
- package/dist/cli.js +15 -0
- package/dist/status-cmd.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval list that bounds which paths the ATTACHED AGENT will send files
|
|
3
|
+
* from.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ READ THIS BEFORE RELYING ON IT: THIS IS NOT A SECURITY BOUNDARY.
|
|
6
|
+
*
|
|
7
|
+
* The attached agent runs as the SAME OS USER as this CLI and normally has
|
|
8
|
+
* shell access. It can therefore run `yolo-bridge allow /` itself, or simply
|
|
9
|
+
* edit `approved-paths.json`. The principal this list constrains can rewrite
|
|
10
|
+
* the list. No amount of care in the containment checks below changes that —
|
|
11
|
+
* they are checks against a mistake, not against an adversary who is already
|
|
12
|
+
* inside the same trust domain. (Found by codex review, gpt-5.6-sol, before
|
|
13
|
+
* this had any consumer; an earlier version of this comment claimed a
|
|
14
|
+
* "structural invariant" and was wrong.)
|
|
15
|
+
*
|
|
16
|
+
* The same reasoning cuts the other way and is why the feature is still worth
|
|
17
|
+
* having: an agent with a PTY can already `curl -T` any readable file to any
|
|
18
|
+
* host. Nothing here can stop a determined or injected agent from exfiltrating,
|
|
19
|
+
* because it never needed our upload path to do it.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS ACTUALLY BUYS, stated so nobody over-trusts it:
|
|
22
|
+
*
|
|
23
|
+
* - It stops ACCIDENTS. An agent casually sending `~/.aws/credentials`
|
|
24
|
+
* because it seemed relevant is the common case, and this prevents it.
|
|
25
|
+
* - It keeps the credentialed path deliberate. Sending through OUR upload,
|
|
26
|
+
* with the operator's workspace credential, requires an explicit act rather
|
|
27
|
+
* than being the path of least resistance.
|
|
28
|
+
* - It is an AUDIT SURFACE. Approvals are visible in `yolo-bridge status`, so
|
|
29
|
+
* a grant that was widened — by the operator or by an agent — is legible
|
|
30
|
+
* rather than silent.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IT DOES NOT BUY: protection against a cloud-originated prompt injection
|
|
33
|
+
* that instructs the agent to widen the list first. If that threat is the one
|
|
34
|
+
* that matters, the approval has to come from a channel the LOCAL AGENT cannot
|
|
35
|
+
* reach — the operator approving in their own authenticated webapp session
|
|
36
|
+
* would qualify, since an injected local agent cannot click it. That is a
|
|
37
|
+
* different design, deliberately not this one, and it still would not stop
|
|
38
|
+
* `curl`.
|
|
39
|
+
*/
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import { configDir, loadAttachment as loadAttachmentDefault, defaultIO } from './config-store.js';
|
|
43
|
+
function approvalsPath(env) {
|
|
44
|
+
return path.join(configDir(env), 'approved-paths.json');
|
|
45
|
+
}
|
|
46
|
+
export const defaultResolver = {
|
|
47
|
+
realpath(p) {
|
|
48
|
+
try {
|
|
49
|
+
return fs.realpathSync(p);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
isDirectory(p) {
|
|
56
|
+
try {
|
|
57
|
+
return fs.statSync(p).isDirectory();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Is `candidate` inside `root`?
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ BOTH SIDES MUST ALREADY BE REALPATHS. This function does no resolution —
|
|
68
|
+
* that is the caller's job — because the containment check is worthless on
|
|
69
|
+
* unresolved input: a symlink sitting inside an approved directory and pointing
|
|
70
|
+
* at `~/.ssh/id_rsa` passes a naive `startsWith` every time.
|
|
71
|
+
*
|
|
72
|
+
* The separator is not decoration either. Without it an approval of
|
|
73
|
+
* `/home/yolo/projA` also covers `/home/yolo/projA-secrets`.
|
|
74
|
+
*/
|
|
75
|
+
export function isWithinRoot(candidateRealpath, rootRealpath) {
|
|
76
|
+
if (candidateRealpath === rootRealpath)
|
|
77
|
+
return true;
|
|
78
|
+
const root = rootRealpath.endsWith(path.sep) ? rootRealpath : rootRealpath + path.sep;
|
|
79
|
+
return candidateRealpath.startsWith(root);
|
|
80
|
+
}
|
|
81
|
+
export function loadApprovals(env = process.env, io = defaultIO) {
|
|
82
|
+
const raw = io.readFile(approvalsPath(env));
|
|
83
|
+
if (!raw)
|
|
84
|
+
return [];
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (!Array.isArray(parsed?.approvals))
|
|
88
|
+
return [];
|
|
89
|
+
return parsed.approvals.filter((a) => !!a && typeof a.path === 'string' && typeof a.workspaceId === 'string');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A corrupt list must not be read as "everything is approved". Empty is the
|
|
93
|
+
// safe reading, and the operator can re-add.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function saveApprovals(approvals, env = process.env, io = defaultIO) {
|
|
98
|
+
io.writeFile(approvalsPath(env), `${JSON.stringify({ approvals }, null, 2)}\n`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Approve a path for one workspace.
|
|
102
|
+
*
|
|
103
|
+
* Resolved and existence-checked HERE rather than at send time, so the operator
|
|
104
|
+
* finds out about a typo now instead of when a send mysteriously fails.
|
|
105
|
+
*/
|
|
106
|
+
export function approvePath(rawPath, workspaceId, env = process.env, io = defaultIO, resolver = defaultResolver, now = () => new Date()) {
|
|
107
|
+
const resolved = resolver.realpath(path.resolve(rawPath));
|
|
108
|
+
if (!resolved) {
|
|
109
|
+
return { ok: false, message: `No such path: ${rawPath}` };
|
|
110
|
+
}
|
|
111
|
+
const existing = loadApprovals(env, io);
|
|
112
|
+
if (existing.some((a) => a.workspaceId === workspaceId && a.path === resolved)) {
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
alreadyPresent: true,
|
|
116
|
+
approved: existing.find((a) => a.workspaceId === workspaceId && a.path === resolved),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const approved = {
|
|
120
|
+
path: resolved,
|
|
121
|
+
raw: rawPath,
|
|
122
|
+
workspaceId,
|
|
123
|
+
approvedAt: now().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
saveApprovals([...existing, approved], env, io);
|
|
126
|
+
return { ok: true, approved, alreadyPresent: false };
|
|
127
|
+
}
|
|
128
|
+
/** Remove one approval. Matches on the RESOLVED path, so removing by the same
|
|
129
|
+
* spelling the operator approved with works even through a symlink. */
|
|
130
|
+
export function revokePath(rawPath, workspaceId, env = process.env, io = defaultIO, resolver = defaultResolver) {
|
|
131
|
+
// Match on the RESOLVED path only. Matching the stored `raw` spelling as well
|
|
132
|
+
// looked convenient and was data loss: approving `output` from two different
|
|
133
|
+
// working directories stores two distinct resolved paths with the SAME raw
|
|
134
|
+
// spelling, so revoking either removed both. (codex P2, gpt-5.6-sol.)
|
|
135
|
+
//
|
|
136
|
+
// Two candidates, because an approved directory may since have been deleted —
|
|
137
|
+
// realpath then fails, and the operator must still be able to revoke the
|
|
138
|
+
// dangling grant by its absolute path.
|
|
139
|
+
const absolute = path.resolve(rawPath);
|
|
140
|
+
const resolved = resolver.realpath(absolute);
|
|
141
|
+
const before = loadApprovals(env, io);
|
|
142
|
+
const after = before.filter((a) => {
|
|
143
|
+
if (a.workspaceId !== workspaceId)
|
|
144
|
+
return true;
|
|
145
|
+
return !(a.path === absolute || (resolved !== undefined && a.path === resolved));
|
|
146
|
+
});
|
|
147
|
+
if (after.length !== before.length)
|
|
148
|
+
saveApprovals(after, env, io);
|
|
149
|
+
return { removed: before.length - after.length };
|
|
150
|
+
}
|
|
151
|
+
export function listApprovals(workspaceId, env = process.env, io = defaultIO) {
|
|
152
|
+
return loadApprovals(env, io).filter((a) => a.workspaceId === workspaceId);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* May the AGENT send this path?
|
|
156
|
+
*
|
|
157
|
+
* A check against a MISTAKE, not against an adversary — see the module header.
|
|
158
|
+
* The agent can rewrite the list this consults.
|
|
159
|
+
*
|
|
160
|
+
* `implicitRoots` is the daemon's own working directory: the project the agent
|
|
161
|
+
* is already operating in, whose files it can read anyway. Everything else
|
|
162
|
+
* needs an explicit `yolo-bridge allow`.
|
|
163
|
+
*
|
|
164
|
+
* Fails closed on anything it cannot resolve — a path that does not exist, or a
|
|
165
|
+
* broken symlink, is not approved.
|
|
166
|
+
*/
|
|
167
|
+
export function checkPathApproved(rawPath, workspaceId, implicitRoots = [], env = process.env, io = defaultIO, resolver = defaultResolver) {
|
|
168
|
+
const resolved = resolver.realpath(path.resolve(rawPath));
|
|
169
|
+
if (!resolved)
|
|
170
|
+
return { approved: false, reason: `No such file: ${rawPath}` };
|
|
171
|
+
// Implicit roots are resolved too — the working directory can itself be
|
|
172
|
+
// reached through a symlink.
|
|
173
|
+
const roots = [];
|
|
174
|
+
for (const r of implicitRoots) {
|
|
175
|
+
const rr = resolver.realpath(path.resolve(r));
|
|
176
|
+
if (rr)
|
|
177
|
+
roots.push(rr);
|
|
178
|
+
}
|
|
179
|
+
for (const a of loadApprovals(env, io)) {
|
|
180
|
+
if (a.workspaceId === workspaceId)
|
|
181
|
+
roots.push(a.path);
|
|
182
|
+
}
|
|
183
|
+
for (const root of roots) {
|
|
184
|
+
if (isWithinRoot(resolved, root))
|
|
185
|
+
return { approved: true, root, resolvedPath: resolved };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
approved: false,
|
|
189
|
+
reason: `${path.basename(resolved)} is outside every approved path for this workspace. `
|
|
190
|
+
+ 'Approve it from your own shell with `yolo-bridge allow <path>`.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* POSIX single-quoting for a path we print back as a COPYABLE command.
|
|
195
|
+
*
|
|
196
|
+
* Media directories routinely contain spaces (`~/My Footage/`), which alone
|
|
197
|
+
* makes an unquoted suggestion wrong. The sharper reason is that a path is
|
|
198
|
+
* attacker-influenceable in a way the operator would not expect — an agent can
|
|
199
|
+
* create a directory — and an unquoted `;` in a line the operator copies runs.
|
|
200
|
+
* Single quotes are literal in POSIX shells; the `'\''` dance closes, escapes
|
|
201
|
+
* one quote, and reopens. (codex P2, gpt-5.6-sol.)
|
|
202
|
+
*/
|
|
203
|
+
export function shellQuote(value) {
|
|
204
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `yolo-bridge allow` — add, list or remove an approved path.
|
|
208
|
+
*
|
|
209
|
+
* Requires an attachment, because an approval is scoped to ONE workspace. A
|
|
210
|
+
* machine that attaches to several workspaces should not have a grant made for
|
|
211
|
+
* one silently apply to the others.
|
|
212
|
+
*/
|
|
213
|
+
export function runAllow(args, deps = {}) {
|
|
214
|
+
const { env, io, resolver } = deps;
|
|
215
|
+
const loadAttachmentFn = deps.loadAttachmentImpl
|
|
216
|
+
?? ((e, i) => loadAttachmentDefault(e, i));
|
|
217
|
+
const attachment = loadAttachmentFn(env, io);
|
|
218
|
+
if (!attachment) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: 'not-attached',
|
|
222
|
+
message: 'No active attachment — run `yolo-bridge attach` first. Approvals are scoped to one workspace.',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const workspaceId = attachment.workspaceId;
|
|
226
|
+
if (args.includes('--list') || args.length === 0) {
|
|
227
|
+
const approvals = listApprovals(workspaceId, env, io);
|
|
228
|
+
if (!approvals.length) {
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
lines: [
|
|
232
|
+
'No approved paths for this workspace.',
|
|
233
|
+
'',
|
|
234
|
+
'The attached agent can send files from the daemon\'s working directory.',
|
|
235
|
+
'To let it send from anywhere else: yolo-bridge allow <path>',
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
lines: [
|
|
242
|
+
`Approved paths (${approvals.length}) — the attached agent may send files from these:`,
|
|
243
|
+
...approvals.map((a) => ` ${a.path}${a.raw !== a.path ? ` (added as ${a.raw})` : ''}`),
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const removeIdx = args.indexOf('--remove');
|
|
248
|
+
if (removeIdx !== -1) {
|
|
249
|
+
const target = args[removeIdx + 1];
|
|
250
|
+
if (!target)
|
|
251
|
+
return { ok: false, reason: 'error', message: '`--remove` needs a path.' };
|
|
252
|
+
const { removed } = revokePath(target, workspaceId, env, io, resolver);
|
|
253
|
+
return removed
|
|
254
|
+
? { ok: true, lines: [`Removed ${removed} approval(s) for ${target}.`] }
|
|
255
|
+
: { ok: false, reason: 'error', message: `${target} was not an approved path for this workspace.` };
|
|
256
|
+
}
|
|
257
|
+
const target = args.find((a) => !a.startsWith('-'));
|
|
258
|
+
if (!target)
|
|
259
|
+
return { ok: false, reason: 'error', message: 'A path is required.' };
|
|
260
|
+
const result = approvePath(target, workspaceId, env, io, resolver);
|
|
261
|
+
if (!result.ok)
|
|
262
|
+
return { ok: false, reason: 'error', message: result.message };
|
|
263
|
+
if (result.alreadyPresent) {
|
|
264
|
+
return { ok: true, lines: [`Already approved: ${result.approved.path}`] };
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
lines: [
|
|
269
|
+
`Approved: ${result.approved.path}`,
|
|
270
|
+
'The attached agent may now send files from here. Remove it with:',
|
|
271
|
+
// The RESOLVED path, not what was typed: a relative spelling is ambiguous
|
|
272
|
+
// across working directories, and pasting it later could revoke a
|
|
273
|
+
// different grant than the one just made.
|
|
274
|
+
` yolo-bridge allow --remove ${shellQuote(result.approved.path)}`,
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { hostname } from 'node:os';
|
|
|
23
23
|
import { runLogin } from './login-cmd.js';
|
|
24
24
|
import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
|
|
25
25
|
import { runShare } from './share-cmd.js';
|
|
26
|
+
import { runAllow } from './approved-paths.js';
|
|
26
27
|
import { runDetach } from './detach-cmd.js';
|
|
27
28
|
import { getStatus, formatStatus } from './status-cmd.js';
|
|
28
29
|
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
@@ -100,6 +101,9 @@ function printHelp() {
|
|
|
100
101
|
' RESUMES it (same tile) instead of adding a duplicate; --fresh skips',
|
|
101
102
|
' that check entirely.',
|
|
102
103
|
' detach Detach the current workspace attachment.',
|
|
104
|
+
' allow <path> Let the ATTACHED AGENT send files from this path. You type',
|
|
105
|
+
' this; nothing in the cloud can. Also --list and --remove <path>.',
|
|
106
|
+
' The daemon\'s own working directory is always allowed.',
|
|
103
107
|
' share <path> Share a local file with the attached workspace, so a cloud',
|
|
104
108
|
' agent can see it. Push only — nothing reads your disk remotely.',
|
|
105
109
|
' status Print local login/attach state.',
|
|
@@ -550,6 +554,15 @@ function cmdStatus() {
|
|
|
550
554
|
process.stdout.write(`${formatStatus(getStatus())}\n`);
|
|
551
555
|
return 0;
|
|
552
556
|
}
|
|
557
|
+
function cmdAllow(args) {
|
|
558
|
+
const result = runAllow(args);
|
|
559
|
+
if (!result.ok) {
|
|
560
|
+
process.stderr.write(`yolo-bridge allow: ${result.message}\n`);
|
|
561
|
+
return 1;
|
|
562
|
+
}
|
|
563
|
+
process.stdout.write(`${result.lines.join('\n')}\n`);
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
553
566
|
async function cmdShare(args) {
|
|
554
567
|
const rawPath = args[0];
|
|
555
568
|
if (!rawPath || rawPath.startsWith('-')) {
|
|
@@ -619,6 +632,8 @@ async function main() {
|
|
|
619
632
|
return cmdAttach(rest);
|
|
620
633
|
case 'detach':
|
|
621
634
|
return cmdDetach();
|
|
635
|
+
case 'allow':
|
|
636
|
+
return cmdAllow(rest);
|
|
622
637
|
case 'share':
|
|
623
638
|
return cmdShare(rest);
|
|
624
639
|
case 'status':
|
package/dist/status-cmd.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* longer invisible just because it can't be printed to the screen.
|
|
23
23
|
*/
|
|
24
24
|
import { loadAuth, loadAttachment } from './config-store.js';
|
|
25
|
+
import { listApprovals } from './approved-paths.js';
|
|
25
26
|
import { loadConnectionState, formatConnectionEvent } from './connection-state.js';
|
|
26
27
|
export function getStatus(deps = {}) {
|
|
27
28
|
const now = deps.now ?? Date.now;
|
|
@@ -31,6 +32,11 @@ export function getStatus(deps = {}) {
|
|
|
31
32
|
loggedIn: Boolean(auth),
|
|
32
33
|
attached: Boolean(attachment),
|
|
33
34
|
};
|
|
35
|
+
if (attachment) {
|
|
36
|
+
const approvals = listApprovals(attachment.workspaceId, deps.env, deps.io);
|
|
37
|
+
if (approvals.length)
|
|
38
|
+
report.approvedPaths = approvals.map((a) => a.path);
|
|
39
|
+
}
|
|
34
40
|
if (auth) {
|
|
35
41
|
report.tokenExpiresAtMs = auth.expiresAtMs;
|
|
36
42
|
report.tokenExpired = auth.expiresAtMs <= now();
|
|
@@ -78,5 +84,11 @@ export function formatStatus(report) {
|
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
}
|
|
87
|
+
if (report.approvedPaths?.length) {
|
|
88
|
+
lines.push(`Agent may send files from (${report.approvedPaths.length} approved path(s)):`);
|
|
89
|
+
for (const p of report.approvedPaths)
|
|
90
|
+
lines.push(` ${p}`);
|
|
91
|
+
lines.push('(plus the daemon\'s working directory. Change with `yolo-bridge allow`.)');
|
|
92
|
+
}
|
|
81
93
|
return lines.join('\n');
|
|
82
94
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|