ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/bin/mcp-fs
Revision: 1.103
Committed: Thu Nov 12 03:58:20 2026 UTC (30 hours, 10 minutes ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.102: +71 -13 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 #!/opt/bin/perl
2    
3     =head1 NAME
4    
5 root 1.10 mcp-fs - filesystem and command tools with optional sandboxing
6 root 1.1
7     =head1 SYNOPSIS
8    
9     mcp-fs --stdio /path/to/workdir
10 root 1.11 mcp-fs --name project --bind 127.0.0.1:31741 /path/to/workdir
11 root 1.88 mcp-fs --stdio --host kaos --rsh rsh /root/src
12 root 1.1
13     =head1 DESCRIPTION
14    
15 root 1.44 C<mcp-fs> gives an MCP client tools to read, search and change files, and
16     run commands. An assistant can use these tools to work on your projects
17     without you writing a Perl server.
18    
19     Start it with one or more project directories, called workdirs. The client
20     connects to the server, lists its tools and makes requests. A request can
21     select one of these workdirs; with only one, the selection can be omitted.
22     Tools operate on files on disk. To work with unsaved Vim buffers, use
23     L<mcp-vim>.
24    
25     Before starting an operation, the server checks permission rules and, when
26     needed, asks you for approval. Operations normally run in a bubblewrap
27     sandbox with the approved access. Workdirs provide the default filesystem
28     scope; operations needing access elsewhere require the corresponding
29     sandbox grants.
30    
31     You can connect an MCP client directly over HTTP or stdin/stdout, or run
32     the server through C<mcp-multibroker>. See L<AnyEvent::MCP::Intro> for a
33     walkthrough from installation to a first filesystem request.
34 root 1.1
35 root 1.88 With C<--host>, the same tools operate on a remote host over SSH (or the
36     command selected by C<--rsh>). Only Perl 5.8.4 and the tools used by the
37     requested operations are needed remotely; neither mcp-fs nor its CPAN
38     modules must be installed there. Each server instance uses one host, with
39     any number of workdirs on that host.
40    
41 root 1.70 =head1 FIRST USE
42    
43     Configure your MCP client to launch the server with an existing project:
44    
45     mcp-fs --stdio --name project /home/alice/src/project
46    
47     The client starts the process and speaks MCP over stdin/stdout. Do not run
48     another copy in a terminal. For HTTP clients, run without C<--stdio> and
49     use C<--print-url> with the same C<--name> and C<--bind> settings; see
50     L<AnyEvent::MCP::Intro/YOUR FIRST FILESYSTEM SERVER>.
51    
52     To inspect the tools available with your installed utilities without
53     starting a listener, run C<mcp-fs --tool-help /home/alice/src/project>.
54    
55     Once connected, ask the assistant:
56    
57     Show the configured workdirs and list my project's top-level files.
58     Read its README if present. Do not change files or run commands.
59    
60     C<workdirs> shows the available paths without a worker. Listing requests
61     C<inspect> permission, and reading a file requests C<read>. Allow each once
62     to try them. Approval is specific to the requested action and sandbox grants.
63     If no dialog appears, inspect the tool error and server stderr: the helper
64     needs a YAD display or usable terminal. See L</PERMISSIONS>.
65    
66     For a separate sandbox check without MCP, change to your project directory
67     and run:
68    
69     mcp-fs --exec-cmd workdir-read -- ls
70    
71     This uses the current directory as the workdir. It tests the sandbox,
72     not client connectivity or permission prompting.
73    
74 root 1.88 =head2 Remote workdirs
75    
76     To use an existing directory on another machine:
77    
78     mcp-fs --stdio --host server.example.org /srv/project
79     mcp-fs --stdio -h kaos -e rsh /root/src /srv/projects
80    
81     Workdir arguments are ordinary absolute paths on that host, without a
82     C<host:> prefix. Omitting C<--host> retains local operation, regardless of
83     C<--rsh>. The remote shell is started locally, outside the operation
84     sandbox. Its connection does not require the remote operation's C<network>
85     grant. Combine unrelated hosts through C<mcp-multibroker>.
86    
87     Remote request cancellation and timeouts terminate the local remote-shell
88     process, but may leave the remote worker running. After a connection failure,
89     the outcome of a modifying operation may be unknown; inspect the remote
90     filesystem before retrying it. Reliable remote process termination is not
91     yet implemented.
92    
93 root 1.34 =head1 TOOLS
94    
95 root 1.92 The filesystem tools are:
96    
97     =over
98    
99     =item C<workdirs>
100    
101     Report configured workdirs and available sandbox grants, without starting a worker.
102    
103     =item C<list>, C<find>
104    
105     Enumerate directory entries.
106    
107     =item C<stat>
108    
109     Return lstat metadata without following symlinks.
110    
111     =item C<file-read>
112    
113     Read byte ranges and return Base64 data.
114    
115     =item C<read-lines>
116    
117     Read LF-delimited lines with selectable UTF-8, Latin-1 or Base64 representation.
118    
119     =item C<grep>
120    
121     Search files using a literal pattern or PCRE.
122    
123     =item C<file-write>
124    
125     Write Base64 bytes at an offset without implicit truncation.
126    
127     =item C<path-op>
128    
129     Perform pathname mutations.
130    
131     =item C<file-patch>
132    
133     Apply a unified diff to a single file.
134    
135     =item C<exec>
136    
137     Run an argv array, not a shell command string. To use shell syntax,
138     explicitly invoke a shell in that array.
139    
140     =back
141    
142     For C<exec>, C<stdin_encoding> selects C<utf-8> (default), C<latin1>
143     or C<base64>. The 4 MiB limit applies to the resulting stdin bytes.
144 root 1.98 The C<argv> elements are Unicode MCP strings encoded as UTF-8 for the
145     operating system; they cannot represent arbitrary non-UTF-8 argv bytes.
146 root 1.92 Stdout and stderr are decoded as UTF-8, with a raw capture limit of
147     1 MiB per stream; use the byte-range tools for binary file contents.
148     Its optional C<sandbox_grants> B<replaces> the default C<workdir-read>
149     grant rather than adding to it. Explicitly passing an empty array
150     therefore asks for no grants. Command exit codes are normal tool results,
151     not transport errors. On timeout, output collection stops and
152     C<truncated> is true; C<exit> and C<signal> still describe the reaped child.
153    
154 root 1.56 Perl is required for normal server operation; missing optional GNU utilities
155 root 1.92 can cause their tools to be omitted. The command requested by C<exec>
156     is resolved when the worker runs; it need not be present when the server
157     starts. Inspect C<tools/list> rather than assuming every tool is present.
158 root 1.34
159 root 1.45 =head2 Command output
160    
161 root 1.80 C<exec>, C<vcs-diff>, C<vcs-status> and C<vcs-log> share three presentation options:
162 root 1.45
163     =over
164    
165     =item grep => $pattern
166    
167     Optional PCRE line filter for stdout. It runs in a separate timed GNU grep
168     worker after command output has been captured. It is not passed to the
169     original command or a shell. Stderr remains unfiltered.
170    
171     =item max_lines => $integer
172    
173     Maximum returned stdout lines after filtering, default 2000, maximum 100000.
174    
175     =item max_bytes => $integer
176    
177     Maximum returned UTF-8 bytes per stream, default 65536, maximum 1048576.
178     Clipping does not leave an incomplete UTF-8 character. Stderr has its own
179     budget, so a large stdout does not hide diagnostics.
180    
181     =back
182    
183     The command runs once and is drained to completion, subject to its timeout.
184     Raw capture is bounded to 1 MiB per stream, independently of the returned
185     output limits. C<capture_truncated> means bytes were omitted before filtering
186     or collection ended on timeout: matches in that omitted output are unknown.
187     C<stdout_truncated>, C<stderr_truncated> and the combined C<truncated> flag
188     also account for returned-output limits.
189    
190     Changing presentation options does not change the command's permission
191     subject. C<exit>, C<signal> and C<timed_out> always describe the original
192     command, including a diff exit code indicating differences. A filter error
193     makes the tool result an error and adds C<filter_error>, while retaining that
194     command status. The command may already have completed; do not rerun it
195     merely because its output filter failed.
196    
197     =head2 Version control
198    
199 root 1.80 C<vcs-diff>, C<vcs-status> and C<vcs-log> take a required C<backend>: C<git>, C<svn>,
200 root 1.46 C<cvs> or C<hg>, a workdir, an optional C<cwd> (default C<.>), and an optional
201     C<files> array. C<cwd> selects a directory within the workdir; it does not
202     change the granted mount boundary. Files are relative to C<cwd> and must
203     stay within the selected workdir. The worker checks its resolved directory
204     after chdir and before starting the client, rejecting symlink escapes.
205     Backend selection is explicit. With no files, the command examines the
206     working copy at C<cwd>. Files must be inside the selected workdir;
207 root 1.45 they are passed as literal filenames, not options, revisions or shell code.
208     Git diff shows unstaged changes; SVN, CVS and Mercurial diff compare the
209     working copy with its base. No commit, update, fetch or push is performed.
210 root 1.80 C<vcs-log> reads revision history. CVS reports revisions separately per file
211     (including their commit IDs), unlike Git's repository-wide commits.
212     Use output limits to bound potentially long histories.
213 root 1.45
214 root 1.80 These tools have separate C<vcs-diff>, C<vcs-status> and C<vcs-log> permission actions.
215 root 1.46 They request C<workdir-read> for the selected working copy. CVS additionally
216 root 1.80 requests C<ssh-agent>, which includes network access. SVN log may contact the
217     repository and also requests C<ssh-agent> to support svn+ssh; SVN diff/status,
218     Git and Mercurial operations run without network or home access.
219 root 1.45 A saved approval can cover the fixed operation regardless of filenames or
220     output filters, for that workdir and backend, or all supported backends.
221     Configured VCS clients and their configuration are trusted, including any
222     helpers they invoke; these tools do not sandbox the client's own semantics.
223     The ordinary sandbox grants still bound its host access. In unsandboxed
224     mode, the normal full-authority approval requirement applies.
225    
226     Clients are resolved at startup. C<executable.git>, C<executable.svn>,
227     C<executable.cvs> and C<executable.hg> in trusted program/instance settings
228     can override them. A missing client produces a tool error for that backend.
229    
230 root 1.44 =head1 REQUIREMENTS
231    
232     Bubblewrap is required by default, but can be disabled explicitly.
233 root 1.56 Perl 5.8.4 or later must run in the worker environment at startup (except
234     for C<--exec-cmd>). GNU find, grep, patch and sed are optional; tools
235     requiring an unavailable utility are omitted. Use C<tools/list> to see
236     which tools are available.
237 root 1.44
238 root 1.88 Remote startup resolves workdirs, HOME, required mounts, executable
239     locations and VCS markers once; C<workdirs> thereafter uses this snapshot.
240     Optional C<mount-try> paths are instead passed to bubblewrap for every
241     invocation. Restart mcp-fs to refresh the workdir configuration and tools.
242    
243 root 1.44 =head1 CONNECTIONS
244    
245     HTTP is the default and does not print its URL on startup. C<--print-url>
246     prints the credential-bearing URL; the same token works in a bearer header.
247     C<--stdio> or
248     C<PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL=stdio> selects stdin/stdout.
249     See L<AnyEvent::MCP/GLOBAL CONFIGURATION> for named HTTP settings.
250    
251 root 1.1 =head1 OPTIONS
252    
253 root 1.87 Shared switches are documented in
254     L<AnyEvent::MCP::Server::Simple/COMMON COMMAND-LINE OPTIONS>.
255     The following switches are specific to C<mcp-fs>.
256 root 1.67
257 root 1.1 =over
258    
259 root 1.10 =item --exec-cmd SANDBOX-GRANT,...
260 root 1.7
261 root 1.75 Run command with specified sandbox grants, using current directory as
262     workdir; no MCP listener or permission rules. Supply command and arguments
263     after optional C<--> delimiter. Without command, run C<$SHELL> or C</bin/sh>.
264     C<--sandbox none> runs with normal host-user privileges.
265     Example: C<mcp-fs --exec-cmd workdir-read -- ls>.
266 root 1.88 C<--exec-cmd> is local-only; it cannot be combined with C<--host>.
267 root 1.75
268     =item --ask MODE
269    
270 root 1.95 Permission prompts: C<helper> (default), C<tty>, C<yad>, C<mcp>, C<auto>,
271     or C<none>.
272 root 1.75 Local helper keeps decisions local; tty/yad force interface without fallback.
273     C<mcp> asks client; C<auto> prefers client elicitation when advertised,
274 root 1.95 otherwise local helper. C<none> denies unmatched permission requests without
275     prompting. Overrides C<PERL_ANYEVENT_MCP_PERMISSION_ASK>.
276 root 1.4
277 root 1.88 =item -h, --host HOST
278    
279     Run filesystem operations on HOST instead of the local machine. Empty or
280     omitted HOST selects local operation. All workdirs belong to this one host.
281     Remote workdir paths must be absolute. Host identity qualifies permission
282 root 1.91 scopes, not workdir IDs, which use logical pathnames without following symlinks.
283 root 1.88
284     =item -e, --rsh COMMAND
285    
286     Remote shell command, default C<ssh>. Like rsync's C<-e>, COMMAND may include
287     arguments. Only used with C<--host>; C<rsh> is also supported.
288     The command is split into arguments locally without a local shell. The
289 root 1.91 remote shell invokes a short Perl loader. The launcher source is transferred
290     over stdin, followed by length-prefixed binary startup frames; subsequent
291     worker input remains on the same stream.
292 root 1.88
293 root 1.10 =item --sandbox MODE
294    
295 root 1.75 Worker sandbox mode: C<bwrap> (default), C<auto>, C<none>.
296     C<bwrap> requires working bubblewrap. C<auto> uses bubblewrap if installed,
297     otherwise runs unsandboxed; failing bubblewrap does not silently fall back.
298     C<none> runs with full host-user privileges. Unsandboxed workers require
299     approval for maximum authority (world-write, network, shared-tmp, host-proc)
300     even for reads; narrower grants cannot authorize them. Startup warns.
301 root 1.10
302 root 1.103 =item --exec-prefix COMMAND
303    
304     Run workers and their executable probe through COMMAND, split into argv with
305     shell-style quoting. No additional shell is used locally. For example,
306     C<--exec-prefix 'doas -n'> can read protected files on a machine using doas
307     instead of sudo. Remote execution uses the existing SSH command shell and
308     places the prefix before the remote Perl launcher. A failed prefix never
309     falls back to the original credentials.
310    
311     =item --sudo[=USER]
312    
313     Shortcut for C<--exec-prefix 'sudo -n'>, optionally adding C<-u USER>.
314     Overrides C<--exec-prefix>. For example, C<--sudo> with
315     C<--host user@host> works when SSH must log in as an unprivileged account
316     but passwordless sudo is permitted. C<--sudo=mail> runs as the mail user.
317     Sudo must work noninteractively; no password or terminal is supplied.
318    
319     =item --host-dac
320    
321     Keep the host user namespace and retain C<CAP_DAC_OVERRIDE> inside
322     bubblewrap. For example, a root worker can inspect a 0700 directory owned
323     by another user within a granted workdir. Other capabilities are dropped;
324     the existing mount and MCP permission restrictions still apply. Requires
325     privileged bubblewrap; C<--sandbox none> is incompatible.
326    
327 root 1.1 =item --bwrap PATH
328    
329 root 1.75 Use specified bubblewrap executable. Default: C<bwrap> found in C<PATH>.
330     Relevant with C<--sandbox bwrap> or C<--sandbox auto>.
331 root 1.1
332 root 1.19 =back
333 root 1.1
334 root 1.87 =head2 Shared-option behavior
335    
336     =over
337    
338     =item --name NAME
339    
340     Also selects permission profile C<fs-NAME> and its rule file,
341     C<rules/fs-NAME.ini>. Default instance name: C<default>.
342    
343     =item --print-url[=USER]
344    
345     Does not require a WORKDIR argument.
346    
347     =item --tool-help
348    
349     Requires normal WORKDIR arguments, since available tools depend on the
350     selected workdirs and installed utilities.
351    
352     =item --auto-allow TOOL[,ACTION[,CAPABILITY]]
353    
354     For example, combining C<--auto-allow '*,inspect,workdir-read'> with
355     C<--auto-allow '*,read,workdir-read'> approves only those two actions
356     within read-only workdir authority. C<--auto-allow '*,*,workdir-write'>
357     approves any action requiring at most workdir-write authority,
358     including reads.
359    
360     =back
361    
362 root 1.67 Rules reside in C<$XDG_CONFIG_HOME/anyevent-mcp/rules/fs-NAME.ini>,
363     or the corresponding C<$HOME/.config> path. The rules filename shares the
364     service-instance name but has an C<fs> namespace. The default instance uses
365     C<rules/fs-default.ini>. The rules path is printed on stderr when starting
366     HTTP; normal startup leaves stdout untouched.
367 root 1.88 Local and remote instances with the same C<--name> intentionally share
368 root 1.93 the same rule profile. Permission requests include a host dimension: C<local>
369     for local execution, or C<remote HOST> using C<--host> independently of
370     C<--rsh>. New persistent rules default to this host, but may match all hosts.
371     The workdir matcher is simply the logical path, independent of host. Existing
372     rules lacking C<match.host> continue to apply across local and remote hosts;
373     only newly saved host-specific rules restrict this.
374     Use different instance names or C<match.host> to separate approvals for
375     different hosts. Unscoped rules apply across hosts sharing the profile.
376 root 1.67
377 root 1.19 =head1 TRUSTED CONFIGURATION
378 root 1.1
379 root 1.19 In C<~/.config/anyevent-mcp/global.ini> (or the corresponding
380     C<XDG_CONFIG_HOME> location), C<[program NAME]> supplies defaults based
381     on the executable basename, while C<[instance NAME]> overrides them for
382     C<--name NAME>. A symlinked executable therefore has its own program defaults.
383    
384     [program mcp-fs]
385 root 1.88 mount-try.opt = ro:/opt
386     mount-try.perl = ro:/opt/perl
387 root 1.59 prepend-path = /opt/bin:/opt/sbin
388 root 1.19 mount.shared = rw:/srv/shared
389 root 1.46 env.cvs = CVS_RSH=/my/cvs/rsh
390 root 1.19 executable.perl = /opt/bin/perl
391    
392     [instance myproject]
393     mount.opt =
394 root 1.59 prepend-path =
395 root 1.19 mount.extra = ro:/srv/project-tools
396    
397 root 1.93 To override settings for one invocation, use the common C<-s> / C<--set>
398     option rather than editing the file:
399    
400     mcp-fs --stdio -s executable.perl=/opt/perl/bin/perl \
401     -s prepend-path=/opt/perl/bin /srv/project
402    
403     Command-line settings take precedence over program and instance settings;
404     repeated keys use their last value. See L<AnyEvent::MCP::Server::Simple/COMMON COMMAND-LINE OPTIONS>.
405    
406 root 1.66 Automatic permission rules can also be configured:
407    
408     [program mcp-fs]
409     auto-rule.10 = allow *,read,workdir-read
410     auto-rule.20 = allow *,write,workdir-write
411     auto-rule.30 = deny *,exec,*
412    
413 root 1.92 The supported configuration entries are:
414 root 1.66
415 root 1.92 =over
416    
417     =item C<auto-rule.SUFFIX>
418    
419     Automatic permission rules apply to every named mcp-fs instance;
420     CLI auto-rules are evaluated first.
421    
422     =item C<mount.SUFFIX>
423    
424     A required sandbox mount. The named path must exist at startup.
425    
426     =item C<mount-try.SUFFIX>
427    
428     An optional sandbox mount, using bubblewrap's C<--ro-bind-try> or
429     C<--bind-try>. An absent path is skipped by bubblewrap at each invocation,
430     without a startup existence check.
431    
432     Both mount kinds are applied in alphabetical key order after the private
433     sandbox mounts. The suffix is an arbitrary stable name; an empty instance
434     value suppresses an inherited entry. Paths must be absolute. A colon after
435     C<ro:> or C<rw:> starts the literal pathname; no separate quoting or escaping
436     syntax is used.
437    
438     =item C<env.SUFFIX>
439    
440     Specify C<NAME=value> to set a literal value, or C<NAME> to inherit that
441     variable from the server environment if present. Values may contain spaces
442     and equals signs; there is no shell expansion. Entries are applied in
443     alphabetical key order after the default environment. An empty instance
444     entry suppresses the corresponding program entry. C<SSH_AUTH_SOCK> is managed
445     exclusively by the C<ssh-agent> grant, even when listed in these settings.
446     Environment settings do not expose paths; custom executables outside the
447     runtime/workdir mounts need configured mounts.
448    
449     =item C<prepend-path>
450    
451     Add colon-separated directories before the bubblewrapped worker's fixed
452     C<PATH> of C</usr/bin:/usr/sbin:/bin:/sbin:/usr/local/bin:/usr/local/sbin>.
453 root 1.59 Trailing colons are ignored, so both C</opt/bin> and C</opt/bin:> work.
454     The server's C<PATH> is not inherited. An empty instance value restores the
455     fixed default rather than the program value. C<--sandbox=none> continues to
456     use the host environment. Directories outside the normal runtime mounts
457     still need a configured mount to be accessible.
458    
459 root 1.92 =item C<executable.find>, C<executable.grep>, C<executable.patch>,
460     C<executable.perl>, C<executable.sed>, C<executable.bwrap>
461    
462     Override automatic executable discovery. Explicit choices must resolve to
463 root 1.56 executable files and are trusted instead of version-probed, except for
464     C<executable.perl>, which must satisfy the Perl minimum version.
465     C<executable.perl> defaults to C<perl> in the worker's C<PATH>;
466     the other worker executables are discovered there too. C<--bwrap>
467     overrides C<executable.bwrap> and is located on the host.
468 root 1.1
469 root 1.92 =back
470    
471     Configured mounts grant baseline access to every sandbox invocation
472     (when present for C<mount-try>), independently of tool permission grants.
473     With C<world-read>, only C<rw:> exceptions are applied over the read-only
474     host filesystem. With C<world-write>, these configured mounts are ignored.
475 root 1.101 Selecting C</> as a workdir makes C<workdir-read> and C<workdir-write>
476     equivalent in filesystem reach to C<world-read> and C<world-write>,
477     respectively, without changing saved permission identities. Host directory
478     mounts are applied parent-first, followed by protected private system mounts
479     and explicitly configured narrower exceptions.
480 root 1.92 Baseline execution paths include C</usr>, C</bin>, system libraries and
481     selected system configuration, but not C</opt>. Shared host temporary
482 root 1.94 directories require C<shared-tmp>, an explicit configured mount, or
483     C<world-write>. All other grants, including C<world-read>, use private
484     writable temporary directories. C<world-write> implies C<shared-tmp>.
485 root 1.92
486 root 1.19 Defaults also include alternatives, font configuration, local timezone,
487     TLS certificate stores, NSS configuration and DNS configuration where
488 root 1.101 present. A synthetic passwd entry is created in the private root if the
489     host root is not exposed. A workdir covering C</etc> will instead show the
490     host passwd file, without ever modifying it. A root workdir exposes the
491     host passwd and group databases.
492 root 1.1
493 root 1.47 The C<workdirs> result includes C<workdir_vcs>, a mapping from configured
494     workdir identities to arrays of backends with metadata markers directly
495     in that directory. Detection checks C<CVS>, C<.git>, C<.hg> and C<.svn>;
496     a Git metadata file is supported as well as a directory. Symlink markers
497     are reported without following them. This is a hint, not validation of a
498     repository. There is no recursive scan or parent search, multiple markers
499     are retained, and an empty array means no marker was found. Detection is
500     independent of the available clients in C<vcs_backends> and does not choose
501     the backend for a VCS command.
502    
503 root 1.10 =head1 SANDBOX GRANTS
504 root 1.8
505 root 1.92 =over
506    
507     =item C<workdir-read>
508    
509     Read access to the selected workdir.
510    
511     =item C<workdir-write>
512    
513     Write access to the selected workdir; implies C<workdir-read>.
514    
515     =item C<all-workdirs-read>
516    
517     Read access to every configured workdir.
518    
519     =item C<all-workdirs-write>
520    
521     Write access to every configured workdir; implies read access.
522    
523     =item C<homedir-read>
524    
525     Read access to HOME.
526    
527     =item C<homedir-write>
528    
529     Write access to HOME; implies read access.
530    
531     =item C<world-read>
532    
533     Read access to the host filesystem.
534    
535     =item C<world-write>
536    
537     Write access to the host filesystem; implies C<world-read>.
538 root 1.94 Also implies C<shared-tmp>, so host C</tmp> and C</var/tmp> remain
539     writable and visible.
540 root 1.92
541     =item C<network>
542    
543     Enable networking outside the sandbox's network namespace. Without this
544     grant, bubblewrap uses a separate network namespace. Loopback connections
545     between processes in that same sandbox work, but its C<127.0.0.1> is not
546     the host's C<127.0.0.1>. Connecting to a host-side loopback server requires
547     C<network>.
548    
549     =item C<shared-tmp>
550    
551 root 1.94 Use the host's writable C</tmp> and C</var/tmp> instead of private
552     writable temporary directories. Without this grant, temporary files
553     are isolated and disappear when their sandbox ends, even with
554     C<world-read>. Host temporary files are visible across invocations,
555     though the host may clean them up at any time. C<world-write>
556     automatically grants C<shared-tmp>.
557 root 1.92
558     =item C<long-running>
559    
560     Allow C<exec> timeouts over 300 seconds.
561    
562     =item C<host-proc>
563    
564     Host-process access, available only without bubblewrap.
565    
566     =item C<ssh-agent>
567    
568     Implies C<network>. Exposes the server's C<SSH_AUTH_SOCK> at
569     C</run/anyevent-mcp/ssh-agent> and sets the child's variable to that path.
570     In remote mode it uses the remote shell's agent socket, when available
571     (for example through SSH agent forwarding), rather than mounting the local
572     socket path. SSH authentication used to reach the host is separate from
573     the worker's C<ssh-agent> and C<network> grants.
574    
575     Also exposes C<~/.ssh/known_hosts> read-only if present, without mounting
576     the rest of HOME. Private key files and SSH user configuration are not exposed.
577     An unset or empty C<SSH_AUTH_SOCK> exposes no socket, allowing rsh-based CVS;
578     a configured socket must be an absolute path naming an existing socket node.
579     Socket replacement requires a new invocation; an existing bind mount keeps
580     the old node. Agent access permits authentication, not retrieval of private keys.
581    
582     =back
583    
584     C<all-workdirs-read> combined with C<workdir-write> mounts all workdirs
585     read-only except the selected one, which is writable. C<all-workdirs-write>
586     makes all writable. Configured workdirs are mounted using resolved absolute
587     paths, but their permission-rule identities are verbatim command-line
588     arguments.
589    
590     =head2 Workdir selection
591    
592 root 1.64 Persistent permission rules default to the selected workdir, with an explicit
593     option to match any configured workdir, including future additions. This
594     controls reuse of the rule, not the workdirs accessible in one operation.
595     The multibroker alias is shown in permission prompts, without changing the rule file.
596 root 1.51 Tools accept C<workdir> as a persistent positive integer ID, a numeric
597     string containing that ID, or an exact configured or resolved workdir path.
598     C<workdirs> returns C<workdirs> and C<resolved_workdirs> objects keyed by ID,
599     not arrays indexed by argument position. IDs are shared across server
600     instances in C<$XDG_CONFIG_HOME/anyevent-mcp/fs-workdir-ids.json>, or under
601 root 1.89 C<$HOME/.config> when XDG_CONFIG_HOME is unset. IDs use the logical
602     absolute pathname, without resolving symlinks (relative local arguments
603     are made absolute against the server's working directory). The same logical
604     pathname on local and remote servers receives the same ID, even when it
605     resolves to different physical directories. Permission-rule identities
606     remain the verbatim arguments; each server interprets IDs in its own workdirs.
607 root 1.51
608 root 1.52 The registry stores C<ID =E<gt> [workdir, last_use]> pairs, with C<last_use>
609     as a Unix timestamp. Starting
610     a server resolves its IDs once and reserves any new allocations under the
611     file lock. An AnyEvent timer runs immediately and every 5.25 days, rereading
612     the registry and overwriting those fixed assignments with fresh timestamps.
613     Other entries expire after seven days without refresh;
614 root 1.51 their IDs can be reused. Restart servers after manually changing the registry.
615     Numeric IDs therefore survive restarts and argument reordering while retained,
616     but are not permanent references to paths that have expired.
617    
618     Non-numeric strings are matched only against configured workdirs, never
619     interpreted as arbitrary filesystem paths. Unknown or ambiguous selectors
620     fail. With only one workdir, the field may be omitted.
621 root 1.16 Overlapping workdirs have unspecified mount order and access effects;
622     do not rely on overlapping workdir boundaries for isolation.
623 root 1.46
624 root 1.8 =head1 LIMITATIONS
625    
626 root 1.10 With bubblewrap, regardless of granted sandbox grants, the sandbox mounts procfs at
627 root 1.8 C</proc>. Its process entries are restricted by the PID namespace, but
628     kernel-wide and hardware information (including C</proc/irq> and
629     C</proc/bus>) remains visible. This is a limitation of bubblewrap's
630     C<--proc> option, which does not support C<subset=pid>.
631    
632 root 1.1 =head1 PERMISSIONS
633    
634     L<AnyEvent::MCP::Server::Permissions> applies ordered allow, deny and ask
635 root 1.92 decisions to these action classes:
636    
637     =over
638    
639     =item C<inspect>
640    
641     C<stat>, C<list>, C<find>.
642    
643     =item C<read>
644    
645     C<file-read>, C<read-lines>, C<grep>.
646    
647     =item C<write>
648    
649     C<file-write>, C<file-patch>.
650    
651     =item C<manage>
652    
653     C<path-op>.
654    
655     =item C<exec>
656    
657     Command execution.
658    
659     =back
660    
661     C<file-write> may create files despite being in the write class; manage
662     refers specifically to pathname operations rather than an exclusive right
663     to create files.
664 root 1.15 Filesystem operations have no path-based persistent subjects: their rules
665 root 1.78 match C<action>, C<sandbox-grants>, and C<match.workdir> when selected-workdir
666 root 1.16 authority is required. All-workdirs-only requests have no workdir identity.
667 root 1.78 Rules without C<match.workdir> can apply to any selected workdir if their sandbox
668     grants cover the request; dialog-generated workdir rules select exact or any.
669 root 1.93 The independent C<match.host> dimension matches C<local>, a remote host,
670     or all hosts, independently of the remote transport.
671 root 1.88 Instances with the same name share a rule file, including broad unscoped rules.
672 root 1.16 Requested paths are shown in the approval dialog for information only.
673     Exec additionally offers persistent
674 root 1.78 command/argv scopes as C<match.subject>. When stdin is provided, exec attaches display-only
675 root 1.69 details to the permission request: Unicode text for UTF-8 input, or a
676     bounded escaped-byte preview for Latin-1/Base64 input. The YAD helper offers
677     a separate C<View stdin...> window; the preview shows input size and
678     truncation. This does not change authorization: persistent exec rules match
679     argv, not stdin, and later runs may supply different scripts or bytes.
680     Conversion takes place before approval and the worker receives the exact
681     resulting bytes.
682 root 1.48 C<file-patch> supplies C<View patch> details with the target and supplied diff.
683     Base64 patches are decoded for display: valid UTF-8 is shown as Unicode,
684     otherwise non-printable bytes and backslashes are escaped in a byte view.
685     The UI chooses whether to show a separate viewer or an inline preview.
686 root 1.31 Each tool checks permissions before starting its worker. Approval
687 root 1.4 defaults to the local helper selected by C<PERL_ANYEVENT_MCP_PERMISSION_HELPER>,
688 root 1.37 which tries YAD, then a terminal, then denies once. Client elicitation is
689     selected with C<--ask mcp>, C<--ask auto>, or the corresponding
690     C<PERL_ANYEVENT_MCP_PERMISSION_ASK> value.
691 root 1.1
692     You can edit the rule file at any time. It is reread on permission checks,
693     so changes apply without restarting the server. Keep it owned by your user
694     and private (mode 0600). See L<AnyEvent::MCP::Server::Permissions> for the
695     rule syntax and ordering.
696    
697     Permission retries retain earlier one-time approvals within the same call.
698 root 1.15 For one worker accessing several paths, the required sandbox-grant set combines
699     the authority needed for all paths and is approved once. An insufficient reduction fails before
700 root 1.1 the worker starts.
701 root 1.16 Multi-path C<stat> requires all paths inside its selected workdir.
702 root 1.17 C<path-op> can attempt cross-workdir renames after approving the sandbox
703     grants required for both paths. The sandboxed C<rename(2)> may fail with
704     C<EXDEV> across mounts; no copy/delete fallback is attempted.
705     C<mkpath> requires a path inside the selected workdir.
706 root 1.37 C<path-op> performs requested operations in this fixed order:
707     C<unlink>, C<rmdir>, C<mkpath>, C<mkdir>, C<symlink>, C<truncate>,
708     C<chmod>, C<rename>. C<mkpath> includes each missing component in the
709     combined permission check before starting the worker.
710 root 1.22 The first failure stops subsequent operations; successful earlier mutations
711     remain in place. There is no rollback or all-or-nothing transaction.
712 root 1.1
713 root 1.2 Exec defaults to a 30-second timeout and permits up to 300 seconds without
714     additional timeout authority. A larger explicit C<timeout>, up to 21600
715     seconds (six hours), also requires C<long-running>. This capability is added
716     to the requested permissions automatically. Cancellation still terminates
717     the worker, regardless of its allowed timeout.
718    
719 root 1.10 The C<host-proc> grant is unavailable with bubblewrap. Runtime mounts
720     provide sandbox machinery. Symlinks resolve within granted mounts.
721     Without bubblewrap, workers operate in the host mount and process namespaces.
722 root 1.1
723     =head1 PATCHES
724    
725     C<file-patch> uses L<AnyEvent::MCP/PATCH UTILITIES> for normalization and
726     validation. GNU patch runs inside the granted sandbox with fuzz zero.
727     Successful output replaces the original by rename after checking its
728     identity, size, timestamps, metadata and link count. These stat checks
729     do not detect every concurrent edit; they are not a filesystem transaction.
730    
731     =cut
732 root 1.2
733 root 1.1 use common::sense;
734    
735     use MIME::Base64 ();
736     use Getopt::Long ();
737     use Cwd ();
738 root 1.19 use File::Basename ();
739 root 1.46 use File::Spec::Unix ();
740 root 1.88 use Text::ParseWords ();
741     use IPC::Open2 ();
742 root 1.1 use POSIX ();
743 root 1.6 use Fcntl qw(F_SETFD);
744 root 1.38 use Scalar::Util qw(weaken);
745 root 1.1
746     use JSON::XS ();
747     use AnyEvent ();
748 root 1.6 use AnyEvent::MCP ();
749 root 1.11 use AnyEvent::MCP::Config ();
750 root 1.51 use AnyEvent::MCP::Workdirs ();
751 root 1.1 use AnyEvent::MCP::Server::Permissions ();
752     use AnyEvent::Handle ();
753     use AnyEvent::Log ();
754     use AnyEvent::Util ();
755    
756     use AnyEvent::MCP::Server::Simple;
757    
758 root 1.33 # resolve an executable name or path to an absolute executable file.
759 root 1.1 sub find_executable {
760     my ($name) = @_;
761 root 1.33
762 root 1.1 !ref $name and length $name and $name !~ /\0/
763     or return;
764    
765     my @path = $name =~ m{/}
766     ? ($name)
767     : map { (length $_ ? $_ : ".") . "/$name" } split /:/, $ENV{PATH};
768 root 1.54 my $cwd = Cwd::getcwd;
769 root 1.1
770     for my $path (@path) {
771     next unless -f $path and -x _;
772 root 1.54 return $path =~ m{\A/} ? $path : "$cwd/$path";
773 root 1.1 }
774    
775     undef
776     }
777    
778     Getopt::Long::Configure qw(bundling no_ignore_case require_order);
779    
780 root 1.10 my $sandbox_mode = "bwrap";
781 root 1.14 my $usage = "Usage: $0 [OPTION]... WORKDIR [WORKDIR]...\n"
782     . " $0 --exec-cmd GRANT,... [--] [COMMAND [ARG]...]\n";
783 root 1.1
784 root 1.100 my $server_options = mcp_getoptions
785     {
786     usage => $usage,
787     description => "Expose filesystem tools and command execution over MCP.\n"
788     . "Operations require permission and normally run inside bubblewrap.",
789     option_heading => "Filesystem options:",
790     options => [
791     ["-h, --host HOST", "Run on one remote host (default: local)"],
792     ["-e, --rsh COMMAND", "Remote shell command (default: ssh)"],
793     ["--sandbox MODE", "Use bwrap (default), auto, or none (unsandboxed)"],
794 root 1.103 ["--host-dac", "Allow root workers to read other users' protected files inside sandbox mounts"],
795     ["--exec-prefix COMMAND", "Prefix worker commands (e.g. sudo -n), locally or remotely"],
796     ["--sudo[=USER]", "Run workers through sudo -n, optionally as USER; overrides --exec-prefix"],
797 root 1.100 ["--bwrap PATH", "Path to the bubblewrap executable"],
798     ["--exec-cmd GRANT,...", "Run a command directly with the named grants"],
799     ],
800     examples => " $0 --stdio /home/alice/src/project\n"
801     . " $0 --name project --print-url\n"
802     . " $0 --sandbox bwrap /home/alice/src/project\n"
803     . " $0 --auto-allow '*,inspect,workdir-read' --auto-allow '*,read,workdir-read' /project\n"
804     . " $0 --auto-allow '*,*,workdir-write' /project",
805     },
806     "sandbox=s" => \$sandbox_mode,
807     "host|h=s" => \my $host,
808     "rsh|e=s" => \my $rsh,
809     "exec-cmd=s" => \my $exec_cmd,
810     "bwrap=s" => \my $bwrap,
811 root 1.103 "host-dac!" => \my $host_dac,
812     "exec-prefix=s" => \my $exec_prefix,
813     "sudo:s" => \my $sudo,
814 root 1.100 ;
815 root 1.13
816 root 1.72 my $name = $server_options->{name};
817     my $stdio = delete $server_options->{stdio};
818 root 1.4
819 root 1.11 (defined $exec_cmd or @ARGV)
820 root 1.1 or die $usage;
821    
822 root 1.10 $sandbox_mode =~ /\A(?:bwrap|auto|none)\z/
823     or die "--sandbox must be bwrap, auto, or none\n";
824 root 1.103 $host_dac && $sandbox_mode eq "none" and die "--host-dac requires bubblewrap\n";
825 root 1.10
826 root 1.88 $host //= "";
827     $host !~ m{[\s/\0]} && $host !~ /\A-/
828     or die "invalid remote host\n";
829     $rsh //= "ssh";
830     my @rsh = Text::ParseWords::shellwords ($rsh);
831     @rsh or die "empty remote shell command\n";
832 root 1.103 my @exec_prefix = defined $sudo
833     ? ("sudo", "-n", (length $sudo ? ("-u", $sudo) : ()), "--")
834     : defined $exec_prefix ? Text::ParseWords::shellwords ($exec_prefix) : ();
835     defined $exec_prefix && !defined $sudo && !@exec_prefix
836     and die "empty --exec-prefix\n";
837 root 1.88 my $remote = length $host;
838     !($remote && defined $exec_cmd)
839     or die "--host cannot be combined with --exec-cmd\n";
840    
841 root 1.47 my $program_name = AnyEvent::MCP::Config::program_name ();
842 root 1.19 my $config_file = AnyEvent::MCP::Config::_file ();
843 root 1.99 my $config = -e $config_file ? AnyEvent::MCP::Config::global_config () : undef;
844 root 1.19 my %settings = $config ? (
845 root 1.30 %{ $config->{section}{"program $program_name"} },
846     %{ $config->{section}{"instance $name"} },
847 root 1.19 ) : ();
848    
849 root 1.98 # executable names from the UTF-8 config are text; process argv needs bytes.
850     my %executable = map {
851     (my $name = $_) =~ s/\Aexecutable\.//;
852     ($name => AnyEvent::MCP::utf8enc $settings{$_})
853     } grep /\Aexecutable\./, keys %settings;
854    
855 root 1.86 my $sandbox_path = "/usr/bin:/usr/sbin:/bin:/sbin:/usr/local/bin:/usr/local/sbin";
856 root 1.59 (my $prepend = $settings{"prepend-path"} // "") =~ s/:+\z//;
857     $sandbox_path = "$prepend:$sandbox_path" if length $prepend;
858 root 1.98 $sandbox_path = AnyEvent::MCP::utf8enc $sandbox_path;
859 root 1.59
860 root 1.19 my @mount;
861 root 1.88 for my $key (sort grep /\Amount(?:-try)?\./, keys %settings) {
862 root 1.19 my $value = $settings{$key};
863     next unless length $value;
864     my ($mode, $path) = $value =~ /\A(ro|rw):(\/.*)\z/
865     or die "invalid $key mount '$value'\n";
866 root 1.98 -e (AnyEvent::MCP::utf8enc $path) or die "invalid $key mount path '$path'\n"
867     unless $remote || $key =~ /\Amount-try\./;
868 root 1.19 push @mount, [$key, $mode, $path];
869     }
870    
871 root 1.46 my @environment;
872     for my $key (sort grep /\Aenv\./, keys %settings) {
873     my $value = $settings{$key};
874     next unless length $value;
875     my ($name, $text) = $value =~ /\A([A-Za-z_][A-Za-z_0-9]*)(?:=(.*))?\z/s
876     or die "invalid $key environment entry '$value'\n";
877     push @environment, [$name, $text];
878     }
879    
880 root 1.88 # SSH and rsh execute their command through a remote shell. Only this fixed
881     # launcher goes through that shell; all executable arguments are framed on stdin.
882     my $remote_launcher = q{#perl
883     use 5.008004;
884     use POSIX ();
885     use Fcntl qw(F_SETFD);
886     binmode STDIN;
887     my $read = sub {
888     my ($length) = @_;
889     my $data = "";
890     while (length $data < $length) {
891     my $got = sysread STDIN, my $part, $length - length $data;
892     defined $got or die "remote stdin: $!\n";
893     $got or die "short remote startup frame\n";
894     $data .= $part;
895     }
896     $data
897     };
898     my $length = unpack "N", $read->(4);
899     $length <= 1048576 or die "remote startup frame too large\n";
900     my ($home, @command) = unpack "(w/a*)*", $read->($length);
901     @command or die "empty remote command\n";
902     my $agent_index;
903     if (length $home) {
904     for my $i (1 .. $#command - 1) {
905     last if $command[$i] eq "--";
906     if ($command[$i - 1] eq "--ro-bind"
907     && $command[$i] eq "__MCP_REMOTE_AGENT__"
908     && $command[$i + 1] eq "/run/anyevent-mcp/ssh-agent") {
909     $agent_index = $i;
910     last;
911     }
912     }
913     }
914     if (defined $agent_index) {
915     my $source = $ENV{SSH_AUTH_SOCK};
916     if (length $source) {
917     $source =~ m{\A/} && -S $source
918     or die "remote SSH_AUTH_SOCK must name an absolute socket\n";
919     $command[$agent_index] = $source;
920     } else {
921     splice @command, $agent_index - 1, 3;
922     for (my $i = 0; $i < @command; ++$i) {
923     last if $command[$i] eq "--";
924     if ($command[$i] eq "--setenv" && $command[$i + 1] eq "SSH_AUTH_SOCK") {
925     splice @command, $i, 3;
926     last;
927     }
928     }
929     }
930     }
931     my $passwd_fd;
932     if (length $home) {
933     pipe (my $rd, my $wr) or die "passwd pipe: $!\n";
934     print {$wr} "sandbox:x:$<:" . POSIX::getgid () . ":Sandbox:$home:/bin/sh\n"
935     or die "passwd pipe: $!\n";
936     close $wr or die "passwd pipe: $!\n";
937     if (fileno ($rd) != 3) {
938     POSIX::dup2 (fileno ($rd), 3) == 3 or die "passwd dup2: $!\n";
939     close $rd;
940     open $rd, "<&=3" or die "passwd fd: $!\n";
941     }
942     defined fcntl ($rd, F_SETFD, 0) or die "passwd fcntl: $!\n";
943     $passwd_fd = $rd;
944     }
945     exec {$command[0]} @command;
946     die "remote exec $command[0]: $!\n";
947     };
948    
949     sub shell_quote {
950     my ($text) = @_;
951     $text =~ s/'/'"'"'/g;
952     "'$text'"
953     }
954    
955 root 1.91 # Keep the remote command short (rsh limits its size). The tiny loader reads
956     # exactly the launcher source; unlike parsing a script from stdin, it cannot
957     # buffer bytes intended for the eventual exec'd worker.
958 root 1.97 my $remote_loader = sprintf q{binmode STDIN;
959     while (length $s < %1$d) {
960     sysread STDIN, $s, %1$d - length $s, length $s or die "read\n";
961     }
962     eval $s; die $@ if $@;
963     }, length $remote_launcher;
964 root 1.103 my $remote_command = "exec " . join " ", map shell_quote ($_),
965     @exec_prefix, ($executable{perl} || "perl"), "-e", $remote_loader;
966 root 1.88
967 root 1.98 # all fields here are already octets, including encoded filenames and argv.
968 root 1.88 sub fs_remote_frame {
969 root 1.98 my @arg = @_;
970     utf8::downgrade $_ for @arg; # enforce octets, not an external encoding
971     pack "N/a*", pack "(w/a*)*", @arg
972 root 1.88 }
973    
974 root 1.54 my ($find, $grep, $patch, $perl, $sed);
975     my %vcs_program;
976 root 1.45
977 root 1.98 $bwrap //= $executable{bwrap} || "bwrap";
978 root 1.19
979 root 1.88 if ($sandbox_mode ne "none" && !$remote) {
980 root 1.10 my $name = $bwrap;
981     $bwrap = find_executable $name;
982     die "bubblewrap executable not found: $name\n"
983     if !$bwrap && $sandbox_mode eq "bwrap";
984 root 1.88 } elsif ($sandbox_mode eq "none") {
985 root 1.10 undef $bwrap;
986     }
987 root 1.1
988 root 1.103 if ($host_dac && !$remote) {
989     $bwrap or die "--host-dac requires bubblewrap\n";
990     if (@exec_prefix) {
991     my @check = (@exec_prefix, $^X, "-e", 'exit $> ? 1 : 0');
992     system {$check[0]} @check;
993     $? == 0 or die "--host-dac requires a root worker\n";
994     } else {
995     $> == 0 or die "--host-dac requires a root worker\n";
996     }
997     }
998    
999 root 1.88 my @runtime_path = qw(
1000 root 1.30 /usr
1001     /bin
1002     /sbin
1003     /lib
1004     /lib64
1005     /etc/ld.so.cache
1006     /etc/alternatives
1007     /etc/fonts
1008     /etc/localtime
1009     /etc/ssl/openssl.cnf
1010     /etc/ssl/certs
1011     /etc/pki/tls/openssl.cnf
1012     /etc/pki/tls/certs
1013     /etc/pki/ca-trust
1014     /etc/nsswitch.conf
1015     /etc/hosts
1016     /etc/resolv.conf
1017 root 1.19 );
1018 root 1.88 @runtime_path = grep -e, @runtime_path unless $remote;
1019 root 1.1
1020 root 1.16 my (@workdir, @workdir_identity);
1021 root 1.7 if (defined $exec_cmd) {
1022     @workdir = Cwd::abs_path ".";
1023     @workdir or die "cannot resolve current directory\n";
1024     utf8::decode $workdir[0] or die "workdir is not UTF-8\n";
1025 root 1.16 @workdir_identity = @workdir;
1026 root 1.7 } else {
1027     for my $path (@ARGV) {
1028 root 1.88 my $workdir;
1029     if ($remote) {
1030     $path =~ m{\A/} or die "remote workdirs must be absolute paths\n";
1031     $workdir = $path;
1032     } else {
1033     $workdir = Cwd::abs_path $path;
1034     -d $workdir or die "invalid workdir '$path'\n";
1035     }
1036 root 1.7 utf8::decode $workdir or die "workdir is not UTF-8\n";
1037 root 1.16 my $identity = $path;
1038     utf8::decode $identity or die "workdir identity is not UTF-8\n";
1039 root 1.7 push @workdir, $workdir;
1040 root 1.16 push @workdir_identity, $identity;
1041 root 1.7 }
1042 root 1.1 }
1043    
1044     my $home = $ENV{HOME};
1045     $home =~ m{\A/} and -d $home
1046     or die "HOME must name an absolute existing directory\n";
1047     $home = Cwd::abs_path $home
1048     // die "cannot resolve HOME\n";
1049     utf8::decode $home
1050     or die "HOME is not a valid UTF-8 path\n";
1051    
1052 root 1.7 my $config_home;
1053     unless (defined $exec_cmd) {
1054     $config_home = $ENV{XDG_CONFIG_HOME};
1055     if (length $config_home) {
1056     $config_home =~ m{\A/}
1057     or die "permission config directory must be absolute\n";
1058     utf8::decode $config_home
1059     or die "permission config directory is not UTF-8\n";
1060     } else {
1061     $config_home = "$home/.config";
1062     }
1063 root 1.1 }
1064    
1065 root 1.88 my (%remote_program, @remote_vcs_marker, $remote_umask);
1066 root 1.101 my @tmp_realpath = map { Cwd::abs_path $_ || $_ } qw(/tmp /var/tmp);
1067 root 1.88 if ($remote) {
1068 root 1.100 # probe once per startup; the local MCP frontend keeps the result.
1069     # no remote package beyond core Perl, nor a remote installed mcp-fs.
1070 root 1.88 my $probe = q{#perl
1071     use 5.008004;
1072     use Cwd ();
1073     binmode STDOUT;
1074    
1075     my ($path, $mode, $bwrap_name, $nwork) = splice @ARGV, 0, 4;
1076     my @work = splice @ARGV, 0, $nwork;
1077     my $nmount = shift @ARGV;
1078     my @mount = splice @ARGV, 0, $nmount;
1079     my $nrun = shift @ARGV;
1080     my @runtime = splice @ARGV, 0, $nrun;
1081     $ENV{PATH} = $path;
1082    
1083     my $find_executable = sub {
1084     my ($name) = @_;
1085     my @path = $name =~ m{/} ? ($name)
1086     : map { (length $_ ? $_ : ".") . "/$name" } split /:/, $ENV{PATH}, -1;
1087     for (@path) {
1088     next unless -f $_ && -x _;
1089     return m{\A/} ? $_ : Cwd::getcwd () . "/$_";
1090     }
1091     ""
1092     };
1093    
1094     my $home = $ENV{HOME} || (getpwuid $<)[7];
1095     $home =~ m{\A/} && -d $home or die "remote HOME is not an absolute directory\n";
1096     $home = Cwd::abs_path $home or die "cannot resolve remote HOME\n";
1097     my @resolved = map {
1098     my $path = Cwd::abs_path $_;
1099     -d $path or die "invalid remote workdir '$_'\n";
1100     $path
1101     } @work;
1102     -e $_ or die "invalid remote mount '$_'\n" for @mount;
1103    
1104     my $bwrap = $mode eq "none" ? "" : $find_executable->($bwrap_name);
1105     $bwrap or die "remote bubblewrap executable not found: $bwrap_name\n"
1106     if $mode eq "bwrap";
1107    
1108     my %marker = (cvs => "CVS", git => ".git", hg => ".hg", svn => ".svn");
1109     my @markers = map {
1110     my $dir = $_;
1111     join ",", grep {
1112     lstat "$dir/$marker{$_}" && (-d _ || -l _ || ($_ eq "git" && -f _))
1113     } sort keys %marker
1114     } @resolved;
1115    
1116     my %test = (
1117     find => [[qw(--version)], qr/^find \(GNU findutils\)/m],
1118     grep => [[qw(--version)], qr/^grep \(GNU grep\)/m],
1119     patch => [[qw(--version)], qr/^GNU patch /m],
1120     sed => [[qw(--version)], qr/\(GNU sed\)/],
1121     );
1122     my @program;
1123     while (@ARGV) {
1124     my ($name, $wanted, $explicit) = splice @ARGV, 0, 3;
1125     my $found = $find_executable->($wanted);
1126     if ($found && !$explicit && $test{$name}) {
1127     my ($args, $match) = @{$test{$name}};
1128     my $ok = open my $fh, "-|", $found, @$args;
1129     if ($ok) {
1130     my $output = do { local $/; <$fh> };
1131     $ok = close $fh && $output =~ $match;
1132     }
1133     $found = "" unless $ok;
1134     }
1135     push @program, $found;
1136     }
1137    
1138     print pack "N/a*", pack "(w/a*)*",
1139 root 1.103 $home, $bwrap, scalar umask, $>, @resolved, @markers,
1140 root 1.101 (map { -e $_ ? $_ : "" } @runtime), @program,
1141     (map { Cwd::abs_path $_ || $_ } qw(/tmp /var/tmp));
1142 root 1.88 };
1143    
1144     my @names = qw(perl find grep patch sed git svn cvs hg);
1145     my @args = (
1146     $sandbox_path, $sandbox_mode, $bwrap, scalar @workdir,
1147 root 1.98 (map { AnyEvent::MCP::utf8enc $_ } @workdir),
1148     scalar (grep $_->[0] =~ /\Amount\./, @mount), (map { AnyEvent::MCP::utf8enc $_->[2] } grep $_->[0] =~ /\Amount\./, @mount),
1149 root 1.88 scalar @runtime_path, @runtime_path,
1150 root 1.98 (map { ($_, $executable{$_} || $_, length $settings{"executable.$_"} ? 1 : 0) } @names),
1151 root 1.88 );
1152 root 1.98 my @command = ($executable{perl} || "perl", "-e", $probe, @args);
1153 root 1.88 my $pid = IPC::Open2::open2 (my $out, my $in, @rsh, $host, $remote_command);
1154     binmode $in;
1155     binmode $out;
1156 root 1.91 print {$in} $remote_launcher, fs_remote_frame ("", @command) or die "remote probe input: $!\n";
1157 root 1.88 close $in or die "remote probe input: $!\n";
1158     my $output = do { local $/; <$out> };
1159     close $out;
1160     waitpid $pid, 0;
1161     $? == 0 or die "remote probe failed (wait status $?)\n";
1162     my $declared = unpack "N", substr $output, 0, 4;
1163     length ($output) >= 4 && length ($output) == $declared + 4
1164     or die "invalid remote probe frame (received " . length ($output)
1165     . " bytes, declared $declared, first bytes " . unpack ("H*", substr ($output, 0, 16)) . ")\n";
1166     my @values = unpack "(w/a*)*", substr $output, 4;
1167 root 1.103 my $expected = 6 + 2 * @workdir + @runtime_path + @names;
1168 root 1.88 @values == $expected or die "invalid remote probe result\n";
1169     $home = shift @values;
1170     $bwrap = shift @values;
1171     $remote_umask = shift @values;
1172 root 1.103 my $worker_uid = shift @values;
1173     $host_dac && $worker_uid != 0
1174     and die "--host-dac needs root privileges on the remote host\n";
1175 root 1.88 @workdir = splice @values, 0, @workdir;
1176     @remote_vcs_marker = map { [length $_ ? split /,/, $_ : ()] } splice @values, 0, @workdir;
1177     @runtime_path = grep length, splice @values, 0, @runtime_path;
1178 root 1.101 @remote_program{@names} = splice @values, 0, @names;
1179     @tmp_realpath = splice @values, 0, 2;
1180     for ($home, @workdir, @runtime_path, @tmp_realpath) {
1181 root 1.88 utf8::decode $_ or die "remote path is not UTF-8\n";
1182     }
1183 root 1.98 # remote executable names and the bwrap path are OS bytes, like local discovery.
1184 root 1.88 }
1185    
1186 root 1.103 $host_dac && !$bwrap and die "--host-dac requires bubblewrap\n";
1187 root 1.51 my ($workdir_ids, $workdir_refresh);
1188    
1189     unless (defined $exec_cmd) {
1190 root 1.89 # IDs represent logical pathnames, not physical symlink destinations.
1191 root 1.51 $workdir_ids = AnyEvent::MCP::Workdirs->new (
1192     file => "$config_home/anyevent-mcp/fs-workdir-ids.json",
1193 root 1.89 paths => [map File::Spec::Unix->rel2abs ($_), @workdir_identity],
1194 root 1.51 );
1195    
1196     my $interval = .75 * $workdir_ids->{expiry};
1197 root 1.52 $workdir_refresh = AE::timer 0, $interval, sub {
1198 root 1.51 $workdir_ids->refresh;
1199     };
1200     }
1201    
1202 root 1.10 my %sandbox_grant = (
1203 root 1.1 "workdir-read" => [],
1204     "workdir-write" => ["workdir-read"],
1205 root 1.16 "all-workdirs-read" => ["workdir-read"],
1206     "all-workdirs-write" => [qw(all-workdirs-read workdir-write)],
1207 root 1.46 "homedir-read" => [],
1208     "homedir-write" => ["homedir-read"],
1209     "world-read" => [qw(all-workdirs-read homedir-read)],
1210 root 1.94 "world-write" => [qw(world-read all-workdirs-write homedir-write shared-tmp)],
1211 root 1.1 "network" => [],
1212 root 1.46 "ssh-agent" => ["network"],
1213 root 1.2 "long-running" => [],
1214 root 1.1 "shared-tmp" => [],
1215     "host-proc" => [],
1216     );
1217    
1218 root 1.10 my %sandbox_grant_description = (
1219 root 1.16 "workdir-read" => "Read files in the selected workdir",
1220     "workdir-write" => "Change files in the selected workdir",
1221     "all-workdirs-read" => "Read files in all configured workdirs",
1222     "all-workdirs-write" => "Change files in all configured workdirs",
1223 root 1.46 "homedir-read" => "Read files in your home directory",
1224     "homedir-write" => "Change files in your home directory",
1225 root 1.9 "world-read" => "Read the host filesystem",
1226     "world-write" => "Change the host filesystem",
1227     "network" => "Use network connections",
1228 root 1.46 "ssh-agent" => "Use your SSH agent to authenticate, read known_hosts and use network connections",
1229 root 1.9 "long-running" => "Run commands longer than five minutes",
1230     "shared-tmp" => "Access host temporary directories",
1231 root 1.30 "host-proc" => $bwrap ? "Host process access (unavailable with bubblewrap)"
1232     : "Full access to host processes",
1233 root 1.9 );
1234    
1235 root 1.10 my @exec_default_sandbox_grants = ("workdir-read");
1236 root 1.1
1237 root 1.88 my $umask = $remote ? $remote_umask : umask;
1238 root 1.1
1239 root 1.7 my $permissions;
1240     $permissions = AnyEvent::MCP::Server::Permissions->new (
1241 root 1.30 sandbox_grants => \%sandbox_grant,
1242 root 1.18 sandbox_grant_descriptions => sub {
1243     my ($request) = @_;
1244 root 1.33
1245 root 1.18 my %description = %sandbox_grant_description;
1246     if (defined $request->{selected_workdir}) {
1247     my $name = JSON::XS->new->utf8 (0)->allow_nonref
1248     ->encode ($request->{selected_workdir});
1249     $description{"workdir-read"} = "Read files in selected workdir $name";
1250     $description{"workdir-write"} = "Change files in selected workdir $name";
1251     }
1252     \%description
1253     },
1254 root 1.10 sandbox_implementation => $bwrap ? "bubblewrap" : "NONE - UNSANDBOXED (full host-user privileges)",
1255     actions => {
1256     inspect => "Inspect resource metadata and enumerate names",
1257     read => "Read resource contents",
1258     write => "Modify resource contents",
1259     manage => "Rename, remove, and manage filesystem paths",
1260     exec => "Run arbitrary commands",
1261 root 1.45 "vcs-diff" => "Read working-copy differences using a VCS client",
1262     "vcs-status" => "Read working-copy status using a VCS client",
1263 root 1.80 "vcs-log" => "Read revision history using a VCS client",
1264 root 1.10 },
1265 root 1.11 name => $name,
1266 root 1.47 file => "$config_home/anyevent-mcp/rules/fs-$name.ini",
1267 root 1.7 ) unless defined $exec_cmd;
1268 root 1.1
1269 root 1.96 mcp_permissions $permissions;
1270    
1271 root 1.33 # permissions group tools by their kind of access, not by MCP tool name.
1272 root 1.10 my %tool_action = (
1273     stat => "inspect", list => "inspect", find => "inspect",
1274     "file-read" => "read", "read-lines" => "read", grep => "read",
1275     "file-write" => "write", "file-patch" => "write",
1276     "path-op" => "manage", exec => "exec",
1277     );
1278    
1279 root 1.33 # normalize an API pathname lexically against its selected workdir.
1280 root 1.1 sub fs_path {
1281     my ($workdir, $path) = @_;
1282    
1283     !ref $path and length $path and $path !~ /\0/
1284     or die "path must be a nonempty string without NUL\n";
1285     $path = "$workdir/$path" unless $path =~ m{\A/};
1286    
1287     my @part;
1288    
1289     for (split m{/+}, $path) {
1290     next if !length or $_ eq ".";
1291     if ($_ eq "..") {
1292     pop @part;
1293     } else {
1294     push @part, $_;
1295     }
1296     }
1297    
1298     "/" . join "/", @part
1299     }
1300    
1301 root 1.33 # test pathname containment without resolving symlinks.
1302 root 1.1 sub fs_inside {
1303     my ($path, $directory) = @_;
1304    
1305     $directory eq "/" or $path eq $directory or (index $path, "$directory/") == 0
1306     }
1307    
1308 root 1.33 # classify relative to the selected workdir, not any configured workdir.
1309 root 1.1 sub fs_path_capability {
1310 root 1.16 my ($workdir, $path, $mode) = @_;
1311 root 1.1
1312     return "workdir-$mode"
1313 root 1.16 if fs_inside $path, $workdir;
1314     return "all-workdirs-$mode"
1315 root 1.1 if grep { fs_inside $path, $_ } @workdir;
1316 root 1.46 return "homedir-$mode" if fs_inside $path, $home;
1317 root 1.1 return "shared-tmp" if (fs_inside $path, "/tmp") or (fs_inside $path, "/var/tmp");
1318    
1319     "world-$mode"
1320     }
1321    
1322 root 1.33 # persist selected-workdir identities only where the requested authority
1323 root 1.16 # actually depends on that selection. Broad all-workdirs grants are unscoped.
1324     sub fs_grant_workdir {
1325     my ($identity, $grants) = @_;
1326 root 1.33
1327 root 1.16 my $have = $permissions->closure ($grants);
1328     return $identity if $have->{"workdir-write"} && !$have->{"all-workdirs-write"};
1329     return $identity if $have->{"workdir-read"} && !$have->{"all-workdirs-read"};
1330     undef
1331     }
1332    
1333 root 1.33 # permission prompts display the selected, verbatim configured workdir next
1334 root 1.18 # to workdir-specific grant choices. This is information, not a rule scope.
1335     sub fs_require_permission {
1336     my ($req, $identity, $request) = @_;
1337 root 1.33
1338 root 1.18 $request->{selected_workdir} = $identity;
1339     $req->require_permission ($request)
1340     }
1341    
1342 root 1.33 # direct execution has no filesystem, network or process isolation. Request
1343 root 1.10 # every grant the process effectively receives, regardless of client wishes.
1344     my @unsandboxed_grants = qw(world-write network shared-tmp host-proc);
1345     sub fs_effective_grants {
1346     return @_ if $bwrap;
1347     (@unsandboxed_grants, grep $_ eq "long-running", @_)
1348     }
1349    
1350 root 1.33 # one worker needs the combined sandbox authority for every path it will access.
1351 root 1.78 sub fs_permission_dimensions {
1352     my ($workdir, $subject, $choices) = @_;
1353     my %dimensions;
1354    
1355 root 1.93 my $host_id = $remote ? "remote $host" : "local";
1356     $dimensions{host} = {
1357     label => "Host", value => $host_id,
1358     choices => [
1359 root 1.102 { label => "This host: " . ($remote ? $host : "local"), match => $host_id },
1360 root 1.93 { label => "All hosts", match => "^" },
1361     ],
1362     };
1363 root 1.88
1364 root 1.78 $dimensions{workdir} = {
1365     label => "Workdir", value => $workdir,
1366     choices => [
1367 root 1.102 { label => "This workdir: $workdir", match => $workdir },
1368 root 1.78 { label => "Any configured workdir, including future ones", match => "^" },
1369     ],
1370     } if defined $workdir;
1371     $dimensions{subject} = {
1372     label => "Subject", value => $subject, choices => $choices,
1373     } if defined $subject;
1374    
1375     \%dimensions
1376     }
1377    
1378 root 1.1 sub fs_require_paths {
1379 root 1.48 my ($req, $workdir, $identity, $paths, $mode, $reason, $details) = @_;
1380 root 1.1
1381 root 1.15 my %seen;
1382     $paths = [grep !$seen{$_}++, @$paths];
1383 root 1.16 my %need = map { (fs_path_capability $workdir, $_, $mode) => 1 } @$paths;
1384 root 1.10 %need = map { $_ => 1 } fs_effective_grants (keys %need);
1385 root 1.16 my @grants = sort keys %need;
1386 root 1.18 my $grant = fs_require_permission ($req, $identity, {
1387 root 1.15 tool => $req->{tool},
1388     action => $tool_action{$req->{tool}},
1389 root 1.78 dimensions => fs_permission_dimensions (fs_grant_workdir ($identity, \@grants)),
1390 root 1.15 paths => $paths,
1391 root 1.16 sandbox_grants => \@grants,
1392 root 1.15 reason => $reason // "$req->{tool} " . join (", ", @$paths),
1393 root 1.48 (defined $details ? (details => $details) : ()),
1394 root 1.15 });
1395 root 1.1
1396 root 1.15 my $have = $permissions->closure ($grant);
1397 root 1.1 for my $capability (sort keys %need) {
1398     $have->{$capability}
1399     or die "permission reduced below $capability for $req->{tool}\n";
1400     }
1401    
1402     [sort keys %$have]
1403     }
1404    
1405 root 1.33 # test whether the granted capability set makes this pathname writable.
1406 root 1.1 sub fs_writable {
1407 root 1.16 my ($workdir, $path, $have) = @_;
1408 root 1.1
1409     $have->{"world-write"}
1410 root 1.16 or ($have->{"all-workdirs-write"} and (grep { fs_inside $path, $_ } @workdir))
1411     or ($have->{"workdir-write"} and fs_inside $path, $workdir)
1412 root 1.46 or ($have->{"homedir-write"} and (fs_inside $path, $home))
1413 root 1.1 or ($have->{"shared-tmp"} and ((fs_inside $path, "/tmp") or (fs_inside $path, "/var/tmp")))
1414     }
1415    
1416 root 1.33 # build the bubblewrap argv implementing the granted sandbox authority.
1417 root 1.1 sub fs_sandbox {
1418 root 1.10 my ($workdir, $sandbox_grants) = @_;
1419 root 1.7
1420     my $have = {};
1421 root 1.10 my @todo = @$sandbox_grants;
1422 root 1.7 while (@todo) {
1423     my $capability = pop @todo;
1424 root 1.10 exists $sandbox_grant{$capability} or die "Unknown permission capability '$capability'\n";
1425 root 1.7 next if $have->{$capability}++;
1426 root 1.10 push @todo, @{$sandbox_grant{$capability}};
1427     }
1428    
1429     unless ($bwrap) {
1430     unless (defined $exec_cmd) {
1431     $have->{$_} or die "unsandboxed execution needs $_ grant\n"
1432     for @unsandboxed_grants;
1433     }
1434     return ();
1435 root 1.7 }
1436    
1437 root 1.1 die "host-proc sandbox authority is unresolved and unavailable in v1\n"
1438     if $have->{"host-proc"};
1439    
1440 root 1.98 my $workdir_bytes = AnyEvent::MCP::utf8enc $workdir;
1441     my $home_bytes = AnyEvent::MCP::utf8enc $home;
1442 root 1.101 my $configured_root = grep $_->[2] eq "/", @mount;
1443     my $configured_root_write = grep $_->[2] eq "/" && $_->[1] eq "rw", @mount;
1444     my $root_read = $configured_root || $have->{"world-read"}
1445     || ($have->{"workdir-read"} && $workdir eq "/")
1446     || ($have->{"all-workdirs-read"} && grep $_ eq "/", @workdir);
1447     my $root_write = $configured_root_write || $have->{"world-write"}
1448     || ($have->{"workdir-write"} && $workdir eq "/")
1449     || ($have->{"all-workdirs-write"} && grep $_ eq "/", @workdir);
1450     $root_read ||= $root_write;
1451    
1452 root 1.98
1453 root 1.1 my @command = ($bwrap, qw(
1454 root 1.7 --die-with-parent
1455 root 1.103 --unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup-try
1456 root 1.1 ));
1457 root 1.103 push @command, "--unshare-user", "--disable-userns" unless $host_dac;
1458     push @command, "--cap-drop", "ALL";
1459     push @command, "--cap-add", "CAP_DAC_OVERRIDE" if $host_dac;
1460 root 1.7 push @command, "--new-session" unless defined $exec_cmd;
1461 root 1.1
1462     push @command, "--unshare-net" unless $have->{network};
1463    
1464 root 1.101 # Prepare the private root before any host bind; never write to host passwd.
1465 root 1.103 push @command, "--file", 3, "/etc/passwd"
1466     unless $root_read || (@exec_prefix && !$remote);
1467     push @command, "--ro-bind", "/etc/passwd", "/etc/passwd" if @exec_prefix && !$remote && !$root_read;
1468 root 1.101 push @command, $root_write ? "--bind-try" : "--ro-bind-try", "/", "/"
1469     if $root_read;
1470 root 1.1
1471     my @scope_mount;
1472    
1473 root 1.101 unless ($root_read) {
1474 root 1.19 for my $path (@runtime_path) {
1475 root 1.16 my $mount = (fs_writable $workdir, $path, $have) ? "--bind-try" : "--ro-bind-try";
1476 root 1.98 my $bytes = AnyEvent::MCP::utf8enc $path;
1477 root 1.101 push @scope_mount, [$mount, $bytes, $bytes];
1478 root 1.1 }
1479     }
1480    
1481 root 1.46 if ($have->{"homedir-read"}
1482 root 1.101 and (!$root_read or ($have->{"homedir-write"} and !$root_write))) {
1483 root 1.16 my $mount = (fs_writable $workdir, $home, $have) ? "--bind-try" : "--ro-bind-try";
1484 root 1.101 push @scope_mount, [$mount, $home_bytes, $home_bytes];
1485 root 1.1 }
1486    
1487 root 1.16 if ($have->{"all-workdirs-read"}) {
1488 root 1.1 for my $path (@workdir) {
1489 root 1.101 next if $path eq "/";
1490 root 1.16 my $mount = ($have->{"all-workdirs-write"}
1491     or ($path eq $workdir && $have->{"workdir-write"}))
1492     ? "--bind" : "--ro-bind";
1493 root 1.98 my $bytes = AnyEvent::MCP::utf8enc $path;
1494 root 1.101 push @scope_mount, [$mount, $bytes, $bytes];
1495 root 1.1 }
1496 root 1.101 } elsif ($have->{"workdir-read"} && $workdir ne "/") {
1497 root 1.16 my $mount = $have->{"workdir-write"} ? "--bind" : "--ro-bind";
1498 root 1.101 push @scope_mount, [$mount, $workdir_bytes, $workdir_bytes];
1499 root 1.1 }
1500    
1501 root 1.101 # Host binds go parent-first so broader mounts cannot hide narrower grants.
1502     # Skip a nested read that is already covered by an equally strong mount.
1503     my @visible;
1504     for my $entry (sort { length $a->[2] <=> length $b->[2] } @scope_mount) {
1505     my ($mode, undef, $path) = @$entry;
1506     my $write = $mode =~ /\A--bind/;
1507     next if $root_write || ($root_read && !$write);
1508     next if grep {
1509     fs_inside ($path, $_->[2]) && (!$write || $_->[0] =~ /\A--bind/)
1510     } @visible;
1511     push @command, @$entry;
1512     push @visible, $entry;
1513 root 1.1 }
1514    
1515 root 1.101 # Avoid remounting DNS files already visible through a granted parent,
1516     # which could itself expose symlinked destinations.
1517 root 1.1 for my $path (qw(/etc/resolv.conf /etc/hosts)) {
1518 root 1.101 next if $root_read || grep { fs_inside $path, $_->[2] } @visible;
1519     my $mount = fs_writable ($workdir, $path, $have) ? "--bind-try" : "--ro-bind-try";
1520 root 1.1 push @command, $mount, $path, $path;
1521     }
1522    
1523 root 1.101 # Configured mounts are explicit exceptions. Apply an ancestor of a
1524     # private mount first; the narrow exception itself may follow afterward.
1525     my @configured_after;
1526 root 1.19 unless ($have->{"world-write"}) {
1527     for my $entry (@mount) {
1528     my ($key, $mode, $path) = @$entry;
1529 root 1.101 next if $path eq "/";
1530     next if $mode eq "ro" && ($root_read || fs_writable $workdir, $path, $have);
1531     my $bytes = AnyEvent::MCP::utf8enc $path;
1532     my @bind = (($mode eq "rw" ? "--bind" : "--ro-bind")
1533     . ($key =~ /\Amount-try\./ ? "-try" : ""), $bytes, $bytes);
1534     if (grep { $_ ne $path && fs_inside $_, $path } qw(/sys /run /dev /proc /tmp /var/tmp)) {
1535     push @command, @bind;
1536     } else {
1537     push @configured_after, @bind;
1538     }
1539     }
1540     }
1541    
1542     # Private facilities override broad host mounts.
1543     push @command, qw(--tmpfs /sys --tmpfs /run --dev /dev);
1544     push @command, "--proc", "/proc";
1545    
1546     my @tmp = qw(/tmp /var/tmp);
1547     for my $i (0 .. $#tmp) {
1548     my $tmp = $tmp[$i];
1549     next if $root_write;
1550     # Explicit access to the temporary directory itself takes precedence.
1551     next if (grep $_->[2] eq $tmp, @mount)
1552     || ($have->{"workdir-read"} && $workdir eq $tmp)
1553     || ($have->{"all-workdirs-read"} && grep $_ eq $tmp, @workdir);
1554    
1555     # If the host symlink is visible, bwrap must mount on its real target,
1556     # not the symlink. The remote probe supplies remote, not local, paths.
1557     my $visible = $root_read
1558     || (grep { fs_inside $tmp, $_->[2] } @visible)
1559     || (grep { fs_inside $tmp, $_->[2] } @mount);
1560     my $destination = $visible ? AnyEvent::MCP::utf8enc $tmp_realpath[$i] : $tmp;
1561     if ($have->{"shared-tmp"}) {
1562     push @command, "--bind-try", $tmp, $destination;
1563     } else {
1564     push @command, "--perms", "1777", "--tmpfs", $destination;
1565 root 1.19 }
1566     }
1567    
1568 root 1.101 # Narrow explicit exceptions are allowed to override the private baseline.
1569     push @command, @configured_after;
1570 root 1.6
1571 root 1.46 my $agent_socket;
1572     if ($have->{"ssh-agent"}) {
1573 root 1.88 if ($remote) {
1574     $agent_socket = "/run/anyevent-mcp/ssh-agent";
1575     push @command, "--ro-bind", "__MCP_REMOTE_AGENT__", $agent_socket;
1576     } elsif (length $ENV{SSH_AUTH_SOCK}) {
1577 root 1.46 $ENV{SSH_AUTH_SOCK} =~ m{\A/} && -S $ENV{SSH_AUTH_SOCK}
1578     or die "SSH_AUTH_SOCK must name an absolute existing socket\n";
1579     my $source = $ENV{SSH_AUTH_SOCK};
1580     $agent_socket = "/run/anyevent-mcp/ssh-agent";
1581     push @command, "--ro-bind", $source, $agent_socket;
1582     }
1583 root 1.98 push @command, "--ro-bind-try", "$home_bytes/.ssh/known_hosts", "$home_bytes/.ssh/known_hosts"
1584 root 1.46 unless $have->{"homedir-read"} || $have->{"world-read"};
1585     }
1586    
1587 root 1.1 push @command, "--remount-ro", "/"
1588 root 1.101 unless $root_write;
1589 root 1.1
1590     push @command,
1591     "--clearenv",
1592 root 1.59 "--setenv", "PATH", $sandbox_path,
1593 root 1.1 "--setenv", "LC_ALL", "C.UTF-8",
1594 root 1.82 "--setenv", "PERL_BADLANG", "0",
1595 root 1.98 "--setenv", "HOME", $home_bytes;
1596 root 1.7
1597     for my $name (qw(TERM COLORTERM TERMINFO TERMINFO_DIRS NO_COLOR CLICOLOR CLICOLOR_FORCE LINES COLUMNS)) {
1598     push @command, "--setenv", $name, $ENV{$name} if defined $ENV{$name};
1599     }
1600    
1601 root 1.46 for my $entry (@environment) {
1602     my ($name, $value) = @$entry;
1603     next if $name eq "SSH_AUTH_SOCK";
1604 root 1.98 $value = defined $value ? AnyEvent::MCP::utf8enc $value : $ENV{$name};
1605 root 1.46 push @command, "--setenv", $name, $value if defined $value;
1606     }
1607     push @command, "--setenv", "SSH_AUTH_SOCK", $agent_socket if defined $agent_socket;
1608    
1609 root 1.7 push @command,
1610 root 1.98 "--chdir", defined $exec_cmd ? $workdir_bytes : $have->{"workdir-read"} ? $workdir_bytes : "/tmp",
1611 root 1.1 "--";
1612    
1613     @command
1614     }
1615    
1616 root 1.33 # pass a private synthetic user entry to bubblewrap without a host passwd mount.
1617 root 1.7 sub fs_passwd_fd {
1618     my ($read, $write) = AnyEvent::Util::portable_pipe
1619     or die "passwd pipe: $!\n";
1620 root 1.98 print {$write} "sandbox:x:$<:" . POSIX::getgid () . ":Sandbox:" . (AnyEvent::MCP::utf8enc $home) . ":/bin/sh\n"
1621 root 1.7 or die "passwd pipe: $!\n";
1622     close $write or die "passwd pipe: $!\n";
1623    
1624     if (fileno ($read) != 3) {
1625     POSIX::dup2 (fileno ($read), 3) == 3 or die "passwd dup2: $!\n";
1626     close $read;
1627     open $read, "<&=3" or die "passwd fd: $!\n";
1628     }
1629    
1630     defined fcntl ($read, F_SETFD, 0) or die "passwd fcntl: $!\n";
1631     $read
1632     }
1633    
1634     if (defined $exec_cmd) {
1635 root 1.10 my @sandbox_grants = length $exec_cmd ? split /,/, $exec_cmd, -1 : ();
1636 root 1.103 my @command = (@exec_prefix, (fs_sandbox $workdir[0], \@sandbox_grants),
1637 root 1.7 (@ARGV ? @ARGV : ($ENV{SHELL} || "/bin/sh")));
1638 root 1.103 my $passwd_fd = fs_passwd_fd () if $bwrap && !@exec_prefix;
1639 root 1.7
1640     exec {$command[0]} @command;
1641     die "exec $command[0]: $!\n";
1642     }
1643    
1644 root 1.56 # Perl is required in the worker namespace; discover the other tools there too.
1645     my $probe = q{#perl
1646     use 5.008004;
1647     use Cwd ();
1648    
1649     my %test = (
1650     find => [[qw(--version)], qr/^find \(GNU findutils\)/m],
1651     grep => [[qw(--version)], qr/^grep \(GNU grep\)/m],
1652     patch => [[qw(--version)], qr/^GNU patch /m],
1653 root 1.76 sed => [[qw(--version)], qr/\(GNU sed\)/],
1654 root 1.56 );
1655    
1656     sub find_executable {
1657     my ($name) = @_;
1658    
1659     return unless length $name && $name !~ /\0/;
1660     my @path = $name =~ m{/} ? ($name)
1661     : map { (length $_ ? $_ : ".") . "/$name" } split /:/, $ENV{PATH}, -1;
1662     for (@path) {
1663     next unless -f $_ && -x _;
1664     return m{\A/} ? $_ : Cwd::getcwd () . "/$_";
1665     }
1666     undef
1667     }
1668    
1669     while (@ARGV) {
1670     my ($name, $wanted, $explicit) = splice @ARGV, 0, 3;
1671     my $path = find_executable $wanted;
1672     if ($path && !$explicit && $test{$name}) {
1673     my ($args, $match) = @{$test{$name}};
1674     my $ok = open my $fh, "-|", $path, @$args;
1675     if ($ok) {
1676     my $output = do { local $/; <$fh> };
1677 root 1.54 $ok = close $fh && $output =~ $match;
1678     }
1679 root 1.56 undef $path unless $ok;
1680 root 1.54 }
1681 root 1.56 $path ||= "";
1682     print "$path\0";
1683 root 1.54 }
1684 root 1.56 };
1685    
1686     my @names = qw(perl find grep patch sed git svn cvs hg);
1687 root 1.98 my $perl_name = $executable{perl} || "perl";
1688 root 1.88 my %program = %remote_program;
1689     unless ($remote) {
1690 root 1.103 my @command = (@exec_prefix, $bwrap ? (fs_sandbox $workdir[0], []) : (),
1691 root 1.88 $perl_name, "-e", $probe,
1692 root 1.98 map { ($_, $executable{$_} || $_, length $settings{"executable.$_"} ? 1 : 0) } @names);
1693 root 1.88 my $pid = open my $fh, "-|";
1694     defined $pid or die "Perl probe fork: $!\n";
1695 root 1.54
1696 root 1.88 unless ($pid) {
1697 root 1.103 my $passwd_fd = fs_passwd_fd () if $bwrap && !@exec_prefix;
1698 root 1.88 exec {$command[0]} @command;
1699     warn "exec $command[0]: $!\n";
1700     POSIX::_exit 126;
1701     }
1702    
1703     my $output = do { local $/; <$fh> };
1704     close $fh or die "Perl '$perl_name' cannot run in the worker environment\n";
1705     my @paths = split /\0/, $output, -1;
1706     @paths == @names + 1 && pop @paths eq ""
1707     or die "invalid Perl executable probe response\n";
1708     @program{@names} = @paths;
1709 root 1.54 }
1710 root 1.56 $program{perl} or die "Perl interpreter not found inside the sandbox\n";
1711 root 1.54 ($find, $grep, $patch, $perl, $sed) = @program{qw(find grep patch perl sed)};
1712 root 1.56 warn "$_: not detected or wrong version\n"
1713     for grep !$program{$_}, qw(find grep patch sed);
1714     %vcs_program = map { ($_ => $program{$_}) } qw(git svn cvs hg);
1715 root 1.54
1716 root 1.90 AE::log warn => "bubblewrap unavailable, running without sandbox"
1717     if $sandbox_mode eq "auto" && !$bwrap;
1718 root 1.10
1719 root 1.1 AnyEvent::detect; # proc_spawn creates child watchers after they might have died
1720    
1721 root 1.38 # spawn stdin/stdout and stderr handles; proc_destroy closes them and terminates the child.
1722     # the independent child watcher remains alive until the child is reaped.
1723 root 1.1 sub proc_spawn {
1724     my ($program, @argv) = @_;
1725    
1726 root 1.88 my $remote_input;
1727     if ($remote) {
1728 root 1.98 $remote_input = $remote_launcher . fs_remote_frame ($bwrap ? AnyEvent::MCP::utf8enc $home : "", $program, @argv);
1729 root 1.88 ($program, @argv) = (@rsh, $host, $remote_command);
1730 root 1.103 } elsif (@exec_prefix) {
1731     ($program, @argv) = (@exec_prefix, $program, @argv);
1732 root 1.88 }
1733    
1734 root 1.1 my ($parent, $child) = AnyEvent::Util::portable_socketpair
1735     or die "socketpair: $!\n";
1736     my ($error, $child_error) = AnyEvent::Util::portable_pipe
1737     or die "pipe: $!\n";
1738    
1739     my $pid = fork;
1740     defined $pid or die "fork: $!\n";
1741    
1742     unless ($pid) {
1743     open STDIN, "<&", $child or POSIX::_exit 126;
1744     open STDOUT, ">&", $child or POSIX::_exit 126;
1745     open STDERR, ">&", $child_error or POSIX::_exit 126;
1746    
1747     close $parent;
1748     close $error;
1749     close $child;
1750     close $child_error;
1751    
1752 root 1.103 my $passwd_fd = fs_passwd_fd () if $bwrap && !$remote && !@exec_prefix;
1753     AnyEvent::Util::close_all_fds_except (0, 1, 2, ($bwrap && !$remote && !@exec_prefix ? 3 : ()));
1754 root 1.1
1755     exec {$program} $program, @argv;
1756     print STDERR "exec $program: $!\n";
1757     POSIX::_exit 126;
1758     }
1759    
1760     close $child;
1761     close $child_error;
1762    
1763     my $io = AnyEvent::Handle->new (fh => $parent);
1764     my $err = AnyEvent::Handle->new (fh => $error);
1765 root 1.42 my $proc = { pid => $pid };
1766 root 1.1
1767 root 1.33 # keep the watcher alive independently so a fatal I/O error still reaps.
1768 root 1.1 $proc->{child} = AE::child $pid, sub {
1769 root 1.42 $proc->{status} = $_[1];
1770 root 1.1 delete $proc->{pid};
1771     delete $proc->{child};
1772 root 1.42 (delete $proc->{done})->($proc->{status}) if $proc->{done};
1773 root 1.1 };
1774    
1775 root 1.38 $io->{proc} = $proc;
1776     $io->{proc_err} = $err;
1777 root 1.88 $io->push_write ($remote_input) if $remote;
1778 root 1.1
1779     $io
1780     }
1781    
1782 root 1.33 # signal a spawned process while its pid is still owned by us.
1783 root 1.1 sub proc_kill {
1784     my ($proc, $signal) = @_;
1785    
1786     my $pid = $proc->{pid} or return;
1787    
1788     $signal //= "KILL";
1789     kill $signal => $pid
1790     }
1791    
1792 root 1.38 # abandon a process and release its streams; repeated cleanup is harmless.
1793     sub proc_destroy {
1794     my ($io) = @_;
1795    
1796     proc_kill (delete $io->{proc}) if $io->{proc};
1797     (delete $io->{proc_err})->destroy if $io->{proc_err};
1798     $io->destroy;
1799     }
1800    
1801 root 1.42 # register the process's exit callback, or call it immediately if reaped.
1802 root 1.1 sub proc_on_exit {
1803     my ($proc, $done) = @_;
1804    
1805     return $done->($proc->{status})
1806     if defined $proc->{status};
1807    
1808 root 1.42 $proc->{done} = $done;
1809 root 1.1 }
1810    
1811 root 1.85 # run a one-shot subprocess, leaving output interpretation to the caller.
1812     # the stream callbacks consume rbuf and may return true to terminate the child.
1813 root 1.35 sub proc_run {
1814     my ($req, $command, $input, $timeout, $on_stdout, $on_stderr, $done) = @_;
1815 root 1.1
1816 root 1.33 my $io = proc_spawn @$command;
1817 root 1.38 my $err = $io->{proc_err};
1818 root 1.1 my $proc = $io->{proc};
1819 root 1.42 my ($open, $timed_out, $failure, $timer) = (2);
1820 root 1.35
1821 root 1.1 my $complete = sub {
1822 root 1.42 return if $open || !defined $proc->{status};
1823     my $cb = $done or return;
1824     undef $done;
1825 root 1.1 undef $timer;
1826 root 1.38 proc_destroy $io;
1827 root 1.1 return if $req->{finished};
1828     delete $req->{on_cancel};
1829 root 1.42 $cb->($proc->{status}, $timed_out, $failure);
1830 root 1.1 };
1831    
1832 root 1.43 my $stop = sub {
1833     undef $timer;
1834     delete $req->{on_cancel};
1835     $open = 0;
1836     proc_destroy $io;
1837     $complete->();
1838     };
1839     $req->{on_cancel} = $stop;
1840    
1841 root 1.38 for my $stream ([$io, $on_stdout], [$err, $on_stderr]) {
1842 root 1.35 my ($handle, $consume) = @$stream;
1843     my $closed;
1844 root 1.1 my $finish = sub {
1845 root 1.35 return if $closed++;
1846 root 1.1 --$open;
1847     $complete->();
1848     };
1849 root 1.35 my $read = sub {
1850     my ($handle, $eof) = @_;
1851     my $stop = eval { $consume->($handle, $eof) };
1852     if ($@) {
1853     $failure ||= $@;
1854     $stop = 1;
1855     }
1856     proc_kill $proc if $stop;
1857     };
1858     $handle->on_read (sub { $read->($_[0], 0) });
1859 root 1.1 $handle->on_eof (sub {
1860 root 1.35 $read->($_[0], 1);
1861 root 1.1 $finish->();
1862     });
1863     $handle->on_error (sub {
1864     return unless $_[1];
1865 root 1.35 $read->($_[0], 0);
1866     $failure ||= "subprocess I/O: $_[2]\n" unless $timed_out;
1867 root 1.1 proc_kill $proc;
1868     $finish->();
1869     });
1870     }
1871    
1872     my $ok = eval {
1873     $io->push_write ($input) if length $input;
1874     $io->push_shutdown;
1875     1
1876     };
1877     unless ($ok) {
1878 root 1.38 my $error = $@;
1879     proc_destroy $io;
1880     die $error;
1881 root 1.1 }
1882    
1883     $timer = AE::timer $timeout, 0, sub {
1884     $timed_out = 1;
1885 root 1.43 $stop->();
1886 root 1.1 };
1887    
1888 root 1.42 proc_on_exit $proc, $complete;
1889 root 1.1 }
1890    
1891 root 1.85 # run a subprocess and collect bounded output.
1892 root 1.35 sub fs_run {
1893     my ($req, $command, $input, $timeout, $byte_limit, $line_limit, $done) = @_;
1894    
1895     my ($truncated, $line_truncated);
1896     my ($stdout, $stderr) = ("", "");
1897    
1898     my $capture = sub {
1899     my ($target, $data, $lines) = @_;
1900    
1901     return if $lines and $line_truncated;
1902     return unless length $data;
1903     my $remaining = $byte_limit - (length $stdout) - (length $stderr);
1904     $truncated = 1 if length $data > $remaining;
1905     $$target .= substr $data, 0, $remaining
1906     if $remaining > 0;
1907    
1908     if ($lines and $line_limit and ($$target =~ tr/\n//) > $line_limit) {
1909     my $end = -1;
1910     $end = index $$target, "\n", $end + 1
1911     for 1 .. $line_limit;
1912     substr $$target, $end + 1, length $$target, "";
1913     $truncated = $line_truncated = 1;
1914     return 1
1915     }
1916    
1917     0
1918     };
1919    
1920     proc_run $req, $command, $input, $timeout,
1921     sub {
1922     my ($handle) = @_;
1923     my $stop = $capture->(\$stdout, $handle->{rbuf}, 1);
1924     $handle->{rbuf} = "";
1925     $stop
1926     },
1927     sub {
1928     my ($handle) = @_;
1929     $capture->(\$stderr, $handle->{rbuf}, 0);
1930     $handle->{rbuf} = "";
1931     0
1932     },
1933     sub {
1934     my ($status, $timed_out, $failure) = @_;
1935     return $done->(0, $failure) if $failure;
1936     $done->(1, +{
1937     stdout => $stdout,
1938     stderr => $stderr,
1939     exit => $status >> 8,
1940     signal => $status & 127,
1941     timed_out => $timed_out ? JSON::XS::true : JSON::XS::false,
1942 root 1.43 truncated => $truncated || $timed_out ? JSON::XS::true : JSON::XS::false,
1943 root 1.35 });
1944     };
1945     }
1946    
1947 root 1.1 my $perl_slave = q{#perl
1948     binmode STDIN;
1949     binmode STDOUT;
1950     $SIG{PIPE} = "IGNORE";
1951     $| = 1;
1952    
1953     for (;;) {
1954     4 == read STDIN, my $buf, 4
1955     or last;
1956     my $len = unpack "N", $buf;
1957     $len == read STDIN, $buf, $len
1958     or die "short perl frame\n";
1959    
1960     my @result = do {
1961     (my $source, local @ARGV) = unpack "(w/a*)*", $buf;
1962     ("+", eval $source)
1963     };
1964    
1965     @result = ("-", $@)
1966     if $@;
1967    
1968     print pack "N/a*", pack "(w/a*)*", @result
1969     or die "stdout: $!\n"
1970     }
1971     };
1972    
1973     sub perl_frame {
1974     pack "N/a*", pack "(w/a*)*", @_
1975     }
1976    
1977 root 1.36 my $perl_worker;
1978    
1979 root 1.1 sub perl_eval {
1980 root 1.39 my ($req, $workdir, $sandbox_grants, $source, $args, $timeout, $done) = @_;
1981 root 1.33
1982 root 1.10 my @command = ((fs_sandbox $workdir, $sandbox_grants), $perl, "-e", $perl_slave);
1983 root 1.36 my $key = pack "(w/a*)*", @command;
1984 root 1.40 my $io = $perl_worker;
1985 root 1.36 undef $perl_worker;
1986    
1987 root 1.40 if ($io && ($io->{perl_key} ne $key || !$io->{proc}{pid})) {
1988     proc_destroy $io;
1989     undef $io;
1990 root 1.36 }
1991 root 1.1
1992 root 1.40 unless ($io) {
1993     $io = proc_spawn @command;
1994 root 1.39 $io->{rbuf_max} = (1 << 20) + 4096;
1995 root 1.40 $io->{perl_key} = $key;
1996     weaken (my $this = $io);
1997 root 1.41
1998     my $idle_destroy = sub {
1999     my $io = $this or return;
2000    
2001     undef $perl_worker if $perl_worker && $perl_worker == $io;
2002     proc_destroy $io;
2003     };
2004     my $idle_stderr = sub { $_[0]{rbuf} = "" };
2005    
2006     $io->{perl_idle} = sub {
2007     my ($io) = @_;
2008     my $err = $io->{proc_err};
2009    
2010     $io ->on_eof ($idle_destroy);
2011     $io ->on_error ($idle_destroy);
2012     $err->on_eof ($idle_destroy);
2013     $err->on_error ($idle_destroy);
2014     $err->on_read ($idle_stderr );
2015     $io ->on_read ($idle_destroy);
2016     };
2017    
2018 root 1.36 proc_on_exit $io->{proc}, sub {
2019 root 1.41 my $io = $this or return;
2020 root 1.38
2021 root 1.41 $io->{perl_on_exit}->($_[0]) if $io->{perl_on_exit};
2022     $idle_destroy->() if $perl_worker && $perl_worker == $io;
2023 root 1.36 };
2024     }
2025 root 1.33
2026 root 1.36 my $err = $io->{proc_err};
2027 root 1.42 my $timer;
2028 root 1.36 my $stderr = "";
2029    
2030     my $finish = sub {
2031 root 1.39 my ($failure, $payload) = @_;
2032 root 1.42 my $cb = $done or return;
2033     undef $done;
2034 root 1.36 undef $timer;
2035     delete $req->{on_cancel};
2036 root 1.40 delete $io->{perl_on_exit};
2037 root 1.36
2038 root 1.40 if ($failure || defined $io->{proc}{status}) {
2039 root 1.57 my $status = $io->{proc}{status};
2040     my $reason = $failure || "exited (exit " . ($status >> 8)
2041     . ", signal " . ($status & 127) . ")";
2042     AE::log warn => "sandbox perl worker: $reason"
2043     unless $failure && $failure =~ /\Asandbox perl (?:cancelled|timed out)\n\z/;
2044 root 1.38 proc_destroy $io;
2045 root 1.42 return $cb->(0, $failure || "sandbox perl exited\n")
2046 root 1.36 unless $req->{finished};
2047     return;
2048     }
2049 root 1.1
2050     my (@response, $error);
2051     my $ok = eval {
2052 root 1.39 @response = unpack "(w/a*)*", $payload;
2053 root 1.1 my $status = shift @response;
2054     defined $status or die "missing perl response status\n";
2055     if ($status eq "-") {
2056 root 1.6 $error = AnyEvent::MCP::utf8dec $response[0];
2057 root 1.1 } elsif ($status ne "+") {
2058     die "invalid perl response status\n";
2059     }
2060     1
2061     };
2062    
2063 root 1.36 unless ($ok) {
2064 root 1.38 my $error = $@;
2065 root 1.57 AE::log warn => "sandbox perl worker: $error";
2066 root 1.38 proc_destroy $io;
2067 root 1.42 return $cb->(0, $error) unless $req->{finished};
2068 root 1.36 return;
2069     }
2070    
2071 root 1.38 # the response completes the request; the interpreter remains idle.
2072 root 1.40 proc_destroy $perl_worker if $perl_worker;
2073     $perl_worker = $io;
2074 root 1.41 $io->{perl_idle}->($io);
2075 root 1.36 return if $req->{finished};
2076 root 1.42 return $cb->(0, $error) if defined $error;
2077     $cb->(1, \@response);
2078 root 1.1 };
2079 root 1.36
2080 root 1.40 $io->{perl_on_exit} = sub {
2081 root 1.36 my ($status) = @_;
2082     $finish->("sandbox perl failed (exit " . ($status >> 8)
2083     . ", signal " . ($status & 127) . "): $stderr\n");
2084     };
2085    
2086     $req->{on_cancel} = sub { $finish->("sandbox perl cancelled\n") };
2087     $io->on_eof (sub { $finish->("short perl response\n") });
2088     $io->on_error (sub { $finish->("sandbox perl stdout: $_[2]\n") if $_[1] });
2089     $err->on_read (sub {
2090 root 1.39 $stderr .= substr $_[0]{rbuf}, 0, 65536 - length $stderr
2091     if length $stderr < 65536;
2092 root 1.36 $_[0]{rbuf} = "";
2093     });
2094     $err->on_eof (sub { $finish->("sandbox perl stderr closed\n") });
2095     $err->on_error (sub { $finish->("sandbox perl stderr: $_[2]\n") if $_[1] });
2096    
2097     $timer = AE::timer $timeout, 0, sub { $finish->("sandbox perl timed out\n") };
2098 root 1.39 my $ok = eval {
2099     $io->push_read (packstring => "N", sub {
2100     my ($io, $payload) = @_;
2101    
2102     return $finish->("unexpected sandbox perl output\n")
2103     if length $io->{rbuf};
2104     $finish->(undef, $payload);
2105     });
2106     $io->push_write (perl_frame $source, @$args);
2107     1
2108     };
2109 root 1.36 $finish->($@) unless $ok;
2110 root 1.1 }
2111    
2112 root 1.51 # resolve a configured workdir by persistent ID or exact path.
2113 root 1.1 sub fs_workdir {
2114 root 1.21 my ($id) = @_;
2115 root 1.1
2116 root 1.51 $id = $workdir_ids->{ids}[0] if !defined $id and @workdir == 1;
2117 root 1.1
2118 root 1.21 defined $id && !ref $id
2119 root 1.51 or die "select a configured workdir by ID or exact path\n";
2120 root 1.21
2121     my $i;
2122     if ($id =~ /\A[0-9]+\z/) {
2123 root 1.51 ($i) = grep $workdir_ids->{ids}[$_] == $id, 0 .. $#workdir;
2124     defined $i or die "Unknown workdir ID '$id'\n";
2125 root 1.21 } else {
2126     my @match = grep { $id eq $workdir_identity[$_] || $id eq $workdir[$_] } 0 .. $#workdir;
2127     @match == 1 or die @match
2128     ? "Ambiguous configured workdir '$id'\n"
2129     : "Unknown configured workdir '$id'\n";
2130     $i = $match[0];
2131     }
2132 root 1.1
2133 root 1.16 wantarray ? ($workdir[$i], $workdir_identity[$i]) : $workdir[$i]
2134 root 1.1 }
2135    
2136 root 1.21 my $workdir_schema = {
2137 root 1.74 description => "Workdir ID or configured absolute path. Get IDs/paths from cerebro-fs.workdirs. IDs are persistent.",
2138 root 1.21 anyOf => [
2139 root 1.51 { type => "integer", minimum => 1 },
2140 root 1.21 { type => "string", minLength => 1 },
2141     ],
2142     };
2143    
2144 root 1.45 sub fs_result;
2145    
2146     # command output options affect presentation, never argv or permission scope.
2147     my %output_schema = (
2148 root 1.74 grep => { type => "string", description => "Optional PCRE stdout line filter after capture; no shell." },
2149 root 1.45 max_lines => { type => "integer", minimum => 1, maximum => 100000, default => 2000,
2150 root 1.74 description => "Max stdout lines after filtering." },
2151 root 1.45 max_bytes => { type => "integer", minimum => 1, maximum => 1048576, default => 65536,
2152 root 1.74 description => "Max returned UTF-8 bytes per stream; stderr unfiltered." },
2153 root 1.45 );
2154    
2155     sub fs_output_options {
2156     my ($args) = @_;
2157    
2158     my %options = (max_lines => $args->{max_lines} // 2000, max_bytes => $args->{max_bytes} // 65536);
2159     for my $name (qw(max_lines max_bytes)) {
2160     $options{$name} =~ /\A[0-9]+\z/ && $options{$name} >= 1
2161     && $options{$name} <= $output_schema{$name}{maximum}
2162     or die "invalid $name\n";
2163     }
2164    
2165     if (exists $args->{grep}) {
2166     defined $args->{grep} && !ref $args->{grep} && $args->{grep} !~ /\0/
2167     && length $args->{grep} <= 65536
2168     or die "grep must be a string without NUL, at most 65536 characters\n";
2169     $grep or die "output filtering needs GNU grep\n";
2170     $options{grep} = AnyEvent::MCP::utf8enc $args->{grep};
2171     }
2172    
2173     \%options
2174     }
2175    
2176     # clipping a UTF-8 stream may leave at most three bytes of an incomplete character.
2177     sub fs_output_text {
2178     my ($bytes, $cut) = @_;
2179    
2180     for (0 .. ($cut ? 3 : 0)) {
2181     my $text = $bytes;
2182     return $text if utf8::decode $text;
2183     chop $bytes;
2184     }
2185    
2186     die "command output is not UTF-8\n";
2187     }
2188    
2189     # preserve child status while bounding capture and, separately, returned output.
2190     sub fs_command {
2191 root 1.46 my ($req, $workdir, $grants, $argv, $input, $timeout, $options, $cwd) = @_;
2192    
2193 root 1.98 # argv and input are octets; workdir and cwd are Unicode paths.
2194     my $workdir_bytes = AnyEvent::MCP::utf8enc $workdir;
2195    
2196 root 1.46 # check the actual directory inside the worker, after resolving symlinks.
2197     $argv = [$perl, "-MCwd", "-e", q{
2198     my $workdir = shift;
2199     chdir shift or die "VCS chdir: $!\n";
2200     my $cwd = Cwd::getcwd ();
2201     defined $cwd && ($workdir eq "/" || $cwd eq $workdir || (index $cwd, "$workdir/") == 0)
2202     or die "VCS cwd must resolve within the selected workdir\n";
2203     exec {$ARGV[0]} @ARGV;
2204     die "VCS exec: $!\n";
2205 root 1.98 }, $workdir_bytes, (AnyEvent::MCP::utf8enc $cwd), @$argv] if defined $cwd;
2206 root 1.45
2207     my @command = ((fs_sandbox $workdir, $grants),
2208     $bwrap ? @$argv
2209 root 1.98 : ($remote ? $perl : $^X, "-e", 'chdir shift or die "chdir: $!"; exec @ARGV; die "exec: $!";', $workdir_bytes, @$argv));
2210 root 1.45 my ($stdout, $stderr) = ("", "");
2211     my ($stdout_cut, $stderr_cut);
2212    
2213     my $capture = sub {
2214     my ($handle, $out, $cut) = @_;
2215     my $remaining = 1048576 - length $$out;
2216     $$cut = 1 if length $handle->{rbuf} > $remaining;
2217     $$out .= substr $handle->{rbuf}, 0, $remaining if $remaining > 0;
2218     $handle->{rbuf} = "";
2219     0
2220     };
2221    
2222     proc_run $req, \@command, $input, $timeout,
2223     sub { $capture->($_[0], \$stdout, \$stdout_cut) },
2224     sub { $capture->($_[0], \$stderr, \$stderr_cut) },
2225     sub {
2226     my ($status, $timed_out, $failure) = @_;
2227     return $req->error ($failure) if $failure;
2228    
2229     my $result = {
2230     exit => $status >> 8, signal => $status & 127,
2231     timed_out => $timed_out ? JSON::XS::true : JSON::XS::false,
2232     capture_truncated => $stdout_cut || $stderr_cut || $timed_out ? JSON::XS::true : JSON::XS::false,
2233     };
2234    
2235     my $finish = sub {
2236     my $ok = eval {
2237     $stdout_cut = 1 if length $stdout > $options->{max_bytes};
2238     $stderr_cut = 1 if length $stderr > $options->{max_bytes};
2239     my $out = fs_output_text (substr ($stdout, 0, $options->{max_bytes}), $stdout_cut);
2240     my $err = fs_output_text (substr ($stderr, 0, $options->{max_bytes}), $stderr_cut);
2241     my @lines = split /(?<=\n)/, $out;
2242     if (@lines > $options->{max_lines}) {
2243     splice @lines, $options->{max_lines};
2244     $out = join "", @lines;
2245     $stdout_cut = 1;
2246     }
2247     @$result{qw(stdout stderr)} = ($out, $err);
2248     $result->{stdout_truncated} = $stdout_cut || $timed_out ? JSON::XS::true : JSON::XS::false;
2249     $result->{stderr_truncated} = $stderr_cut || $timed_out ? JSON::XS::true : JSON::XS::false;
2250     $result->{truncated} = $stdout_cut || $stderr_cut || $timed_out ? JSON::XS::true : JSON::XS::false;
2251     1
2252     };
2253     return $req->error ($@) unless $ok;
2254     $req->reply (fs_result $result, exists $result->{filter_error});
2255     };
2256    
2257     return $finish->() unless exists $options->{grep};
2258    
2259     {
2260     my $ok = eval { $stdout = AnyEvent::MCP::utf8enc (fs_output_text $stdout, $stdout_cut); 1 };
2261     return $req->error ($@) unless $ok;
2262     }
2263    
2264     # regex execution stays in a timed worker, outside the broker's event loop.
2265     my @filter = ((fs_sandbox $workdir, $grants), $grep, "-aP", "--", $options->{grep});
2266     fs_run $req, \@filter, $stdout, 5, 1048576, 0, sub {
2267     my ($success, $filtered) = @_;
2268     if (!$success || $filtered->{exit} > 1 || $filtered->{signal} || $filtered->{timed_out}) {
2269     my $error = $success ? $filtered->{stderr} : $filtered;
2270     $error = eval { fs_output_text (substr ($error, 0, 4096), 1) } || "no diagnostic";
2271     $result->{filter_error} = "stdout filter failed: $error";
2272     $stdout = "";
2273     $stdout_cut = 1;
2274     } else {
2275     $stdout = $filtered->{stdout};
2276     $stdout_cut ||= $filtered->{truncated};
2277     }
2278     $finish->();
2279     };
2280     };
2281     }
2282    
2283 root 1.33 # wrap raw text as an MCP text result.
2284 root 1.1 sub fs_text_result {
2285     my ($text, $failed) = @_;
2286    
2287     my $response = {
2288     content => [{
2289     type => "text",
2290     text => $text,
2291     }],
2292     };
2293    
2294     $response->{isError} = JSON::XS::true if $failed;
2295    
2296     $response
2297     }
2298    
2299 root 1.33 # wrap a Perl value as an MCP text result.
2300 root 1.1 sub fs_result {
2301     my ($result, $failed) = @_;
2302 root 1.33
2303     my $response = fs_text_result (JSON::XS->new->encode ($result), $failed);
2304 root 1.1 $response->{structuredContent} = $result;
2305 root 1.33
2306 root 1.1 $response
2307     }
2308    
2309     mcp_tool workdirs =>
2310 root 1.88 "List workdirs, VCS markers (remote snapshot at startup), available VCS clients, sandbox grants, trusted mounts, runtime paths.",
2311 root 1.1 {
2312     type => "object",
2313     properties => {},
2314     additionalProperties => JSON::XS::false,
2315     },
2316     sub {
2317     my ($req, $args) = @_;
2318    
2319 root 1.60 AE::log debug => "fs workdirs";
2320 root 1.1
2321 root 1.88 # report metadata markers, not an inferred repository for the tree.
2322 root 1.47 my %marker = (cvs => "CVS", git => ".git", hg => ".hg", svn => ".svn");
2323     my %workdir_vcs = map {
2324     my $i = $_;
2325 root 1.88 ($workdir_identity[$i] => $remote ? $remote_vcs_marker[$i] : [grep {
2326 root 1.47 lstat "$workdir[$i]/$marker{$_}" && (-d _ || -l _ || ($_ eq "git" && -f _))
2327     } sort keys %marker])
2328     } 0 .. $#workdir;
2329    
2330 root 1.1 $req->reply (fs_result +{
2331 root 1.88 ($remote ? (host => $host, rsh => $rsh) : ()),
2332 root 1.51 workdirs => { map { $workdir_ids->{ids}[$_] => $workdir_identity[$_] } reverse 0 .. $#workdir },
2333     resolved_workdirs => { map { $workdir_ids->{ids}[$_] => $workdir[$_] } 0 .. $#workdir },
2334 root 1.47 workdir_vcs => \%workdir_vcs,
2335 root 1.45 vcs_backends => [sort grep defined $vcs_program{$_}, keys %vcs_program],
2336 root 1.33 umask => (sprintf "%04o", $umask),
2337     runtime_paths => \@runtime_path,
2338     mounts => \@mount,
2339     sandbox_grants => \%sandbox_grant,
2340 root 1.10 exec_default_sandbox_grants => \@exec_default_sandbox_grants,
2341     unavailable_sandbox_grants => ["host-proc"],
2342 root 1.1 });
2343     }
2344     ;
2345    
2346 root 1.33 # escape a pathname for compact line-oriented text output.
2347 root 1.1 sub escape_path {
2348     my ($text) = @_;
2349    
2350     $text =~ s/\\/\\\\/g;
2351     $text =~ s/\n/\\n/g;
2352     $text
2353     }
2354    
2355     sub format_iec_number {
2356     my ($number) = @_;
2357    
2358 root 1.33 for my $suffix ("", qw(k M G T P)) {
2359 root 1.1 return "$number$suffix" if $number < 1024 or $suffix eq "P";
2360     $number = int (($number + 512) / 1024);
2361     }
2362     }
2363    
2364     sub fs_find_relative {
2365     my ($base, $path) = @_;
2366    
2367     return "" if $path eq $base;
2368 root 1.77 substr $path, (length $base) + ($base eq "/" ? 0 : 1)
2369 root 1.1 }
2370    
2371     sub format_path_listing {
2372     my ($base, $record, $truncated) = @_;
2373 root 1.33
2374 root 1.1 my (@line, $directory);
2375    
2376     for my $record (sort { $a->[2] cmp $b->[2] } @$record) {
2377     my ($type, $size, $full_path) = @$record;
2378     my $relative = fs_find_relative $base, $full_path;
2379    
2380     if ($type eq "d") {
2381     push @line, "/" . (escape_path $relative);
2382     $directory = $relative;
2383     next;
2384     }
2385    
2386     my ($dir, $name);
2387    
2388     if (!length $relative) {
2389 root 1.71 ($name) = $full_path =~ m{([^/]*)\z};
2390 root 1.1 } else {
2391     ($dir, $name) = $relative =~ m{\A(?:(.*)/)?([^/]*)\z};
2392     $dir //= "";
2393    
2394     if (!defined $directory or $directory ne $dir) {
2395     push @line, "/" . (escape_path $dir);
2396     $directory = $dir;
2397     }
2398     }
2399    
2400     $name = escape_path $name;
2401     push @line, $type eq "f"
2402     ? (join "\t", $type, (format_iec_number $size), $name)
2403     : (join "\t", $type, $name);
2404     }
2405    
2406     push @line, "!truncated" if $truncated;
2407    
2408 root 1.33 join "", map "$_\n", @line
2409 root 1.1 }
2410    
2411     sub fs_list_find {
2412     my ($req, $args, $recurse) = @_;
2413 root 1.33
2414 root 1.1 my $tool = $recurse ? "find" : "list";
2415    
2416 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2417 root 1.84 my $path = fs_path $workdir, length $args->{path} ? $args->{path} : ".";
2418 root 1.1 my $limit = $args->{limit} // 200;
2419     $limit <= 1000
2420     or die "invalid limit\n";
2421    
2422 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
2423     $args->{reason} // "$tool $path");
2424 root 1.1
2425     if ($recurse) {
2426 root 1.60 AE::log debug => join " ", "fs", $tool, $path, (substr $args->{name}, 0, 40);
2427 root 1.1 } else {
2428 root 1.60 AE::log debug => "fs $tool $path";
2429 root 1.1 }
2430    
2431 root 1.6 my $find_path = AnyEvent::MCP::utf8enc $path;
2432 root 1.1 my @command = ((fs_sandbox $workdir, $granted), $find, $find_path);
2433    
2434     if ($recurse) {
2435 root 1.6 my $needle = AnyEvent::MCP::utf8enc $args->{name};
2436 root 1.1 $needle =~ s/([\\*?\[])/\\$1/g;
2437     push @command, "!", "-type", "d", "-name", "*$needle*";
2438     } else {
2439     push @command, "-maxdepth", "1";
2440     }
2441    
2442     push @command, "-printf", q{%y\0%s\0%p\0};
2443    
2444 root 1.35 my (@record, $seen, $truncated, $failure);
2445 root 1.1 my $stderr = "";
2446    
2447 root 1.35 proc_run $req, \@command, "", 30,
2448     sub {
2449     my ($handle, $eof) = @_;
2450    
2451     while (!$failure and !$truncated
2452     and $handle->{rbuf} =~ s/\A([^\0]*)\0([^\0]*)\0([^\0]*)\0//s) {
2453     my ($type, $size, $raw_path) = ($1, $2, $3);
2454     my $full_path = eval { AnyEvent::MCP::utf8dec $raw_path };
2455    
2456     if ($@) {
2457     $failure = "$tool returned a non-UTF-8 pathname\n";
2458     last;
2459     }
2460 root 1.1
2461 root 1.35 if (++$seen > $limit) {
2462     $truncated = 1;
2463     last;
2464     }
2465 root 1.1
2466 root 1.35 push @record, [$type, $size, $full_path];
2467 root 1.1 }
2468    
2469 root 1.35 $failure ||= "incomplete $tool output\n"
2470     if $eof && length $handle->{rbuf} && !$truncated;
2471     $handle->{rbuf} = "" if $failure or $truncated;
2472     $failure || $truncated
2473     },
2474     sub {
2475     my ($handle) = @_;
2476     my $remaining = 65536 - length $stderr;
2477     $stderr .= substr $handle->{rbuf}, 0, $remaining if $remaining > 0;
2478     $handle->{rbuf} = "";
2479     0
2480     },
2481     sub {
2482     my ($status, $timed_out, $io_failure) = @_;
2483     return if $req->{finished};
2484 root 1.1
2485 root 1.35 my $result;
2486     my $ok = eval {
2487     die "$tool timed out\n" if $timed_out;
2488     die $failure || $io_failure if $failure || $io_failure;
2489 root 1.1
2490 root 1.35 if (($status >> 8 or $status & 127) and !$truncated) {
2491     my $error = eval { AnyEvent::MCP::utf8dec $stderr };
2492     $error = "non-UTF-8 error output" if $@;
2493     die "$tool failed (exit " . ($status >> 8) . ", signal " . ($status & 127) . "): $error\n";
2494     }
2495 root 1.33
2496 root 1.35 $result = fs_text_result (format_path_listing $path, \@record, $truncated);
2497     1
2498     };
2499 root 1.1
2500 root 1.35 if ($ok) {
2501     $req->reply ($result);
2502     } else {
2503     $req->error ($@);
2504 root 1.1 }
2505 root 1.35 };
2506 root 1.1 }
2507    
2508     $find && mcp_tool list =>
2509 root 1.74 "List immediate entries; no symlink following. "
2510     . "Text format: /relative-directory headers; f<TAB>approx-size<TAB>name for files; "
2511     . "TYPE<TAB>name for l/p/s/b/c/? nodes. "
2512     . "Size suffixes k/M/G/T/P use IEC powers (1024); "
2513     . "!truncated means limit exceeded. Names escape backslash/newline.",
2514 root 1.1 {
2515     type => "object",
2516     properties => {
2517     path => {
2518     type => "string",
2519 root 1.84 description => "Directory; omitted/empty = .",
2520 root 1.1 },
2521     limit => {
2522     type => "integer",
2523     minimum => 1,
2524     maximum => 1000,
2525     default => 200,
2526     },
2527 root 1.21 workdir => $workdir_schema,
2528 root 1.1 reason => {
2529     type => "string",
2530 root 1.74 description => "Why access needed.",
2531 root 1.1 },
2532     },
2533     additionalProperties => JSON::XS::false,
2534     },
2535     sub {
2536     fs_list_find $_[0], $_[1], 0
2537     }
2538     ;
2539    
2540     $find && mcp_tool find =>
2541 root 1.74 "Find non-directory entries recursively by literal basename substring; no symlink following. Output as list.",
2542 root 1.1 {
2543     type => "object",
2544     properties => {
2545     path => {
2546     type => "string",
2547 root 1.84 description => "Search root; omitted/empty = .",
2548 root 1.1 },
2549     name => {
2550     type => "string",
2551     minLength => 1,
2552 root 1.74 description => "Literal basename substring.",
2553 root 1.1 },
2554     limit => {
2555     type => "integer",
2556     minimum => 1,
2557     maximum => 1000,
2558     default => 200,
2559     },
2560 root 1.21 workdir => $workdir_schema,
2561 root 1.1 reason => {
2562     type => "string",
2563 root 1.74 description => "Why access needed.",
2564 root 1.1 },
2565     },
2566     required => ["name"],
2567     additionalProperties => JSON::XS::false,
2568     },
2569     sub {
2570     fs_list_find $_[0], $_[1], 1
2571     }
2572     ;
2573    
2574     my $file_read = q{#perl
2575     my ($path, $offset, $length) = @ARGV;
2576    
2577     open my $fh, "<:raw", $path or return 0, "open: $!";
2578     defined sysseek $fh, $offset, 0 or return 0, "seek: $!";
2579     my $n = sysread $fh, my $data, $length;
2580     defined $n or return 0, "read: $!";
2581    
2582     (1, $data)
2583     };
2584    
2585     $perl && mcp_tool file_read =>
2586 root 1.74 "Read up to 1 MiB at byte offset. Returns {data: BASE64}, even for text; {error: STRING} on filesystem failure. Prefer read-lines for source/text.",
2587 root 1.1 {
2588     type => "object",
2589     properties => {
2590     path => {
2591     type => "string",
2592     },
2593     offset => {
2594     type => "integer",
2595     minimum => 0,
2596     default => 0,
2597     },
2598     length => {
2599     type => "integer",
2600     minimum => 1,
2601     maximum => 1<<20,
2602     default => 1<<16,
2603     },
2604 root 1.21 workdir => $workdir_schema,
2605 root 1.1 reason => {
2606     type => "string",
2607 root 1.74 description => "Why access needed.",
2608 root 1.1 },
2609     },
2610     required => ["path"],
2611     additionalProperties => JSON::XS::false,
2612     },
2613     sub {
2614     my ($req, $args) = @_;
2615    
2616 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2617 root 1.1 my $offset = $args->{offset} // 0;
2618     my $length = $args->{length} // 65536;
2619     $length <= 1<<20
2620     or die "invalid read range\n";
2621     my $path = fs_path $workdir, $args->{path};
2622    
2623 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
2624     $args->{reason} // "read $path");
2625 root 1.1
2626 root 1.60 AE::log debug => join " ", "fs file-read", $path, $offset, $length;
2627 root 1.1
2628     perl_eval $req, $workdir, $granted, $file_read,
2629 root 1.39 [AnyEvent::MCP::utf8enc $path, $offset, $length], 30, sub {
2630 root 1.1 my ($success, $result) = @_;
2631 root 1.33
2632 root 1.1 return $req->error ($result)
2633     unless $success;
2634    
2635     @$result == 2
2636     or return $req->error ("invalid file-read response\n");
2637     my ($ok, $value) = @$result;
2638     if ($ok eq "1") {
2639     $req->reply (fs_result {
2640     data => MIME::Base64::encode_base64 ($value, ""),
2641     });
2642     } elsif ($ok eq "0") {
2643     $req->reply (fs_result {
2644 root 1.6 error => AnyEvent::MCP::utf8dec $value,
2645 root 1.1 });
2646     } else {
2647     $req->error ("invalid file-read status\n");
2648     }
2649     };
2650     }
2651     ;
2652    
2653     $sed && mcp_tool read_lines =>
2654 root 1.74 "Read byte-LF (0x0A) lines by positive line ranges. "
2655     . "encoding: strict utf-8 (default), latin1 (raw bytes as text), base64 (exact range bytes). "
2656     . "linenums=true prefixes absolute line number plus colon (before base64 encoding). "
2657     . "Per range: two MCP text items, [range=N,M] metadata (truncated=1 if cut), then lines without added newlines. Preserves order, overlaps, duplicates; max 1 MiB raw output.",
2658 root 1.1 {
2659     type => "object",
2660     properties => {
2661     path => {
2662     type => "string",
2663     },
2664     ranges => {
2665     type => "array",
2666 root 1.74 description => 'Inclusive line ranges as strings, e.g. ["1,60", "80"]. Comma, not dash.',
2667 root 1.1 items => {
2668     type => "string",
2669     pattern => q{^[1-9][0-9]*(?:,[1-9][0-9]*)?$},
2670     },
2671     minItems => 1,
2672     maxItems => 20,
2673     },
2674 root 1.26 encoding => {
2675     type => "string",
2676 root 1.74 description => "Range format: strict utf-8 (default), latin1, base64. Lines split at byte LF (0x0A).",
2677 root 1.26 enum => ["utf-8", "latin1", "base64"],
2678     default => "utf-8",
2679     },
2680 root 1.58 linenums => {
2681     type => "boolean",
2682 root 1.74 description => "Prefix all lines, including blanks, with absolute number and colon.",
2683 root 1.58 default => JSON::XS::false,
2684     },
2685 root 1.21 workdir => $workdir_schema,
2686 root 1.1 reason => {
2687     type => "string",
2688 root 1.74 description => "Why access needed.",
2689 root 1.1 },
2690     },
2691     required => ["path", "ranges"],
2692     additionalProperties => JSON::XS::false,
2693     },
2694     sub {
2695     my ($req, $args) = @_;
2696 root 1.33
2697 root 1.26 my $encoding = $args->{encoding} // "utf-8";
2698     $encoding =~ /\A(?:utf-8|latin1|base64)\z/ or die "invalid read-lines encoding\n";
2699 root 1.1
2700 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2701 root 1.1 my $path = fs_path $workdir, $args->{path};
2702     my @range = map {
2703     my ($start, $end) = /\A([1-9]\d*)(?:,([1-9]\d*))?\z/
2704     or die "invalid line range\n";
2705     $end //= $start;
2706     $start <= $end
2707     or die "invalid line range\n";
2708     [$_, $start, $end]
2709     } @{$args->{ranges}};
2710    
2711 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
2712     $args->{reason} // "read lines $path");
2713 root 1.1
2714 root 1.60 AE::log debug => join " ", "fs read-lines", $path, map $_->[0], @range;
2715 root 1.1
2716     my (@result, $truncated);
2717     my $remaining = 1 << 20;
2718     my $next;
2719     $next = sub {
2720     unless (@range) {
2721     undef $next;
2722 root 1.27 my @content;
2723 root 1.28 for my $i (0 .. $#result) {
2724     my ($range, $body) = @{$result[$i]};
2725     my $header = "[range=$range";
2726     $header .= " truncated=1" if $truncated && $i == $#result;
2727     push @content,
2728     { type => "text", text => "$header]" },
2729     { type => "text", text => $body };
2730 root 1.27 }
2731 root 1.28 push @content, { type => "text", text => "[truncated=1]" }
2732     if $truncated && !@content;
2733 root 1.27 return $req->reply ({ content => \@content });
2734 root 1.1 }
2735    
2736     my ($range, $start, $end) = @{shift @range};
2737     my @command = (
2738     (fs_sandbox $workdir, $granted),
2739     $sed, "-n",
2740     "-e", ($start == $end ? "${start}p" : "${start},${end}p"),
2741 root 1.27 "-e", "${end}Q", # Preserve unterminated final line (GNU sed).
2742 root 1.6 AnyEvent::MCP::utf8enc $path,
2743 root 1.1 );
2744    
2745     fs_run $req, \@command, "", 30, $remaining, 0, sub {
2746     my ($success, $value) = @_;
2747 root 1.33
2748 root 1.1 return $req->error ($value)
2749     unless $success;
2750    
2751     my $body;
2752     my $ok = eval {
2753     die "read-lines timed out\n" if $value->{timed_out};
2754     if ($value->{exit} or $value->{signal}) {
2755 root 1.6 my $stderr = AnyEvent::MCP::utf8dec $value->{stderr};
2756 root 1.1 die "read-lines failed (exit $value->{exit}, signal $value->{signal}): $stderr\n";
2757     }
2758 root 1.58 if ($args->{linenums} && length $value->{stdout}) {
2759     my $line = $start;
2760     $value->{stdout} =~ s/^/$line++ . ":"/gme;
2761     if (length $value->{stdout} > $remaining) {
2762     substr ($value->{stdout}, $remaining) = "";
2763     $value->{truncated} = 1;
2764     }
2765     }
2766 root 1.26 $body = $encoding eq "utf-8" ? AnyEvent::MCP::utf8dec $value->{stdout}
2767     : $encoding eq "base64" ? MIME::Base64::encode_base64 ($value->{stdout}, "")
2768     : $value->{stdout}; # Unflagged byte strings are Latin-1 to Perl/JSON::XS.
2769 root 1.1 1
2770     };
2771     return $req->error ($@)
2772     unless $ok;
2773    
2774     push @result, [$range, $body];
2775     $remaining -= length $value->{stdout};
2776 root 1.33 if ($value->{truncated} or ($remaining <= 0 and @range)) {
2777 root 1.1 $truncated = 1;
2778     @range = ();
2779     }
2780     $next->();
2781     };
2782     };
2783    
2784     $next->();
2785     }
2786     ;
2787    
2788     my $file_write = q{#perl
2789     my ($path, $offset, $data) = @ARGV;
2790    
2791     use Fcntl qw(O_WRONLY O_CREAT);
2792     sysopen my $fh, $path, O_WRONLY | O_CREAT, 0666 or return 0, "open: $!";
2793    
2794     binmode $fh;
2795     defined sysseek $fh, $offset, 0 or return 0, "seek: $!";
2796     my $n = syswrite $fh, $data;
2797     defined $n or return 0, "write: $!";
2798    
2799     (1, "$n")
2800     };
2801    
2802     $perl && mcp_tool file_write =>
2803 root 1.74 "Write up to 1 MiB Base64-decoded bytes at offset; no truncation. Returns {bytes: INTEGER} or {error: STRING} on filesystem failure.",
2804 root 1.1 {
2805     type => "object",
2806     properties => {
2807     path => {
2808     type => "string",
2809     },
2810     offset => {
2811     type => "integer",
2812     minimum => 0,
2813     default => 0,
2814     },
2815     data => {
2816     type => "string",
2817     },
2818 root 1.21 workdir => $workdir_schema,
2819 root 1.1 reason => {
2820     type => "string",
2821 root 1.74 description => "Why access needed.",
2822 root 1.1 },
2823     },
2824     required => ["path", "data"],
2825     additionalProperties => JSON::XS::false,
2826     },
2827     sub {
2828     my ($req, $args) = @_;
2829    
2830 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2831 root 1.1 my $offset = $args->{offset} // 0;
2832     my $data = MIME::Base64::decode_base64 $args->{data};
2833     MIME::Base64::encode_base64 ($data, "") eq $args->{data}
2834     or die "invalid base64 data\n";
2835     length $data <= 1048576
2836     or die "write data exceeds 1 MiB\n";
2837     my $path = fs_path $workdir, $args->{path};
2838    
2839 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "write",
2840     $args->{reason} // "write $path");
2841 root 1.1
2842 root 1.60 AE::log debug => join " ", "fs file-write", $path, $offset, length ($data);
2843 root 1.1
2844     perl_eval $req, $workdir, $granted, $file_write,
2845 root 1.39 [AnyEvent::MCP::utf8enc $path, $offset, $data], 30, sub {
2846 root 1.1 my ($success, $result) = @_;
2847 root 1.33
2848 root 1.1 return $req->error ($result)
2849     unless $success;
2850    
2851     @$result == 2
2852     or return $req->error ("invalid file-write response\n");
2853     my ($ok, $value) = @$result;
2854     if ($ok eq "1") {
2855     $value =~ /\A[0-9]+\z/
2856     or return $req->error ("invalid file-write byte count\n");
2857     $req->reply (fs_result {
2858     bytes => 0 + $value,
2859     });
2860     } elsif ($ok eq "0") {
2861     $req->reply (fs_result {
2862 root 1.6 error => AnyEvent::MCP::utf8dec $value,
2863 root 1.1 });
2864     } else {
2865     $req->error ("invalid file-write status\n");
2866     }
2867     };
2868     }
2869     ;
2870    
2871     my $path_op = q{#perl
2872     while (@ARGV) {
2873     my $op = shift;
2874    
2875     if ($op eq "unlink") {
2876     my $path = shift;
2877 root 1.33
2878 root 1.1 unlink $path
2879     or die "unlink $path: $!\n";
2880     } elsif ($op eq "rmdir") {
2881     my $path = shift;
2882 root 1.33
2883 root 1.1 rmdir $path
2884     or die "rmdir $path: $!\n";
2885     } elsif ($op eq "mkdir") {
2886     my ($path, $mode) = splice @ARGV, 0, 2;
2887     $mode = oct $mode;
2888     mkdir $path, $mode
2889     or die "mkdir $path: $!\n";
2890     chmod $mode, $path
2891     or die "chmod $path: $!\n";
2892 root 1.55 } elsif ($op eq "mkpath") {
2893     my ($path, $mode) = splice @ARGV, 0, 2;
2894     $mode = oct $mode;
2895     my $prefix = "";
2896    
2897     for (split m{/}, $path) {
2898     next unless length;
2899     $prefix .= "/$_";
2900     next if -d $prefix;
2901     mkdir $prefix, $mode
2902     or die "mkdir $prefix: $!\n";
2903     chmod $mode, $prefix
2904     or die "chmod $prefix: $!\n";
2905     }
2906 root 1.1 } elsif ($op eq "symlink") {
2907     my ($target, $path) = splice @ARGV, 0, 2;
2908     symlink $target, $path
2909     or die "symlink $path: $!\n";
2910     } elsif ($op eq "truncate") {
2911     my ($path, $size) = splice @ARGV, 0, 2;
2912     truncate $path, $size
2913     or die "truncate $path: $!\n";
2914     } elsif ($op eq "chmod") {
2915     my ($path, $mode) = splice @ARGV, 0, 2;
2916     $mode = oct $mode;
2917     chmod $mode, $path
2918     or die "chmod $path: $!\n";
2919     } elsif ($op eq "rename") {
2920     my ($path, $to) = splice @ARGV, 0, 2;
2921     rename $path, $to
2922     or die "rename $path -> $to: $!\n";
2923     } else {
2924     die "invalid path operation '$op'\n";
2925     }
2926     }
2927    
2928     1
2929     };
2930    
2931     $perl && mcp_tool path_op =>
2932 root 1.74 "Mutate paths in fixed order: unlink, rmdir, mkpath, mkdir, symlink, truncate, chmod, rename. "
2933     . "Stop at first failure; earlier changes remain (no rollback). "
2934     . "mkpath creates missing components in worker; symlink target is link text, not sandbox authority.",
2935 root 1.1 {
2936     type => "object",
2937     properties => {
2938     path => {
2939     type => "string",
2940     },
2941     unlink => {
2942     type => "boolean",
2943     },
2944     rmdir => {
2945     type => "boolean",
2946     },
2947     mkdir => {
2948 root 1.74 description => "Create directory with final mode (ignores umask).",
2949 root 1.1 anyOf => [
2950     { type => "string", pattern => q{^[0-7]+$} },
2951     { type => "integer", minimum => 0 },
2952     ],
2953     },
2954     mkpath => {
2955 root 1.74 description => "Create missing directories with final mode (ignores umask).",
2956 root 1.1 anyOf => [
2957     { type => "string", pattern => q{^[0-7]+$} },
2958     { type => "integer", minimum => 0 },
2959     ],
2960     },
2961     symlink => {
2962     type => "string",
2963     minLength => 1,
2964 root 1.74 description => "Create symlink; value is link target.",
2965 root 1.1 },
2966     truncate => {
2967     type => "integer",
2968     minimum => 0,
2969     },
2970     chmod => {
2971     anyOf => [
2972     { type => "string", pattern => q{^[0-7]+$} },
2973     { type => "integer", minimum => 0 },
2974     ],
2975     },
2976     rename => {
2977     type => "string",
2978     minLength => 1,
2979 root 1.74 description => "Rename destination.",
2980 root 1.1 },
2981 root 1.21 workdir => $workdir_schema,
2982 root 1.1 reason => {
2983     type => "string",
2984 root 1.74 description => "Why access needed.",
2985 root 1.1 },
2986     },
2987     required => ["path"],
2988     additionalProperties => JSON::XS::false,
2989     },
2990     sub {
2991     my ($req, $args) = @_;
2992    
2993     ($args->{unlink} or $args->{rmdir}
2994     or grep { exists $args->{$_} } qw(mkdir mkpath symlink truncate chmod rename))
2995     or die "no path operation requested\n";
2996    
2997 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2998 root 1.1 my $path = fs_path $workdir, $args->{path};
2999     my $rename = exists $args->{rename}
3000     ? fs_path $workdir, $args->{rename}
3001     : undef;
3002 root 1.16 die "mkpath requires a path in the selected workdir\n"
3003     if exists $args->{mkpath} && !fs_inside ($path, $workdir);
3004 root 1.1
3005 root 1.46 my $json = JSON::XS->new;
3006 root 1.1 my @log = map {
3007     ($_ eq "unlink" or $_ eq "rmdir")
3008     ? ($args->{$_} ? $_ : ())
3009 root 1.46 : (exists $args->{$_}
3010     ? "$_: " . $json->encode ($_ eq "rename" ? $rename : $args->{$_}) : ())
3011 root 1.1 } qw(unlink rmdir mkpath mkdir symlink truncate chmod rename);
3012 root 1.46 my $reason = join "\n",
3013     (defined $args->{reason} ? $args->{reason} : ()),
3014     "Path: " . $json->encode ($path), "Operations (in order):", map " $_", @log;
3015    
3016     my $granted = fs_require_paths $req, $workdir, $identity,
3017 root 1.55 [$path, (defined $rename ? $rename : ())], "write", $reason;
3018 root 1.1
3019 root 1.60 AE::log debug => join " ", "fs path-op", $path, @log;
3020 root 1.1
3021     my @operation;
3022 root 1.6 my $raw_path = AnyEvent::MCP::utf8enc $path;
3023 root 1.1
3024     push @operation, unlink => $raw_path
3025     if $args->{unlink};
3026     push @operation, rmdir => $raw_path
3027     if $args->{rmdir};
3028 root 1.55 push @operation, mkpath => $raw_path, $args->{mkpath}
3029     if exists $args->{mkpath};
3030 root 1.1 push @operation, mkdir => $raw_path, $args->{mkdir}
3031     if exists $args->{mkdir};
3032 root 1.6 push @operation, symlink => (AnyEvent::MCP::utf8enc $args->{symlink}), $raw_path
3033 root 1.1 if exists $args->{symlink};
3034     push @operation, truncate => $raw_path, $args->{truncate}
3035     if exists $args->{truncate};
3036     push @operation, chmod => $raw_path, $args->{chmod}
3037     if exists $args->{chmod};
3038 root 1.6 push @operation, rename => $raw_path, (AnyEvent::MCP::utf8enc $rename)
3039 root 1.1 if defined $rename;
3040    
3041     perl_eval $req, $workdir, $granted, $path_op,
3042 root 1.39 \@operation, 30, sub {
3043 root 1.1 my ($success, $result) = @_;
3044 root 1.33
3045 root 1.1 return $req->error ($result)
3046     unless $success;
3047    
3048     @$result == 1 and $result->[0] eq "1"
3049     or return $req->error ("invalid path-op response\n");
3050     $req->reply (fs_text_result "ok\n");
3051     };
3052     }
3053     ;
3054    
3055     my $file_patch = q{#perl
3056     use Fcntl qw(O_RDWR O_CREAT O_EXCL);
3057     use Errno qw(EEXIST ENAMETOOLONG);
3058    
3059     my ($patch, $path, $diff, @max_offset) = @ARGV;
3060    
3061     my $create_tempfile = sub {
3062     my ($name) = @_;
3063     utf8::decode $name
3064     or return undef, "invalid UTF-8 tempfile name";
3065    
3066     my $slash = rindex $name, "/";
3067     my $dir = $slash < 0 ? "" : substr $name, 0, $slash + 1;
3068     my $file = substr $name, $slash + 1;
3069    
3070     for (;;) {
3071     my $pathname = "$dir$file";
3072     utf8::encode $pathname;
3073    
3074     if (sysopen my $fh, $pathname, O_RDWR | O_CREAT | O_EXCL, 0600) {
3075     binmode $fh;
3076     return $fh, $pathname
3077     }
3078    
3079     if ($! == EEXIST) {
3080     $file .= "~";
3081     } elsif ($! == ENAMETOOLONG) {
3082     substr $file, (length $file) * .75, 2, "~";
3083     } else {
3084     return undef, "open tempfile: $!"
3085     }
3086     }
3087     };
3088    
3089 root 1.85 # deliberately self-contained: patch validation must not load host modules.
3090 root 1.8 my $check_patch_output = sub {
3091     my ($status, $output, @max_offset) = @_;
3092     my (@hunks, @unparsed);
3093    
3094     for (split /\n/, $output) {
3095     if (/^Hunk #(\d+) (succeeded|FAILED|ignored) at (-?\d+)(?: with fuzz (\d+))?(?: \(offset (-?\d+) lines?\))?\.\z/) {
3096     push @hunks, [0 + $1, $2 eq "succeeded", 0 + $4, 0 + $5];
3097     } elsif (!length
3098     || /^Hmm\.\.\. +Looks like a unified diff to me\.\.\.\z/
3099     || /^The text leading up to this was:\z/
3100     || /^-+\z/ || /^\|/
3101     || /^patching file / || /^checking file /
3102     || /^Using Plan [AB]\.\.\.\z/ || /^done\z/) {
3103     # GNU patch --verbose framing.
3104     } else {
3105     push @unparsed, $_;
3106     }
3107     }
3108    
3109     $status == 0
3110     or die "patch failed (wait status $status):\n$output";
3111     @unparsed == 0
3112     or die "patch: unrecognized output:\n" . (join "\n", @unparsed) . "\n";
3113     @hunks == @max_offset && @hunks
3114     or die "patch: expected " . @max_offset . " hunks, got " . @hunks . "\n";
3115    
3116     for my $i (0 .. $#hunks) {
3117     my ($number, $success, $fuzz, $offset) = @{$hunks[$i]};
3118    
3119     $number == $i + 1
3120     or die "patch: unexpected hunk number $number\n";
3121     $success
3122     or die "patch: hunk $number failed\n";
3123     !$fuzz
3124     or die "patch: hunk $number used fuzz $fuzz\n";
3125     abs ($offset) <= $max_offset[$i]
3126     or die "patch: hunk $number offset $offset exceeds $max_offset[$i]\n";
3127     }
3128     };
3129    
3130 root 1.1 my $run_patch = sub {
3131     my ($newname, $path, $diff) = @_;
3132    
3133     my ($errfh, $errname) = $create_tempfile->("/tmp/mcp-fs~~patch~~");
3134     $errfh
3135     or return undef, $errname;
3136    
3137     local $ENV{LC_ALL} = "C";
3138     open my $write, "|-", "sh", "-c",
3139     q{exec "$4" --unified --verbose --binary --batch --forward --get=0 --fuzz=0 --reject-file=- --no-backup-if-mismatch -o "$2" "$1" >"$3" 2>&1},
3140     "--", $path, $newname, $errname, $patch
3141     or return undef, "open patch pipe: $!";
3142    
3143     my $write_error;
3144     {
3145     print $write $diff
3146     or $write_error = "write patch: $!";
3147     close $write;
3148     }
3149    
3150     my $status = $?;
3151    
3152     unlink $errname
3153     or return undef, "unlink patch stderr: $!";
3154     defined sysseek $errfh, 0, 0
3155     or return undef, "seek patch stderr: $!";
3156     my $output = do { local $/; <$errfh> };
3157     $output = "" unless defined $output;
3158     $output =~ s/\s+\z//;
3159    
3160     return undef, $write_error
3161     if $write_error and !$status;
3162    
3163     $status, $output
3164     };
3165    
3166     my $replace_file = sub {
3167     my ($path, $before, $newfh, $newname) = @_;
3168    
3169     chown $before->[4], $before->[5], $newfh;
3170     chmod $before->[2], $newfh;
3171    
3172     my @after = lstat $path;
3173     @after
3174     or return undef, "lstat: $!";
3175 root 1.77 (join "\0", @$before[0, 1, 2, 3, 4, 5, 7, 9, 10])
3176     eq (join "\0", @after[0, 1, 2, 3, 4, 5, 7, 9, 10])
3177 root 1.1 or return undef, "file changed while applying patch";
3178     my @new = stat $newfh;
3179     $after[3] == 1
3180     or return undef, "refusing to replace multiply-linked file";
3181 root 1.77 (join "\0", @after[2, 4, 5]) eq (join "\0", @new[2, 4, 5])
3182 root 1.1 or return undef, "cannot preserve file metadata";
3183    
3184     rename $newname, $path
3185     or return undef, "rename: $!";
3186    
3187     $new[7], undef
3188     };
3189    
3190     my @before = lstat $path;
3191     @before
3192     or return 0, "lstat: $!";
3193     -f _
3194     or return 0, "not a regular file";
3195    
3196     my ($newfh, $newname) = $create_tempfile->("$path~~new~~");
3197     $newfh
3198     or return 0, $newname;
3199    
3200     my ($status, $output) = $run_patch->($newname, $path, $diff);
3201     unless (defined $status) {
3202     unlink $newname;
3203     return 0, $output
3204     }
3205    
3206 root 1.8 unless (eval { $check_patch_output->($status, $output, @max_offset); 1 }) {
3207 root 1.1 my $error = $@;
3208     unlink $newname;
3209     return 0, $error
3210     }
3211    
3212     my ($bytes, $error) = $replace_file->($path, \@before, $newfh, $newname);
3213     unlink $newname unless defined $bytes;
3214     defined $bytes
3215     ? (1, "$bytes")
3216     : (0, $error)
3217     };
3218    
3219     $perl && $patch && mcp_tool file_patch =>
3220 root 1.74 "Patch one regular file via unified diff. Recompute hunk counts; no fuzz. "
3221     . "Whole-hunk relocation bounded per hunk by distinct old-side lines. "
3222     . "Pure insertion only at file start; elsewhere include old-side context. "
3223     . "diff: Unicode text (encoded UTF-8) "
3224     . "or Base64 raw bytes; autodetect by @ hunk markers ("
3225     . "Base64 cannot contain @).",
3226 root 1.1 {
3227     type => "object",
3228     properties => {
3229     path => { type => "string" },
3230     diff => { type => "string", minLength => 1 },
3231 root 1.21 workdir => $workdir_schema,
3232 root 1.1 reason => {
3233     type => "string",
3234 root 1.74 description => "Why access needed.",
3235 root 1.1 },
3236     },
3237     required => ["path", "diff"],
3238     additionalProperties => JSON::XS::false,
3239     },
3240     sub {
3241     my ($req, $args) = @_;
3242    
3243 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
3244 root 1.1 my $path = fs_path $workdir, $args->{path};
3245 root 1.10 my $diff = $args->{diff};
3246 root 1.48 my $detail = $diff;
3247 root 1.10
3248 root 1.77 if ((index $diff, "@") >= 0) {
3249 root 1.10 $diff = AnyEvent::MCP::utf8enc $diff;
3250     } else {
3251     my $bytes = MIME::Base64::decode_base64 $diff;
3252     MIME::Base64::encode_base64 ($bytes, "") eq $diff
3253     or die "invalid base64 diff\n";
3254     $diff = $bytes;
3255 root 1.48 $detail = eval { AnyEvent::MCP::utf8dec $bytes };
3256    
3257     unless (defined $detail) {
3258     $detail = $bytes;
3259     $detail =~ s/([^\x09\x0a\x20-\x5b\x5d-\x7e])/sprintf "\\x{%02X}", ord $1/ge;
3260     $detail = "Raw patch: " . length ($bytes) . " bytes (escaped byte view)\n\n" . $detail;
3261     }
3262 root 1.10 }
3263    
3264     my $max_offset;
3265     ($diff, $max_offset) = AnyEvent::MCP::normalize_udiff $diff;
3266 root 1.1 length $diff <= 4<<20
3267     or die "patch exceeds 4 MiB\n";
3268    
3269 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "write",
3270 root 1.48 $args->{reason} // "patch $path", { "View patch" => "File: $path\n\n$detail" });
3271 root 1.1
3272 root 1.60 AE::log debug => join " ", "fs file-patch", $path, scalar (@$max_offset), "hunks", length ($diff), "bytes";
3273 root 1.1
3274     perl_eval $req, $workdir, $granted, $file_patch,
3275 root 1.39 [$patch, AnyEvent::MCP::utf8enc $path, $diff, @$max_offset], 30, sub {
3276 root 1.1 my ($success, $result) = @_;
3277 root 1.33
3278 root 1.1 return $req->error ($result)
3279     unless $success;
3280    
3281     @$result == 2
3282     or return $req->error ("invalid file-patch response\n");
3283     my ($ok, $value) = @$result;
3284    
3285     if ($ok eq "1") {
3286     $value =~ /\A[0-9]+\z/
3287     or return $req->error ("invalid file-patch byte count\n");
3288     $req->reply (fs_result {
3289     bytes => 0 + $value,
3290     });
3291     } elsif ($ok eq "0") {
3292     $req->reply (fs_result {
3293 root 1.6 error => AnyEvent::MCP::utf8dec $value,
3294 root 1.1 });
3295     } else {
3296     $req->error ("invalid file-patch status\n");
3297     }
3298     };
3299     }
3300     ;
3301    
3302     $find && $grep && mcp_tool grep =>
3303 root 1.74 "Search regular files up to 1 MiB recursively; skip symlinks and .git. "
3304     . "Literal match default; mode=pcre uses GNU grep -P. "
3305     . "Stop at limit; report truncation and timeout separately.",
3306 root 1.1 {
3307     type => "object",
3308     properties => {
3309     path => {
3310     type => "string",
3311 root 1.84 description => "Search root; omitted/empty = .",
3312 root 1.1 },
3313     pattern => {
3314     type => "string",
3315     minLength => 1,
3316     },
3317     mode => {
3318     type => "string",
3319     enum => ["fixed", "pcre"],
3320     default => "fixed",
3321     },
3322     limit => {
3323     type => "integer",
3324     minimum => 1,
3325     maximum => 1000,
3326     default => 200,
3327 root 1.74 description => "Max matching lines.",
3328 root 1.1 },
3329 root 1.21 workdir => $workdir_schema,
3330 root 1.1 reason => {
3331     type => "string",
3332 root 1.74 description => "Why access needed.",
3333 root 1.1 },
3334     },
3335     required => ["pattern"],
3336     additionalProperties => JSON::XS::false,
3337     },
3338     sub {
3339     my ($req, $args) = @_;
3340    
3341 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
3342 root 1.84 my $path = fs_path $workdir, length $args->{path} ? $args->{path} : ".";
3343 root 1.1 my $mode = $args->{mode} // "fixed";
3344     $mode eq "fixed" or $mode eq "pcre"
3345     or die "invalid grep mode\n";
3346     my $limit = $args->{limit} // 200;
3347     $limit <= 1000
3348     or die "invalid limit\n";
3349 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
3350     $args->{reason} // "grep $path");
3351 root 1.1
3352 root 1.60 AE::log debug => join " ", "fs grep", $path, $mode, (substr $args->{pattern}, 0, 40);
3353 root 1.1
3354 root 1.6 my $find_path = AnyEvent::MCP::utf8enc $path;
3355     my $pattern = AnyEvent::MCP::utf8enc $args->{pattern};
3356 root 1.1 my @command = (
3357     (fs_sandbox $workdir, $granted),
3358     $find, $find_path,
3359     "-name", ".git", "-prune", "-o",
3360     "-type", "f",
3361     "-size", "-1048577c",
3362     "-exec",
3363 root 1.22 # GNU find treats any nonzero -exec ... + batch as failure.
3364     # grep uses 1 for no matches, which is a successful empty search;
3365     # preserve all other errors (including find traversal failures).
3366     "/bin/sh", "-c", q{"$@" || [ "$?" -eq 1 ]}, "sh",
3367 root 1.1 $grep, ($mode eq "pcre" ? "-P" : "-F"),
3368     "-I", "-H", "-n", "--color=never", "-e", $pattern,
3369     "{}", "+",
3370     );
3371    
3372     fs_run $req, \@command, "", 30, 1024 * 1024, $limit, sub {
3373     my ($success, $result) = @_;
3374 root 1.33
3375 root 1.1 return $req->error ($result)
3376     unless $success;
3377    
3378     my $response;
3379     my $ok = eval {
3380 root 1.6 my $stdout = AnyEvent::MCP::utf8dec $result->{stdout};
3381     my $stderr = AnyEvent::MCP::utf8dec $result->{stderr};
3382 root 1.1
3383     if (($result->{exit} or $result->{signal})
3384     and !$result->{truncated} and !$result->{timed_out}) {
3385     die "grep failed (exit $result->{exit}, signal $result->{signal}): $stderr\n";
3386     }
3387    
3388     $response = {
3389     stdout => $stdout,
3390     stderr => $stderr,
3391     timed_out => $result->{timed_out},
3392     truncated => $result->{truncated},
3393     };
3394     1
3395     };
3396     return $req->error ($@)
3397     unless $ok;
3398    
3399     $req->reply (fs_result $response);
3400     };
3401     }
3402     ;
3403    
3404     my $stat = q{#perl
3405     map {
3406     my @s = lstat $_;
3407    
3408     pack "(w/a*)*", (@s
3409     ? (1, (-l _ ? "link" : -d _ ? "directory" : -f _ ? "file" : "other"), @s)
3410     : (0, "lstat: $!"))
3411     } @ARGV
3412     };
3413    
3414     $perl && mcp_tool stat =>
3415 root 1.74 "lstat up to 100 paths; no symlink following.",
3416 root 1.1 {
3417     type => "object",
3418     properties => {
3419     paths => {
3420     type => "array",
3421     items => {
3422     type => "string",
3423     },
3424     minItems => 1,
3425     maxItems => 100,
3426     },
3427 root 1.21 workdir => $workdir_schema,
3428 root 1.1 reason => {
3429     type => "string",
3430 root 1.74 description => "Why access needed.",
3431 root 1.1 },
3432     },
3433     required => ["paths"],
3434     additionalProperties => JSON::XS::false,
3435     },
3436     sub {
3437     my ($req, $args) = @_;
3438    
3439 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
3440 root 1.1 ARRAY:: eq ref $args->{paths} and @{$args->{paths}} and @{$args->{paths}} <= 100
3441     or die "stat needs 1..100 paths\n";
3442     my @paths = map { fs_path $workdir, $_ } @{$args->{paths}};
3443 root 1.16 die "stat paths must be inside the selected workdir\n"
3444     if grep { !fs_inside ($_, $workdir) } @paths;
3445 root 1.1
3446 root 1.16 my $granted = fs_require_paths $req, $workdir, $identity, \@paths, "read", $args->{reason};
3447 root 1.1
3448     my @log = @paths;
3449     splice @log, 3, @log - 3, "..." if @log > 3;
3450 root 1.60 AE::log debug => join " ", "fs stat", @log;
3451 root 1.1
3452 root 1.6 perl_eval $req, $workdir, $granted, $stat, [map { AnyEvent::MCP::utf8enc $_ } @paths],
3453 root 1.39 30, sub {
3454 root 1.1 my ($success, $result) = @_;
3455 root 1.33
3456 root 1.1 return $req->error ($result)
3457     unless $success;
3458    
3459     @$result == @paths
3460     or return $req->error ("invalid stat response\n");
3461    
3462     my @result;
3463     for my $i (0 .. $#paths) {
3464     my ($ok, @s) = unpack "(w/a*)*", $result->[$i];
3465    
3466     if ($ok) {
3467     my $type = shift @s;
3468     push @result, +{
3469     path => $paths[$i],
3470     type => $type,
3471     size => 0 + $s[7],
3472     mode => (sprintf "%04o", $s[2] & 07777),
3473     uid => 0 + $s[4],
3474     gid => 0 + $s[5],
3475     mtime => 0 + $s[9],
3476     };
3477     } else {
3478     push @result, +{
3479     path => $paths[$i],
3480 root 1.6 error => AnyEvent::MCP::utf8dec $s[0],
3481 root 1.1 };
3482     }
3483     }
3484    
3485     $req->reply (fs_result { result => \@result });
3486     };
3487     }
3488     ;
3489    
3490     mcp_tool exec =>
3491 root 1.81 "Run argv " . ($bwrap ? "in bubblewrap. " : "UNSANDBOXED with full host-user privileges. ")
3492     . ($bwrap ? "Private /tmp by default (gone after exec); use for temp files/IPC. shared-tmp exposes HOST /tmp (sockets/services). Request ONLY for HOST /tmp, NOT ordinary temp/IPC. " : "")
3493     . "stdin_encoding: utf-8 (default), latin1 or base64; max 4 MiB decoded. "
3494 root 1.74 . "stdout/stderr UTF-8, captured to 1 MiB each; max_lines/max_bytes bound response. Optional grep: PCRE stdout filter after capture; stderr unfiltered. Reports capture truncation. "
3495     . "Explicit sandbox_grants replace default workdir-read. Exit/signal/timeout are normal results, not tool errors. No command-prefix safety classification. Timeout >300 s requires "
3496     . "long-running grant; max six hours.",
3497 root 1.1 {
3498     type => "object",
3499     properties => {
3500 root 1.45 %output_schema,
3501 root 1.21 workdir => $workdir_schema,
3502 root 1.1 argv => {
3503     type => "array",
3504     items => { type => "string" },
3505     minItems => 1,
3506     },
3507 root 1.10 sandbox_grants => {
3508 root 1.1 type => "array",
3509 root 1.74 description => "Providing sandbox_grants (even []) replaces this default entirely; otherwise workdir-read under bubblewrap. Without bubblewrap: full host authority. host-proc unavailable under bubblewrap.",
3510 root 1.1 items => {
3511     type => "string",
3512 root 1.10 enum => [sort keys %sandbox_grant],
3513 root 1.1 },
3514     },
3515     reason => {
3516     type => "string",
3517 root 1.74 description => "Why command needs authority.",
3518 root 1.1 },
3519     stdin => { type => "string" },
3520 root 1.69 stdin_encoding => {
3521     type => "string",
3522     default => "utf-8",
3523     enum => [qw(utf-8 latin1 base64)],
3524 root 1.74 description => "stdin bytes: UTF-8 (default), Latin-1 code points U+0000..U+00FF, or canonical Base64; max 4 MiB decoded.",
3525 root 1.69 },
3526 root 1.1 timeout => {
3527     type => "integer",
3528     minimum => 1,
3529 root 1.2 maximum => 21600,
3530 root 1.1 default => 30,
3531 root 1.74 description => "Timeout seconds; >300 also requires long-running grant.",
3532 root 1.1 },
3533     },
3534     required => [qw(argv reason)],
3535     additionalProperties => JSON::XS::false,
3536     },
3537     sub {
3538     my ($req, $args) = @_;
3539    
3540 root 1.45 my $output = fs_output_options $args;
3541    
3542 root 1.1 ARRAY:: eq ref $args->{argv} and @{$args->{argv}}
3543     or die "exec needs argv strings without NUL\n";
3544     for my $arg (@{$args->{argv}}) {
3545     defined $arg and !ref $arg and $arg !~ /\0/
3546     or die "exec needs argv strings without NUL\n";
3547     }
3548     length $args->{argv}[0] or die "exec needs an executable\n";
3549    
3550 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
3551 root 1.2 my $timeout = $args->{timeout} // 30;
3552     $timeout >= 1 && $timeout <= 21600 && $timeout == int $timeout
3553     or die "invalid timeout\n";
3554    
3555 root 1.10 my $sandbox_grants = [exists $args->{sandbox_grants} ? @{$args->{sandbox_grants}} : @exec_default_sandbox_grants];
3556     push @$sandbox_grants, "long-running" if $timeout > 300;
3557     $sandbox_grants = [fs_effective_grants @$sandbox_grants];
3558     my $requested = $permissions->closure ($sandbox_grants);
3559 root 1.1 die "host-proc sandbox authority is unresolved and unavailable in v1\n"
3560 root 1.10 if $bwrap && $requested->{"host-proc"};
3561 root 1.1
3562 root 1.31 my $stdin_text = $args->{stdin} // "";
3563 root 1.69 my $stdin_encoding = $args->{stdin_encoding} // "utf-8";
3564     $stdin_encoding =~ /\A(?:utf-8|latin1|base64)\z/
3565     or die "invalid stdin_encoding\n";
3566     my $input;
3567     if ($stdin_encoding eq "utf-8") {
3568     $input = AnyEvent::MCP::utf8enc $stdin_text;
3569     } elsif ($stdin_encoding eq "latin1") {
3570     $stdin_text !~ /[^\x00-\xff]/
3571     or die "latin1 stdin contains code points above U+00FF\n";
3572     $input = pack "C*", unpack "U*", $stdin_text;
3573     } else {
3574     $input = MIME::Base64::decode_base64 $stdin_text;
3575     MIME::Base64::encode_base64 ($input, "") eq $stdin_text
3576     or die "invalid base64 stdin\n";
3577     }
3578 root 1.1 length $input <= 4 * 1024 * 1024 or die "stdin exceeds 4 MiB\n";
3579 root 1.69 my $stdin_detail = $stdin_text;
3580     if (length $input && $stdin_encoding ne "utf-8") {
3581 root 1.77 my $preview = substr $input, 0, 4096;
3582 root 1.69 $preview =~ s/([^\x20-\x5b\x5d-\x7e])/sprintf "\\x{%02X}", ord $1/ge;
3583     $stdin_detail = "$stdin_encoding stdin: " . length ($input) . " bytes (escaped preview)\n\n" . $preview;
3584     $stdin_detail .= "\n... (preview limited to 4096 bytes)"
3585     if length $input > 4096;
3586     }
3587 root 1.1
3588     my $coder = JSON::XS->new->canonical;
3589     my $subject = $coder->encode ($args->{argv});
3590     my $executable = $coder->encode ($args->{argv}[0]);
3591 root 1.102 my $command_label = length $subject > 100 ? substr ($subject, 0, 97) . "..." : $subject;
3592     my $executable_label = $args->{argv}[0];
3593     $executable_label = substr ($executable_label, 0, 97) . "..."
3594     if length $executable_label > 100;
3595 root 1.18 my $granted = fs_require_permission ($req, $identity, {
3596 root 1.33 tool => "exec",
3597     action => "exec",
3598 root 1.78 dimensions => fs_permission_dimensions (fs_grant_workdir ($identity, $sandbox_grants),
3599     $subject, [
3600 root 1.102 { label => "Exact command: $command_label", match => $subject },
3601     { label => "This executable: $executable_label (any arguments)",
3602 root 1.78 match => "^\\[\Q$executable\E(?:,.*)?\\]\\z" },
3603     { label => "any command", match => "^" },
3604     ]),
3605 root 1.33 reason => $args->{reason},
3606 root 1.10 sandbox_grants => $sandbox_grants,
3607 root 1.69 (length $input ? (details => { "View stdin" => $stdin_detail }) : ()),
3608 root 1.1 });
3609    
3610 root 1.2 $timeout <= 300 || $permissions->closure ($granted)->{"long-running"}
3611     or die "permission reduced below long-running for exec\n";
3612    
3613 root 1.60 AE::log debug => "fs exec "
3614 root 1.1 . (substr $subject, 0, 200)
3615     . (length $subject > 200 ? "..." : "")
3616     . (length $input ? " stdin=" . length ($input) : "");
3617    
3618 root 1.98 fs_command $req, $workdir, $granted, [map { AnyEvent::MCP::utf8enc $_ } @{$args->{argv}}], $input, $timeout, $output;
3619 root 1.45 }
3620     ;
3621    
3622     # fixed operations accept filenames, never caller-supplied VCS options or shell code.
3623 root 1.80 for my $operation (qw(diff status log)) {
3624 root 1.45 mcp_tool "vcs-$operation" =>
3625 root 1.74 "Run $operation via Git, SVN, CVS or Mercurial in workdir. "
3626     . "Explicit backend; cwd within workdir (default .). "
3627     . "files relative to cwd; must stay in workdir; never options. "
3628 root 1.80 . "Dedicated permission plus workdir-read; CVS and SVN log also need ssh-agent/network. "
3629 root 1.74 . "Returns exit/signal, bounded stdout/stderr, truncation flags. "
3630     . "grep filters stdout via PCRE after 1 MiB capture; max_lines/max_bytes bound output. "
3631     . "Trusts VCS client/config; sandbox enforces approved filesystem/network grants.",
3632 root 1.45 {
3633     type => "object",
3634     properties => {
3635     workdir => $workdir_schema,
3636 root 1.84 cwd => { type => "string", default => ".",
3637     description => "cwd relative to workdir; empty = .; must stay inside. Mount boundary unchanged." },
3638 root 1.45 backend => { type => "string", enum => [qw(git svn cvs hg)] },
3639     files => { type => "array", items => { type => "string" }, default => [] },
3640     %output_schema,
3641     },
3642     required => ["backend"],
3643     additionalProperties => JSON::XS::false,
3644     },
3645     sub {
3646     my ($req, $args) = @_;
3647 root 1.33
3648 root 1.45 my $backend = $args->{backend};
3649     defined $backend && !ref $backend && $backend =~ /\A(?:git|svn|cvs|hg)\z/
3650     or die "backend must be git, svn, cvs or hg\n";
3651     my $program = $vcs_program{$backend}
3652     or die "$backend executable is unavailable\n";
3653     my $output = fs_output_options $args;
3654     my ($workdir, $identity) = fs_workdir $args->{workdir};
3655 root 1.84 my $cwd = fs_path $workdir, length $args->{cwd} ? $args->{cwd} : ".";
3656 root 1.46 fs_inside $cwd, $workdir or die "VCS cwd must stay within the selected workdir\n";
3657 root 1.45 my $files = $args->{files} // [];
3658     ref $files eq "ARRAY" or die "files must be an array of filenames\n";
3659 root 1.46 my @files;
3660     my @paths = ($cwd);
3661 root 1.45 for my $file (@$files) {
3662 root 1.46 my $path = fs_path $cwd, $file;
3663 root 1.45 fs_inside $path, $workdir or die "VCS files must stay within the selected workdir\n";
3664     push @paths, $path;
3665 root 1.46 my $relative = File::Spec::Unix->abs2rel ($path, $cwd);
3666 root 1.98 push @files, (AnyEvent::MCP::utf8enc "./$relative");
3667 root 1.45 }
3668 root 1.1
3669 root 1.45 my @argv = $backend eq "git"
3670     ? ($program, "--no-pager", "--no-optional-locks", "--literal-pathspecs", "-c", "core.fsmonitor=false",
3671     $operation, ($operation eq "diff" ? qw(--no-ext-diff --no-textconv --no-color --ignore-submodules)
3672 root 1.80 : $operation eq "status" ? qw(--short --untracked-files=normal --ignore-submodules)
3673     : qw(--no-decorate)),
3674 root 1.45 "--", @files)
3675     : $backend eq "svn"
3676     ? ($program, "--non-interactive", "--no-auth-cache", $operation,
3677     ($operation eq "diff" ? ("--internal-diff") : ()), "--", map "$_\@", @files)
3678     : $backend eq "cvs"
3679 root 1.80 ? ($program, "-f", $operation,
3680     ($operation eq "diff" ? ("-u") : $operation eq "log" ? ("-N") : ()),
3681     "--", @files)
3682 root 1.45 : ($program, "--noninteractive", "--config", "ui.paginate=never", $operation, "--", @files);
3683    
3684 root 1.80 my @need = fs_effective_grants ("workdir-read",
3685     ($backend eq "cvs" || $backend eq "svn" && $operation eq "log" ? ("ssh-agent") : ()));
3686 root 1.45 my $granted = fs_require_permission ($req, $identity, {
3687     tool => $req->{tool}, action => "vcs-$operation",
3688 root 1.78 dimensions => fs_permission_dimensions (fs_grant_workdir ($identity, \@need),
3689     $backend, [
3690     { label => "$backend working copies", match => $backend },
3691     { label => "all supported VCS clients", match => "^" },
3692     ]),
3693 root 1.45 sandbox_grants => \@need, paths => \@paths,
3694 root 1.46 reason => "$backend $operation in $cwd (workdir $identity)"
3695     . (@files ? ": " . join (", ", @files) : " (entire working copy)"),
3696 root 1.45 });
3697     my $have = $permissions->closure ($granted);
3698     $have->{$_} or die "permission reduced below $_ for $req->{tool}\n" for @need;
3699 root 1.1
3700 root 1.60 AE::log debug => join " ", "fs vcs-$operation", $backend, $cwd, @files;
3701 root 1.53
3702 root 1.46 fs_command $req, $workdir, $granted, \@argv, "", 30, $output, $cwd;
3703 root 1.1 };
3704 root 1.45 }
3705 root 1.1
3706     mcp_config
3707 root 1.25 name => $name,
3708     idle_exit => 300,
3709 root 1.73 server_name => "mcp-fs",
3710     server_version => 0,
3711 root 1.57 on_error => sub { AE::log info => "mcp-tool $_[1]{tool}: $_[2]" },
3712 root 1.25 on_start => sub {
3713 root 1.2 print STDERR "Permission rules: $permissions->{file}\n"
3714     if $_[0]->isa ("AnyEvent::MCP::Server::HTTP");
3715     },
3716 root 1.63 short_instructions => "Filesystem access: read/edit files, run sandboxed commands, inspect VCS in configured workdirs. Discover tools when working with files.",
3717 root 1.77 instructions =>
3718     "Start with workdirs: IDs/paths, mounts, sandbox_grants. "
3719     . "Select persistent integer ID (listing key, not array position) or configured/resolved path. "
3720     . "Inactive mappings expire after seven days; IDs may be reused; stale paths fail. "
3721     . "Paths relative to selected workdir. "
3722     . "workdir-read/write grant only selected workdir; all-workdirs-read/write grant every workdir. "
3723     . "all-workdirs-read + workdir-write: read all, write selected only. "
3724     . "Absolute paths in other workdirs need all-workdirs grants; other absolute paths need home/world grants or shared-tmp for host tmp. "
3725     . "Multi-path stat stays in selected workdir; rename across paths needs combined grants and may fail EXDEV at mount boundary. "
3726     . "Symlinks resolve inside granted mounts; overlapping workdir access unspecified. "
3727     . "Trusted mounts add baseline authority; world-read keeps writable exceptions, world-write ignores them. "
3728     . "exec requires useful reason and explicit sandbox_grants when needed. "
3729 root 1.80 . "Prefer vcs-diff/vcs-status/vcs-log for working copies: explicit backend, fixed argv, automatic grants. "
3730 root 1.77 . "exec/VCS: grep filters stdout via PCRE after capture; max_lines/max_bytes bound output; stderr unfiltered. "
3731     . "capture_truncated means possible unseen matches.\n";
3732 root 1.1
3733 root 1.72 mcp_start %$server_options, (stdio => $stdio);
3734 root 1.3