@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.
@@ -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':
@@ -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.18.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",