ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/bin/mcp-fs
Revision: 1.45
Committed: Wed Oct 14 10:09:36 2026 UTC (20 hours, 10 minutes ago) by root
Branch: MAIN
Changes since 1.44: +275 -23 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.1
12     =head1 DESCRIPTION
13    
14 root 1.44 C<mcp-fs> gives an MCP client tools to read, search and change files, and
15     run commands. An assistant can use these tools to work on your projects
16     without you writing a Perl server.
17    
18     Start it with one or more project directories, called workdirs. The client
19     connects to the server, lists its tools and makes requests. A request can
20     select one of these workdirs; with only one, the selection can be omitted.
21     Tools operate on files on disk. To work with unsaved Vim buffers, use
22     L<mcp-vim>.
23    
24     Before starting an operation, the server checks permission rules and, when
25     needed, asks you for approval. Operations normally run in a bubblewrap
26     sandbox with the approved access. Workdirs provide the default filesystem
27     scope; operations needing access elsewhere require the corresponding
28     sandbox grants.
29    
30     You can connect an MCP client directly over HTTP or stdin/stdout, or run
31     the server through C<mcp-multibroker>. See L<AnyEvent::MCP::Intro> for a
32     walkthrough from installation to a first filesystem request.
33 root 1.1
34 root 1.34 =head1 TOOLS
35    
36     C<workdirs> reports configured workdirs and available sandbox grants.
37     C<list> and C<find> enumerate directory entries; C<stat> returns lstat
38     metadata without following symlinks. C<file-read> reads byte ranges and
39     returns Base64 data; C<read-lines> reads LF-delimited lines with selectable
40     UTF-8, Latin-1 or Base64 representation. C<grep> searches files using a
41     literal pattern or PCRE. C<file-write> writes Base64 bytes at an offset
42     without implicit truncation; C<path-op> performs pathname mutations;
43     C<file-patch> applies a unified diff to a single file.
44    
45 root 1.37 C<exec> runs an argv array, not a shell command string. To use shell
46     syntax, explicitly invoke a shell in that array. C<stdin> is Unicode text
47     encoded as UTF-8, with a 4 MiB byte limit. Stdout and stderr are decoded as
48 root 1.45 UTF-8, with a raw capture limit of 1 MiB per stream; use the byte-range tools
49     for binary file contents. Its optional
50 root 1.34 C<sandbox_grants> B<replaces> the default C<workdir-read> grant rather than
51     adding to it. Explicitly passing an empty array therefore asks for no
52     grants. Command exit codes are normal tool results, not transport errors.
53 root 1.43 On timeout, output collection stops and C<truncated> is true; C<exit> and
54     C<signal> still describe the reaped child.
55 root 1.37 Required utilities unavailable at startup can cause tools to be omitted.
56     The command requested by C<exec> is resolved when the worker runs; it need
57     not be present when the server starts. Inspect C<tools/list> rather than
58     assuming every tool is present.
59 root 1.34
60 root 1.45 =head2 Command output
61    
62     C<exec>, C<vcs-diff> and C<vcs-status> share three presentation options:
63    
64     =over
65    
66     =item grep => $pattern
67    
68     Optional PCRE line filter for stdout. It runs in a separate timed GNU grep
69     worker after command output has been captured. It is not passed to the
70     original command or a shell. Stderr remains unfiltered.
71    
72     =item max_lines => $integer
73    
74     Maximum returned stdout lines after filtering, default 2000, maximum 100000.
75    
76     =item max_bytes => $integer
77    
78     Maximum returned UTF-8 bytes per stream, default 65536, maximum 1048576.
79     Clipping does not leave an incomplete UTF-8 character. Stderr has its own
80     budget, so a large stdout does not hide diagnostics.
81    
82     =back
83    
84     The command runs once and is drained to completion, subject to its timeout.
85     Raw capture is bounded to 1 MiB per stream, independently of the returned
86     output limits. C<capture_truncated> means bytes were omitted before filtering
87     or collection ended on timeout: matches in that omitted output are unknown.
88     C<stdout_truncated>, C<stderr_truncated> and the combined C<truncated> flag
89     also account for returned-output limits.
90    
91     Changing presentation options does not change the command's permission
92     subject. C<exit>, C<signal> and C<timed_out> always describe the original
93     command, including a diff exit code indicating differences. A filter error
94     makes the tool result an error and adds C<filter_error>, while retaining that
95     command status. The command may already have completed; do not rerun it
96     merely because its output filter failed.
97    
98     =head2 Version control
99    
100     C<vcs-diff> and C<vcs-status> take a required C<backend>: C<git>, C<svn>,
101     C<cvs> or C<hg>, a workdir, and an optional C<files> array. Backend selection
102     is explicit in this first interface. With no files, the command examines the
103     working copy at the selected workdir. Files must be inside that workdir;
104     they are passed as literal filenames, not options, revisions or shell code.
105     Git diff shows unstaged changes; SVN, CVS and Mercurial diff compare the
106     working copy with its base. No commit, update, fetch or push is performed.
107    
108     These tools have separate C<vcs-diff> and C<vcs-status> permission actions.
109     They request C<workdir-read> and C<home-read> for the selected working copy
110     and user configuration. CVS also requests C<network> automatically. The
111     other backends perform local operations without network authority.
112     A saved approval can cover the fixed operation regardless of filenames or
113     output filters, for that workdir and backend, or all supported backends.
114     Configured VCS clients and their configuration are trusted, including any
115     helpers they invoke; these tools do not sandbox the client's own semantics.
116     The ordinary sandbox grants still bound its host access. In unsandboxed
117     mode, the normal full-authority approval requirement applies.
118    
119     Clients are resolved at startup. C<executable.git>, C<executable.svn>,
120     C<executable.cvs> and C<executable.hg> in trusted program/instance settings
121     can override them. A missing client produces a tool error for that backend.
122    
123 root 1.44 =head1 REQUIREMENTS
124    
125     Bubblewrap is required by default, but can be disabled explicitly.
126     GNU find, grep, patch and sed and a compatible Perl are detected at
127     startup; tools requiring an unavailable utility are omitted. Use
128     C<tools/list> to see which tools are available.
129    
130     =head1 CONNECTIONS
131    
132     HTTP is the default and does not print its URL on startup. C<--print-url>
133     prints the credential-bearing URL; the same token works in a bearer header.
134     C<--stdio> or
135     C<PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL=stdio> selects stdin/stdout.
136     See L<AnyEvent::MCP/GLOBAL CONFIGURATION> for named HTTP settings.
137    
138 root 1.1 =head1 OPTIONS
139    
140     =over
141    
142     =item --name NAME
143    
144 root 1.11 Selects the service-instance configuration, credentials and rules.
145     Defaults to C<mcp-fs>.
146 root 1.1
147     =item --stdio
148    
149     Uses stdin/stdout instead of starting an HTTP listener.
150    
151 root 1.10 =item --exec-cmd SANDBOX-GRANT,...
152 root 1.7
153 root 1.10 Run a command with the named sandbox grants. No MCP server or permission rules
154     are used; the current directory is the workdir. With C<--sandbox=none> the
155     command runs directly with normal host-user privileges.
156 root 1.7 Defaults to C<$SHELL> or C</bin/sh> if no command is supplied.
157     The C<--> separator before the command is optional.
158    
159 root 1.11 =item --bind ADDRESS[:PORT]
160 root 1.1
161 root 1.11 Overrides the named HTTP bind setting. By default binds to IPv4 loopback
162     on a stable port derived from the service name in 16384..32767. Port zero
163     requests an ephemeral port.
164 root 1.1
165 root 1.12 =item --token TOKEN
166    
167     Add a URL-safe server token; may be repeated. CLI tokens replace the named
168     C<tokens> setting and the derived default. Every token is valid in URLs and
169     Authorization bearer headers; the first is used by C<--print-url>.
170    
171 root 1.11 =item --print-url
172 root 1.1
173 root 1.11 Prints the credential-bearing URL and exits without opening a listener.
174     No workdir is required. Port zero cannot be used.
175 root 1.1
176 root 1.11 Rules reside in C<$XDG_CONFIG_HOME/anyevent-mcp/rules-fs-NAME.ini>,
177     or the corresponding C<$HOME/.config> path. The rules filename shares the
178     service-instance name but has an C<fs> namespace. The rules path is printed
179 root 1.12 on stderr when starting HTTP; normal startup leaves stdout untouched.
180 root 1.1
181 root 1.4 =item --ask $method
182    
183     Selects permission prompting: C<helper> (default), C<mcp>, or C<auto>.
184 root 1.29 Overrides C<PERL_ANYEVENT_MCP_PERMISSION_ASK>, which can configure the
185     default for all servers using C<AnyEvent::MCP::Server::Permissions>.
186 root 1.4 The default keeps approval local. C<mcp> delegates to client form elicitation;
187     C<auto> uses elicitation when advertised and otherwise the local helper.
188    
189 root 1.10 =item --sandbox MODE
190    
191     Select C<bwrap> (default), C<auto>, or C<none>. C<bwrap> requires a usable
192     bubblewrap executable. C<auto> uses bubblewrap if found, otherwise runs
193     unsandboxed; it does not fall back if an existing executable fails.
194     C<none> deliberately runs workers with full host-user privileges.
195    
196     In unsandboxed mode every MCP worker (including read-only tools) requires
197     approval for the actual maximum execution authority: world-write, network,
198     shared-tmp and host-proc. Narrower approvals do not authorize execution;
199     the server does not pretend that workdir mounts restrict direct processes.
200     Startup warns prominently when there is no sandbox.
201    
202 root 1.1 =item --bwrap PATH
203    
204     Selects the bubblewrap executable. Defaults to C<bwrap> in C<PATH>.
205    
206 root 1.19 =back
207 root 1.1
208 root 1.19 =head1 TRUSTED CONFIGURATION
209 root 1.1
210 root 1.19 In C<~/.config/anyevent-mcp/global.ini> (or the corresponding
211     C<XDG_CONFIG_HOME> location), C<[program NAME]> supplies defaults based
212     on the executable basename, while C<[instance NAME]> overrides them for
213     C<--name NAME>. A symlinked executable therefore has its own program defaults.
214    
215     [program mcp-fs]
216     mount.opt = ro:/opt
217     mount.shared = rw:/srv/shared
218     executable.perl = /opt/bin/perl
219    
220     [instance myproject]
221     mount.opt =
222     mount.extra = ro:/srv/project-tools
223    
224     C<mount.SUFFIX> entries are applied in alphabetical key order after
225     the private sandbox mounts. The suffix is an arbitrary stable name;
226     an empty instance value suppresses an inherited entry. Paths must be
227     absolute and exist. A colon after C<ro:> or C<rw:> starts the literal
228     pathname; no separate quoting or escaping syntax is used.
229    
230     Configured mounts grant baseline access to every sandbox invocation,
231     independently of tool permission grants. With C<world-read>, only C<rw:>
232     exceptions are applied over the read-only host filesystem. With
233     C<world-write>, these configured mounts are ignored. Baseline execution
234     paths include C</usr>, C</bin>, system libraries and selected system
235     configuration, but not C</opt>. Shared host temporary directories require
236     C<shared-tmp>, a configured mount, or a C<world-*> grant.
237    
238     C<executable.find>, C<executable.grep>, C<executable.patch>,
239     C<executable.perl>, C<executable.sed>, and C<executable.bwrap>
240     override automatic executable discovery. Explicit choices must resolve to
241     executable files and are trusted instead of version-probed. C<--bwrap>
242     overrides C<executable.bwrap>.
243 root 1.1
244 root 1.19 Defaults also include alternatives, font configuration, local timezone,
245     TLS certificate stores, NSS configuration and DNS configuration where
246     present. A synthetic passwd entry is supplied without exposing the host
247     passwd or group databases.
248 root 1.1
249 root 1.10 =head1 SANDBOX GRANTS
250 root 1.8
251 root 1.16 C<workdir-read> and C<workdir-write> expose only the selected workdir;
252     write implies read. C<all-workdirs-read> and C<all-workdirs-write>
253     expose every configured workdir. C<all-workdirs-read> combined with
254     C<workdir-write> mounts all workdirs read-only except the selected one,
255     which is writable. C<all-workdirs-write> makes all writable.
256     Configured workdirs are mounted using resolved absolute paths, but their
257     permission-rule identities are verbatim command-line arguments.
258 root 1.21 Tools accept C<workdir> as a zero-based integer index, a numeric string
259     containing that index, or an exact configured or resolved workdir path
260     as returned by C<workdirs>. Non-numeric strings are matched only against
261     configured workdirs, never interpreted as arbitrary filesystem paths.
262     Unknown or ambiguous paths fail rather than selecting a different workdir
263     after configuration changes. With only one workdir, the field may be omitted.
264 root 1.16 Overlapping workdirs have unspecified mount order and access effects;
265     do not rely on overlapping workdir boundaries for isolation.
266     C<home-read> and C<home-write> similarly control HOME.
267 root 1.8 C<world-read> and C<world-write> expose the host filesystem, with write
268     implying read. C<network> and C<shared-tmp> independently enable networking
269     and the host's C</tmp> and C</var/tmp>. C<long-running> permits exec timeouts
270 root 1.10 over 300 seconds. C<host-proc> is unavailable with bubblewrap; direct execution has host-process access.
271 root 1.8
272     =head1 LIMITATIONS
273    
274 root 1.10 With bubblewrap, regardless of granted sandbox grants, the sandbox mounts procfs at
275 root 1.8 C</proc>. Its process entries are restricted by the PID namespace, but
276     kernel-wide and hardware information (including C</proc/irq> and
277     C</proc/bus>) remains visible. This is a limitation of bubblewrap's
278     C<--proc> option, which does not support C<subset=pid>.
279    
280 root 1.1 =head1 PERMISSIONS
281    
282     L<AnyEvent::MCP::Server::Permissions> applies ordered allow, deny and ask
283 root 1.10 decisions to server-defined action classes. C<inspect> groups stat/list/find;
284     C<read> groups file-read/read-lines/grep; C<write> groups file-write/file-patch;
285     C<manage> covers path-op, and C<exec> covers command execution. C<file-write>
286     may create files despite being in the write class; manage refers specifically
287     to pathname operations rather than an exclusive right to create files.
288 root 1.15 Filesystem operations have no path-based persistent subjects: their rules
289 root 1.16 match C<action>, C<sandbox-grants>, and C<workdir> when selected-workdir
290     authority is required. All-workdirs-only requests have no workdir identity.
291     Rules without C<workdir> can apply to any selected workdir if their sandbox
292     grants cover the request; dialog-generated selected-workdir rules are exact.
293     Requested paths are shown in the approval dialog for information only.
294     Exec additionally offers persistent
295 root 1.31 command/argv subjects. When stdin is provided, exec attaches display-only
296     Unicode text to the permission request: the YAD helper offers a separate
297     C<View stdin...> window, with size information and an explicit truncation
298     warning above 65536 characters. This does not change authorization:
299     persistent exec rules match argv, not stdin, and later runs may supply
300     different scripts. The Unicode JSON string is separately converted into
301     UTF-8 bytes before being sent to the worker.
302     Each tool checks permissions before starting its worker. Approval
303 root 1.4 defaults to the local helper selected by C<PERL_ANYEVENT_MCP_PERMISSION_HELPER>,
304 root 1.37 which tries YAD, then a terminal, then denies once. Client elicitation is
305     selected with C<--ask mcp>, C<--ask auto>, or the corresponding
306     C<PERL_ANYEVENT_MCP_PERMISSION_ASK> value.
307 root 1.1
308     You can edit the rule file at any time. It is reread on permission checks,
309     so changes apply without restarting the server. Keep it owned by your user
310     and private (mode 0600). See L<AnyEvent::MCP::Server::Permissions> for the
311     rule syntax and ordering.
312    
313     Permission retries retain earlier one-time approvals within the same call.
314 root 1.15 For one worker accessing several paths, the required sandbox-grant set combines
315     the authority needed for all paths and is approved once. An insufficient reduction fails before
316 root 1.1 the worker starts.
317 root 1.16 Multi-path C<stat> requires all paths inside its selected workdir.
318 root 1.17 C<path-op> can attempt cross-workdir renames after approving the sandbox
319     grants required for both paths. The sandboxed C<rename(2)> may fail with
320     C<EXDEV> across mounts; no copy/delete fallback is attempted.
321     C<mkpath> requires a path inside the selected workdir.
322 root 1.37 C<path-op> performs requested operations in this fixed order:
323     C<unlink>, C<rmdir>, C<mkpath>, C<mkdir>, C<symlink>, C<truncate>,
324     C<chmod>, C<rename>. C<mkpath> includes each missing component in the
325     combined permission check before starting the worker.
326 root 1.22 The first failure stops subsequent operations; successful earlier mutations
327     remain in place. There is no rollback or all-or-nothing transaction.
328 root 1.1
329 root 1.2 Exec defaults to a 30-second timeout and permits up to 300 seconds without
330     additional timeout authority. A larger explicit C<timeout>, up to 21600
331     seconds (six hours), also requires C<long-running>. This capability is added
332     to the requested permissions automatically. Cancellation still terminates
333     the worker, regardless of its allowed timeout.
334    
335 root 1.10 The C<host-proc> grant is unavailable with bubblewrap. Runtime mounts
336     provide sandbox machinery. Symlinks resolve within granted mounts.
337     Without bubblewrap, workers operate in the host mount and process namespaces.
338 root 1.1
339     =head1 PATCHES
340    
341     C<file-patch> uses L<AnyEvent::MCP/PATCH UTILITIES> for normalization and
342     validation. GNU patch runs inside the granted sandbox with fuzz zero.
343     Successful output replaces the original by rename after checking its
344     identity, size, timestamps, metadata and link count. These stat checks
345     do not detect every concurrent edit; they are not a filesystem transaction.
346    
347     =cut
348 root 1.2
349 root 1.1 use common::sense;
350    
351     use MIME::Base64 ();
352     use Getopt::Long ();
353     use Cwd ();
354 root 1.19 use File::Basename ();
355 root 1.1 use POSIX ();
356 root 1.6 use Fcntl qw(F_SETFD);
357 root 1.38 use Scalar::Util qw(weaken);
358 root 1.1
359     use JSON::XS ();
360     use AnyEvent ();
361 root 1.6 use AnyEvent::MCP ();
362 root 1.11 use AnyEvent::MCP::Config ();
363 root 1.1 use AnyEvent::MCP::Server::Permissions ();
364     use AnyEvent::Handle ();
365     use AnyEvent::Log ();
366     use AnyEvent::Util ();
367    
368     use AnyEvent::MCP::Server::Simple;
369    
370 root 1.33 # resolve an executable name or path to an absolute executable file.
371 root 1.1 sub find_executable {
372     my ($name) = @_;
373 root 1.33
374 root 1.1 !ref $name and length $name and $name !~ /\0/
375     or return;
376    
377     my @path = $name =~ m{/}
378     ? ($name)
379     : map { (length $_ ? $_ : ".") . "/$name" } split /:/, $ENV{PATH};
380    
381     for my $path (@path) {
382     next unless -f $path and -x _;
383     my $absolute = Cwd::abs_path $path;
384     return $absolute if length $absolute;
385     }
386    
387     undef
388     }
389    
390     my %program_test = (
391     find => [[qw(--version)], qr/^find \(GNU findutils\)/m],
392     grep => [[qw(--version)], qr/^grep \(GNU grep\)/m],
393     patch => [[qw(--version)], qr/^GNU patch /m],
394     perl => [["-e", "use 5.008004"], qr/\A\z/],
395     sed => [["-n", "1p", "/dev/null"], qr/\A\z/],
396     );
397    
398     Getopt::Long::Configure qw(bundling no_ignore_case require_order);
399    
400 root 1.10 my $sandbox_mode = "bwrap";
401 root 1.14 my $usage = "Usage: $0 [OPTION]... WORKDIR [WORKDIR]...\n"
402     . " $0 --exec-cmd GRANT,... [--] [COMMAND [ARG]...]\n";
403 root 1.1
404 root 1.14 my %server_options = mcp_getoptions ({
405     usage => $usage,
406     description => "Expose filesystem tools and command execution over MCP.\n"
407     . "Operations require permission and normally run inside bubblewrap.",
408     option_heading => "Filesystem options:",
409     options => [
410     ["--ask MODE", "Ask for permission via helper (default), mcp, or auto"],
411     ["--sandbox MODE", "Use bwrap (default), auto, or none (unsandboxed)"],
412     ["--bwrap PATH", "Path to the bubblewrap executable"],
413     ["--exec-cmd GRANT,...", "Run a command directly with the named grants"],
414     ],
415     examples => " $0 --stdio /home/alice/src/project\n"
416     . " $0 --name project --print-url\n"
417     . " $0 --sandbox bwrap /home/alice/src/project",
418     },
419 root 1.10 "sandbox=s" => \$sandbox_mode,
420 root 1.4 "ask=s" => \my $ask,
421 root 1.7 "exec-cmd=s" => \my $exec_cmd,
422 root 1.33 "bwrap=s" => \my $bwrap,
423 root 1.13 );
424    
425     my $name = $server_options{name};
426     my $stdio = delete $server_options{stdio};
427 root 1.1
428 root 1.4 die "--ask must be helper, mcp, or auto\n"
429     if defined $ask && $ask !~ /\A(?:helper|mcp|auto)\z/;
430    
431 root 1.11 (defined $exec_cmd or @ARGV)
432 root 1.1 or die $usage;
433    
434 root 1.10 $sandbox_mode =~ /\A(?:bwrap|auto|none)\z/
435     or die "--sandbox must be bwrap, auto, or none\n";
436    
437 root 1.19 my $program_name = File::Basename::basename ($0);
438     $program_name =~ s/\.(?:pl|perl)\z//;
439     my $config_file = AnyEvent::MCP::Config::_file ();
440     my $config = -e $config_file ? AnyEvent::MCP::Config->global : undef;
441     my %settings = $config ? (
442 root 1.30 %{ $config->{section}{"program $program_name"} },
443     %{ $config->{section}{"instance $name"} },
444 root 1.19 ) : ();
445    
446     my @mount;
447     for my $key (sort grep /\Amount\./, keys %settings) {
448     my $value = $settings{$key};
449     next unless length $value;
450     my ($mode, $path) = $value =~ /\A(ro|rw):(\/.*)\z/
451     or die "invalid $key mount '$value'\n";
452     -e $path or die "invalid $key mount path '$path'\n";
453     push @mount, [$key, $mode, $path];
454     }
455    
456     my %program;
457     for my $name (sort keys %program_test) {
458     my ($args, $match) = @{$program_test{$name}};
459     my $configured = $settings{"executable.$name"};
460     my $path = find_executable (length $configured ? $configured : $name);
461     my $ok;
462    
463     if ($path and length $configured) {
464     $ok = 1; # explicit executable choice is an administrator assertion
465     } elsif ($path and open my $fh, "-|", $path, @$args) {
466     local $/;
467 root 1.33 my $output = <$fh>;
468 root 1.19 $ok = close $fh && $output =~ $match;
469     }
470    
471     $program{$name} = $path if $ok;
472     warn "$name: not detected or wrong version\n" unless $program{$name};
473     }
474     my ($find, $grep, $patch, $perl, $sed) = @program{qw(find grep patch perl sed)};
475    
476 root 1.45 # optional VCS clients use trusted executable settings, like the filesystem utilities.
477     my %vcs_program = map {
478     ($_ => find_executable ($settings{"executable.$_"} || $_))
479     } qw(git svn cvs hg);
480    
481    
482 root 1.19 $bwrap //= $settings{"executable.bwrap"} || "bwrap";
483    
484 root 1.10 if ($sandbox_mode ne "none") {
485     my $name = $bwrap;
486     $bwrap = find_executable $name;
487     die "bubblewrap executable not found: $name\n"
488     if !$bwrap && $sandbox_mode eq "bwrap";
489     } else {
490     undef $bwrap;
491     }
492 root 1.1
493 root 1.19 my @runtime_path = grep -e, qw(
494 root 1.30 /usr
495     /bin
496     /sbin
497     /lib
498     /lib64
499     /etc/ld.so.cache
500     /etc/alternatives
501     /etc/fonts
502     /etc/localtime
503     /etc/ssl/openssl.cnf
504     /etc/ssl/certs
505     /etc/pki/tls/openssl.cnf
506     /etc/pki/tls/certs
507     /etc/pki/ca-trust
508     /etc/nsswitch.conf
509     /etc/hosts
510     /etc/resolv.conf
511 root 1.19 );
512 root 1.1
513 root 1.16 my (@workdir, @workdir_identity);
514 root 1.7 if (defined $exec_cmd) {
515     @workdir = Cwd::abs_path ".";
516     @workdir or die "cannot resolve current directory\n";
517     utf8::decode $workdir[0] or die "workdir is not UTF-8\n";
518 root 1.16 @workdir_identity = @workdir;
519 root 1.7 } else {
520     for my $path (@ARGV) {
521     my $workdir = Cwd::abs_path $path;
522     -d $workdir or die "invalid workdir '$path'\n";
523     utf8::decode $workdir or die "workdir is not UTF-8\n";
524 root 1.16 my $identity = $path;
525     utf8::decode $identity or die "workdir identity is not UTF-8\n";
526 root 1.7 push @workdir, $workdir;
527 root 1.16 push @workdir_identity, $identity;
528 root 1.7 }
529 root 1.1 }
530    
531     my $home = $ENV{HOME};
532     $home =~ m{\A/} and -d $home
533     or die "HOME must name an absolute existing directory\n";
534     $home = Cwd::abs_path $home
535     // die "cannot resolve HOME\n";
536     utf8::decode $home
537     or die "HOME is not a valid UTF-8 path\n";
538    
539 root 1.7 my $config_home;
540     unless (defined $exec_cmd) {
541     $config_home = $ENV{XDG_CONFIG_HOME};
542     if (length $config_home) {
543     $config_home =~ m{\A/}
544     or die "permission config directory must be absolute\n";
545     utf8::decode $config_home
546     or die "permission config directory is not UTF-8\n";
547     } else {
548     $config_home = "$home/.config";
549     }
550 root 1.1 }
551    
552 root 1.10 my %sandbox_grant = (
553 root 1.1 "workdir-read" => [],
554     "workdir-write" => ["workdir-read"],
555 root 1.16 "all-workdirs-read" => ["workdir-read"],
556     "all-workdirs-write" => [qw(all-workdirs-read workdir-write)],
557 root 1.1 "home-read" => [],
558     "home-write" => ["home-read"],
559 root 1.19 "world-read" => [qw(all-workdirs-read home-read)],
560 root 1.16 "world-write" => [qw(world-read all-workdirs-write home-write)],
561 root 1.1 "network" => [],
562 root 1.2 "long-running" => [],
563 root 1.1 "shared-tmp" => [],
564     "host-proc" => [],
565     );
566    
567 root 1.10 my %sandbox_grant_description = (
568 root 1.16 "workdir-read" => "Read files in the selected workdir",
569     "workdir-write" => "Change files in the selected workdir",
570     "all-workdirs-read" => "Read files in all configured workdirs",
571     "all-workdirs-write" => "Change files in all configured workdirs",
572 root 1.9 "home-read" => "Read files in your home directory",
573     "home-write" => "Change files in your home directory",
574     "world-read" => "Read the host filesystem",
575     "world-write" => "Change the host filesystem",
576     "network" => "Use network connections",
577     "long-running" => "Run commands longer than five minutes",
578     "shared-tmp" => "Access host temporary directories",
579 root 1.30 "host-proc" => $bwrap ? "Host process access (unavailable with bubblewrap)"
580     : "Full access to host processes",
581 root 1.9 );
582    
583 root 1.10 my @exec_default_sandbox_grants = ("workdir-read");
584 root 1.1
585     my $umask = umask;
586    
587 root 1.7 my $permissions;
588     $permissions = AnyEvent::MCP::Server::Permissions->new (
589 root 1.30 sandbox_grants => \%sandbox_grant,
590 root 1.18 sandbox_grant_descriptions => sub {
591     my ($request) = @_;
592 root 1.33
593 root 1.18 my %description = %sandbox_grant_description;
594     if (defined $request->{selected_workdir}) {
595     my $name = JSON::XS->new->utf8 (0)->allow_nonref
596     ->encode ($request->{selected_workdir});
597     $description{"workdir-read"} = "Read files in selected workdir $name";
598     $description{"workdir-write"} = "Change files in selected workdir $name";
599     }
600     \%description
601     },
602 root 1.4 (defined $ask ? (ask => $ask) : ()),
603 root 1.10 sandbox_implementation => $bwrap ? "bubblewrap" : "NONE - UNSANDBOXED (full host-user privileges)",
604     actions => {
605     inspect => "Inspect resource metadata and enumerate names",
606     read => "Read resource contents",
607     write => "Modify resource contents",
608     manage => "Rename, remove, and manage filesystem paths",
609     exec => "Run arbitrary commands",
610 root 1.45 "vcs-diff" => "Read working-copy differences using a VCS client",
611     "vcs-status" => "Read working-copy status using a VCS client",
612 root 1.10 },
613 root 1.11 name => $name,
614     file => "$config_home/anyevent-mcp/rules-fs-$name.ini",
615 root 1.7 ) unless defined $exec_cmd;
616 root 1.1
617 root 1.33 # permissions group tools by their kind of access, not by MCP tool name.
618 root 1.10 my %tool_action = (
619     stat => "inspect", list => "inspect", find => "inspect",
620     "file-read" => "read", "read-lines" => "read", grep => "read",
621     "file-write" => "write", "file-patch" => "write",
622     "path-op" => "manage", exec => "exec",
623     );
624    
625 root 1.33 # normalize an API pathname lexically against its selected workdir.
626 root 1.1 sub fs_path {
627     my ($workdir, $path) = @_;
628    
629     !ref $path and length $path and $path !~ /\0/
630     or die "path must be a nonempty string without NUL\n";
631     $path = "$workdir/$path" unless $path =~ m{\A/};
632    
633     my @part;
634    
635     for (split m{/+}, $path) {
636     next if !length or $_ eq ".";
637     if ($_ eq "..") {
638     pop @part;
639     } else {
640     push @part, $_;
641     }
642     }
643    
644     "/" . join "/", @part
645     }
646    
647 root 1.33 # test pathname containment without resolving symlinks.
648 root 1.1 sub fs_inside {
649     my ($path, $directory) = @_;
650    
651     $directory eq "/" or $path eq $directory or (index $path, "$directory/") == 0
652     }
653    
654 root 1.33 # classify relative to the selected workdir, not any configured workdir.
655 root 1.1 sub fs_path_capability {
656 root 1.16 my ($workdir, $path, $mode) = @_;
657 root 1.1
658     return "workdir-$mode"
659 root 1.16 if fs_inside $path, $workdir;
660     return "all-workdirs-$mode"
661 root 1.1 if grep { fs_inside $path, $_ } @workdir;
662     return "home-$mode" if fs_inside $path, $home;
663     return "shared-tmp" if (fs_inside $path, "/tmp") or (fs_inside $path, "/var/tmp");
664    
665     "world-$mode"
666     }
667    
668 root 1.33 # persist selected-workdir identities only where the requested authority
669 root 1.16 # actually depends on that selection. Broad all-workdirs grants are unscoped.
670     sub fs_grant_workdir {
671     my ($identity, $grants) = @_;
672 root 1.33
673 root 1.16 my $have = $permissions->closure ($grants);
674     return $identity if $have->{"workdir-write"} && !$have->{"all-workdirs-write"};
675     return $identity if $have->{"workdir-read"} && !$have->{"all-workdirs-read"};
676     undef
677     }
678    
679 root 1.33 # permission prompts display the selected, verbatim configured workdir next
680 root 1.18 # to workdir-specific grant choices. This is information, not a rule scope.
681     sub fs_require_permission {
682     my ($req, $identity, $request) = @_;
683 root 1.33
684 root 1.18 $request->{selected_workdir} = $identity;
685     $req->require_permission ($request)
686     }
687    
688 root 1.33 # direct execution has no filesystem, network or process isolation. Request
689 root 1.10 # every grant the process effectively receives, regardless of client wishes.
690     my @unsandboxed_grants = qw(world-write network shared-tmp host-proc);
691     sub fs_effective_grants {
692     return @_ if $bwrap;
693     (@unsandboxed_grants, grep $_ eq "long-running", @_)
694     }
695    
696 root 1.33 # one worker needs the combined sandbox authority for every path it will access.
697 root 1.1 sub fs_require_paths {
698 root 1.16 my ($req, $workdir, $identity, $paths, $mode, $reason) = @_;
699 root 1.1
700 root 1.15 my %seen;
701     $paths = [grep !$seen{$_}++, @$paths];
702 root 1.16 my %need = map { (fs_path_capability $workdir, $_, $mode) => 1 } @$paths;
703 root 1.10 %need = map { $_ => 1 } fs_effective_grants (keys %need);
704 root 1.16 my @grants = sort keys %need;
705 root 1.18 my $grant = fs_require_permission ($req, $identity, {
706 root 1.15 tool => $req->{tool},
707     action => $tool_action{$req->{tool}},
708 root 1.16 workdir => fs_grant_workdir ($identity, \@grants),
709 root 1.15 paths => $paths,
710 root 1.16 sandbox_grants => \@grants,
711 root 1.15 reason => $reason // "$req->{tool} " . join (", ", @$paths),
712     });
713 root 1.1
714 root 1.15 my $have = $permissions->closure ($grant);
715 root 1.1 for my $capability (sort keys %need) {
716     $have->{$capability}
717     or die "permission reduced below $capability for $req->{tool}\n";
718     }
719    
720     [sort keys %$have]
721     }
722    
723 root 1.33 # test whether the granted capability set makes this pathname writable.
724 root 1.1 sub fs_writable {
725 root 1.16 my ($workdir, $path, $have) = @_;
726 root 1.1
727     $have->{"world-write"}
728 root 1.16 or ($have->{"all-workdirs-write"} and (grep { fs_inside $path, $_ } @workdir))
729     or ($have->{"workdir-write"} and fs_inside $path, $workdir)
730 root 1.1 or ($have->{"home-write"} and (fs_inside $path, $home))
731     or ($have->{"shared-tmp"} and ((fs_inside $path, "/tmp") or (fs_inside $path, "/var/tmp")))
732     }
733    
734 root 1.33 # build the bubblewrap argv implementing the granted sandbox authority.
735 root 1.1 sub fs_sandbox {
736 root 1.10 my ($workdir, $sandbox_grants) = @_;
737 root 1.7
738     my $have = {};
739 root 1.10 my @todo = @$sandbox_grants;
740 root 1.7 while (@todo) {
741     my $capability = pop @todo;
742 root 1.10 exists $sandbox_grant{$capability} or die "Unknown permission capability '$capability'\n";
743 root 1.7 next if $have->{$capability}++;
744 root 1.10 push @todo, @{$sandbox_grant{$capability}};
745     }
746    
747     unless ($bwrap) {
748     unless (defined $exec_cmd) {
749     $have->{$_} or die "unsandboxed execution needs $_ grant\n"
750     for @unsandboxed_grants;
751     }
752     return ();
753 root 1.7 }
754    
755 root 1.1 die "host-proc sandbox authority is unresolved and unavailable in v1\n"
756     if $have->{"host-proc"};
757    
758     my @command = ($bwrap, qw(
759 root 1.7 --die-with-parent
760 root 1.1 --unshare-user --unshare-pid --unshare-ipc --unshare-uts
761     --unshare-cgroup-try --disable-userns --cap-drop ALL
762     ));
763 root 1.7 push @command, "--new-session" unless defined $exec_cmd;
764 root 1.1
765     push @command, "--unshare-net" unless $have->{network};
766    
767     push @command, $have->{"world-write"} ? "--bind-try" : "--ro-bind-try", "/", "/"
768     if $have->{"world-read"};
769    
770     my @scope_mount;
771    
772 root 1.19 unless ($have->{"world-read"}) {
773     for my $path (@runtime_path) {
774 root 1.16 my $mount = (fs_writable $workdir, $path, $have) ? "--bind-try" : "--ro-bind-try";
775 root 1.1 push @scope_mount, $mount, $path, $path;
776     }
777     }
778    
779     if ($have->{"home-read"}
780     and (!$have->{"world-read"} or ($have->{"home-write"} and !$have->{"world-write"}))) {
781 root 1.16 my $mount = (fs_writable $workdir, $home, $have) ? "--bind-try" : "--ro-bind-try";
782 root 1.1 push @scope_mount, $mount, $home, $home;
783     }
784    
785 root 1.16 if ($have->{"all-workdirs-read"}) {
786 root 1.1 for my $path (@workdir) {
787 root 1.16 my $mount = ($have->{"all-workdirs-write"}
788     or ($path eq $workdir && $have->{"workdir-write"}))
789     ? "--bind" : "--ro-bind";
790 root 1.1 push @scope_mount, $mount, $path, $path;
791     }
792 root 1.16 } elsif ($have->{"workdir-read"}) {
793     my $mount = $have->{"workdir-write"} ? "--bind" : "--ro-bind";
794     push @scope_mount, $mount, $workdir, $workdir;
795 root 1.1 }
796    
797 root 1.33 # replace special host mounts even when broad filesystem authority was asked.
798 root 1.1 push @command, qw(--tmpfs /sys --tmpfs /run --dev /dev);
799     push @command, "--proc", "/proc";
800    
801     for my $tmp (qw(/tmp /var/tmp)) {
802 root 1.19 if ($have->{"shared-tmp"} && !$have->{"world-write"}) {
803 root 1.1 push @command, "--bind-try", $tmp, $tmp;
804 root 1.19 } elsif (!$have->{"world-read"}
805     && !(grep $_->[2] eq $tmp, @mount)) {
806 root 1.1 push @command, "--tmpfs", $tmp;
807     }
808     }
809    
810 root 1.33 # explicit mounts override the private baseline where bwrap/kernel permit it.
811 root 1.1 push @command, @scope_mount;
812     for my $path (qw(/etc/resolv.conf /etc/hosts)) {
813 root 1.16 my $mount = (fs_writable $workdir, $path, $have) ? "--bind-try" : "--ro-bind-try";
814 root 1.1 push @command, $mount, $path, $path;
815     }
816    
817 root 1.33 # configured exceptions come after workdir/home mounts. Do not let an
818 root 1.19 # administrator's ro exception narrow explicitly granted write authority.
819     unless ($have->{"world-write"}) {
820     for my $entry (@mount) {
821     my ($key, $mode, $path) = @$entry;
822     next if $mode eq "ro" && ($have->{"world-read"} || fs_writable $workdir, $path, $have);
823     push @command, ($mode eq "rw" ? "--bind" : "--ro-bind"), $path, $path;
824     }
825     }
826    
827 root 1.6 push @command, "--file", 3, "/etc/passwd";
828    
829 root 1.1 push @command, "--remount-ro", "/"
830     unless $have->{"world-write"};
831    
832     my $sandbox_path = $ENV{PATH} // "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin";
833    
834     push @command,
835     "--clearenv",
836     "--setenv", "PATH", $sandbox_path,
837     "--setenv", "LC_ALL", "C.UTF-8",
838 root 1.7 "--setenv", "HOME", $home;
839    
840     for my $name (qw(TERM COLORTERM TERMINFO TERMINFO_DIRS NO_COLOR CLICOLOR CLICOLOR_FORCE LINES COLUMNS)) {
841     push @command, "--setenv", $name, $ENV{$name} if defined $ENV{$name};
842     }
843    
844     push @command,
845     "--chdir", defined $exec_cmd ? $workdir : $have->{"workdir-read"} ? $workdir : "/tmp",
846 root 1.1 "--";
847    
848     @command
849     }
850    
851 root 1.33 # pass a private synthetic user entry to bubblewrap without a host passwd mount.
852 root 1.7 sub fs_passwd_fd {
853     my ($read, $write) = AnyEvent::Util::portable_pipe
854     or die "passwd pipe: $!\n";
855     print {$write} "sandbox:x:$<:" . POSIX::getgid () . ":Sandbox:$home:/bin/sh\n"
856     or die "passwd pipe: $!\n";
857     close $write or die "passwd pipe: $!\n";
858    
859     if (fileno ($read) != 3) {
860     POSIX::dup2 (fileno ($read), 3) == 3 or die "passwd dup2: $!\n";
861     close $read;
862     open $read, "<&=3" or die "passwd fd: $!\n";
863     }
864    
865     defined fcntl ($read, F_SETFD, 0) or die "passwd fcntl: $!\n";
866     $read
867     }
868    
869     if (defined $exec_cmd) {
870 root 1.10 my @sandbox_grants = length $exec_cmd ? split /,/, $exec_cmd, -1 : ();
871     my @command = ((fs_sandbox $workdir[0], \@sandbox_grants),
872 root 1.7 (@ARGV ? @ARGV : ($ENV{SHELL} || "/bin/sh")));
873 root 1.10 my $passwd_fd = fs_passwd_fd () if $bwrap;
874 root 1.7
875     exec {$command[0]} @command;
876     die "exec $command[0]: $!\n";
877     }
878    
879 root 1.10 warn "mcp-fs: SANDBOX NONE - workers run with full host-user privileges\n"
880     unless $bwrap || defined $exec_cmd;
881    
882 root 1.1 AnyEvent::detect; # proc_spawn creates child watchers after they might have died
883    
884 root 1.38 # spawn stdin/stdout and stderr handles; proc_destroy closes them and terminates the child.
885     # the independent child watcher remains alive until the child is reaped.
886 root 1.1 sub proc_spawn {
887     my ($program, @argv) = @_;
888    
889     my ($parent, $child) = AnyEvent::Util::portable_socketpair
890     or die "socketpair: $!\n";
891     my ($error, $child_error) = AnyEvent::Util::portable_pipe
892     or die "pipe: $!\n";
893    
894     my $pid = fork;
895     defined $pid or die "fork: $!\n";
896    
897     unless ($pid) {
898     open STDIN, "<&", $child or POSIX::_exit 126;
899     open STDOUT, ">&", $child or POSIX::_exit 126;
900     open STDERR, ">&", $child_error or POSIX::_exit 126;
901    
902     close $parent;
903     close $error;
904     close $child;
905     close $child_error;
906    
907 root 1.10 my $passwd_fd = fs_passwd_fd () if $bwrap;
908 root 1.33 AnyEvent::Util::close_all_fds_except (0, 1, 2, ($bwrap ? 3 : ()));
909 root 1.1
910     exec {$program} $program, @argv;
911     print STDERR "exec $program: $!\n";
912     POSIX::_exit 126;
913     }
914    
915     close $child;
916     close $child_error;
917    
918     my $io = AnyEvent::Handle->new (fh => $parent);
919     my $err = AnyEvent::Handle->new (fh => $error);
920 root 1.42 my $proc = { pid => $pid };
921 root 1.1
922 root 1.33 # keep the watcher alive independently so a fatal I/O error still reaps.
923 root 1.1 $proc->{child} = AE::child $pid, sub {
924 root 1.42 $proc->{status} = $_[1];
925 root 1.1 delete $proc->{pid};
926     delete $proc->{child};
927 root 1.42 (delete $proc->{done})->($proc->{status}) if $proc->{done};
928 root 1.1 };
929    
930 root 1.38 $io->{proc} = $proc;
931     $io->{proc_err} = $err;
932 root 1.1
933     $io
934     }
935    
936 root 1.33 # signal a spawned process while its pid is still owned by us.
937 root 1.1 sub proc_kill {
938     my ($proc, $signal) = @_;
939    
940     my $pid = $proc->{pid} or return;
941    
942     $signal //= "KILL";
943     kill $signal => $pid
944     }
945    
946 root 1.38 # abandon a process and release its streams; repeated cleanup is harmless.
947     sub proc_destroy {
948     my ($io) = @_;
949    
950     proc_kill (delete $io->{proc}) if $io->{proc};
951     (delete $io->{proc_err})->destroy if $io->{proc_err};
952     $io->destroy;
953     }
954    
955 root 1.42 # register the process's exit callback, or call it immediately if reaped.
956 root 1.1 sub proc_on_exit {
957     my ($proc, $done) = @_;
958    
959     return $done->($proc->{status})
960     if defined $proc->{status};
961    
962 root 1.42 $proc->{done} = $done;
963 root 1.1 }
964    
965 root 1.35 # Run a one-shot subprocess, leaving output interpretation to the caller.
966     # The stream callbacks consume rbuf and may return true to terminate the child.
967     sub proc_run {
968     my ($req, $command, $input, $timeout, $on_stdout, $on_stderr, $done) = @_;
969 root 1.1
970 root 1.33 my $io = proc_spawn @$command;
971 root 1.38 my $err = $io->{proc_err};
972 root 1.1 my $proc = $io->{proc};
973 root 1.42 my ($open, $timed_out, $failure, $timer) = (2);
974 root 1.35
975 root 1.1 my $complete = sub {
976 root 1.42 return if $open || !defined $proc->{status};
977     my $cb = $done or return;
978     undef $done;
979 root 1.1 undef $timer;
980 root 1.38 proc_destroy $io;
981 root 1.1 return if $req->{finished};
982     delete $req->{on_cancel};
983 root 1.42 $cb->($proc->{status}, $timed_out, $failure);
984 root 1.1 };
985    
986 root 1.43 my $stop = sub {
987     undef $timer;
988     delete $req->{on_cancel};
989     $open = 0;
990     proc_destroy $io;
991     $complete->();
992     };
993     $req->{on_cancel} = $stop;
994    
995 root 1.38 for my $stream ([$io, $on_stdout], [$err, $on_stderr]) {
996 root 1.35 my ($handle, $consume) = @$stream;
997     my $closed;
998 root 1.1 my $finish = sub {
999 root 1.35 return if $closed++;
1000 root 1.1 --$open;
1001     $complete->();
1002     };
1003 root 1.35 my $read = sub {
1004     my ($handle, $eof) = @_;
1005     my $stop = eval { $consume->($handle, $eof) };
1006     if ($@) {
1007     $failure ||= $@;
1008     $stop = 1;
1009     }
1010     proc_kill $proc if $stop;
1011     };
1012     $handle->on_read (sub { $read->($_[0], 0) });
1013 root 1.1 $handle->on_eof (sub {
1014 root 1.35 $read->($_[0], 1);
1015 root 1.1 $finish->();
1016     });
1017     $handle->on_error (sub {
1018     return unless $_[1];
1019 root 1.35 $read->($_[0], 0);
1020     $failure ||= "subprocess I/O: $_[2]\n" unless $timed_out;
1021 root 1.1 proc_kill $proc;
1022     $finish->();
1023     });
1024     }
1025    
1026     my $ok = eval {
1027     $io->push_write ($input) if length $input;
1028     $io->push_shutdown;
1029     1
1030     };
1031     unless ($ok) {
1032 root 1.38 my $error = $@;
1033     proc_destroy $io;
1034     die $error;
1035 root 1.1 }
1036    
1037     $timer = AE::timer $timeout, 0, sub {
1038     $timed_out = 1;
1039 root 1.43 $stop->();
1040 root 1.1 };
1041    
1042 root 1.42 proc_on_exit $proc, $complete;
1043 root 1.1 }
1044    
1045 root 1.35 # Run a subprocess and collect bounded output.
1046     sub fs_run {
1047     my ($req, $command, $input, $timeout, $byte_limit, $line_limit, $done) = @_;
1048    
1049     my ($truncated, $line_truncated);
1050     my ($stdout, $stderr) = ("", "");
1051    
1052     my $capture = sub {
1053     my ($target, $data, $lines) = @_;
1054    
1055     return if $lines and $line_truncated;
1056     return unless length $data;
1057     my $remaining = $byte_limit - (length $stdout) - (length $stderr);
1058     $truncated = 1 if length $data > $remaining;
1059     $$target .= substr $data, 0, $remaining
1060     if $remaining > 0;
1061    
1062     if ($lines and $line_limit and ($$target =~ tr/\n//) > $line_limit) {
1063     my $end = -1;
1064     $end = index $$target, "\n", $end + 1
1065     for 1 .. $line_limit;
1066     substr $$target, $end + 1, length $$target, "";
1067     $truncated = $line_truncated = 1;
1068     return 1
1069     }
1070    
1071     0
1072     };
1073    
1074     proc_run $req, $command, $input, $timeout,
1075     sub {
1076     my ($handle) = @_;
1077     my $stop = $capture->(\$stdout, $handle->{rbuf}, 1);
1078     $handle->{rbuf} = "";
1079     $stop
1080     },
1081     sub {
1082     my ($handle) = @_;
1083     $capture->(\$stderr, $handle->{rbuf}, 0);
1084     $handle->{rbuf} = "";
1085     0
1086     },
1087     sub {
1088     my ($status, $timed_out, $failure) = @_;
1089     return $done->(0, $failure) if $failure;
1090     $done->(1, +{
1091     stdout => $stdout,
1092     stderr => $stderr,
1093     exit => $status >> 8,
1094     signal => $status & 127,
1095     timed_out => $timed_out ? JSON::XS::true : JSON::XS::false,
1096 root 1.43 truncated => $truncated || $timed_out ? JSON::XS::true : JSON::XS::false,
1097 root 1.35 });
1098     };
1099     }
1100    
1101 root 1.1 my $perl_slave = q{#perl
1102     binmode STDIN;
1103     binmode STDOUT;
1104     $SIG{PIPE} = "IGNORE";
1105     $| = 1;
1106    
1107     for (;;) {
1108     4 == read STDIN, my $buf, 4
1109     or last;
1110     my $len = unpack "N", $buf;
1111     $len == read STDIN, $buf, $len
1112     or die "short perl frame\n";
1113    
1114     my @result = do {
1115     (my $source, local @ARGV) = unpack "(w/a*)*", $buf;
1116     ("+", eval $source)
1117     };
1118    
1119     @result = ("-", $@)
1120     if $@;
1121    
1122     print pack "N/a*", pack "(w/a*)*", @result
1123     or die "stdout: $!\n"
1124     }
1125     };
1126    
1127     sub perl_frame {
1128     pack "N/a*", pack "(w/a*)*", @_
1129     }
1130    
1131 root 1.36 my $perl_worker;
1132    
1133 root 1.1 sub perl_eval {
1134 root 1.39 my ($req, $workdir, $sandbox_grants, $source, $args, $timeout, $done) = @_;
1135 root 1.33
1136 root 1.10 my @command = ((fs_sandbox $workdir, $sandbox_grants), $perl, "-e", $perl_slave);
1137 root 1.36 my $key = pack "(w/a*)*", @command;
1138 root 1.40 my $io = $perl_worker;
1139 root 1.36 undef $perl_worker;
1140    
1141 root 1.40 if ($io && ($io->{perl_key} ne $key || !$io->{proc}{pid})) {
1142     proc_destroy $io;
1143     undef $io;
1144 root 1.36 }
1145 root 1.1
1146 root 1.40 unless ($io) {
1147     $io = proc_spawn @command;
1148 root 1.39 $io->{rbuf_max} = (1 << 20) + 4096;
1149 root 1.40 $io->{perl_key} = $key;
1150     weaken (my $this = $io);
1151 root 1.41
1152     my $idle_destroy = sub {
1153     my $io = $this or return;
1154    
1155     undef $perl_worker if $perl_worker && $perl_worker == $io;
1156     proc_destroy $io;
1157     };
1158     my $idle_stderr = sub { $_[0]{rbuf} = "" };
1159    
1160     $io->{perl_idle} = sub {
1161     my ($io) = @_;
1162     my $err = $io->{proc_err};
1163    
1164     $io ->on_eof ($idle_destroy);
1165     $io ->on_error ($idle_destroy);
1166     $err->on_eof ($idle_destroy);
1167     $err->on_error ($idle_destroy);
1168     $err->on_read ($idle_stderr );
1169     $io ->on_read ($idle_destroy);
1170     };
1171    
1172 root 1.36 proc_on_exit $io->{proc}, sub {
1173 root 1.41 my $io = $this or return;
1174 root 1.38
1175 root 1.41 $io->{perl_on_exit}->($_[0]) if $io->{perl_on_exit};
1176     $idle_destroy->() if $perl_worker && $perl_worker == $io;
1177 root 1.36 };
1178     }
1179 root 1.33
1180 root 1.36 my $err = $io->{proc_err};
1181 root 1.42 my $timer;
1182 root 1.36 my $stderr = "";
1183    
1184     my $finish = sub {
1185 root 1.39 my ($failure, $payload) = @_;
1186 root 1.42 my $cb = $done or return;
1187     undef $done;
1188 root 1.36 undef $timer;
1189     delete $req->{on_cancel};
1190 root 1.40 delete $io->{perl_on_exit};
1191 root 1.36
1192 root 1.40 if ($failure || defined $io->{proc}{status}) {
1193 root 1.38 proc_destroy $io;
1194 root 1.42 return $cb->(0, $failure || "sandbox perl exited\n")
1195 root 1.36 unless $req->{finished};
1196     return;
1197     }
1198 root 1.1
1199     my (@response, $error);
1200     my $ok = eval {
1201 root 1.39 @response = unpack "(w/a*)*", $payload;
1202 root 1.1 my $status = shift @response;
1203     defined $status or die "missing perl response status\n";
1204     if ($status eq "-") {
1205 root 1.6 $error = AnyEvent::MCP::utf8dec $response[0];
1206 root 1.1 } elsif ($status ne "+") {
1207     die "invalid perl response status\n";
1208     }
1209     1
1210     };
1211    
1212 root 1.36 unless ($ok) {
1213 root 1.38 my $error = $@;
1214     proc_destroy $io;
1215 root 1.42 return $cb->(0, $error) unless $req->{finished};
1216 root 1.36 return;
1217     }
1218    
1219 root 1.38 # the response completes the request; the interpreter remains idle.
1220 root 1.40 proc_destroy $perl_worker if $perl_worker;
1221     $perl_worker = $io;
1222 root 1.41 $io->{perl_idle}->($io);
1223 root 1.36 return if $req->{finished};
1224 root 1.42 return $cb->(0, $error) if defined $error;
1225     $cb->(1, \@response);
1226 root 1.1 };
1227 root 1.36
1228 root 1.40 $io->{perl_on_exit} = sub {
1229 root 1.36 my ($status) = @_;
1230     $finish->("sandbox perl failed (exit " . ($status >> 8)
1231     . ", signal " . ($status & 127) . "): $stderr\n");
1232     };
1233    
1234     $req->{on_cancel} = sub { $finish->("sandbox perl cancelled\n") };
1235     $io->on_eof (sub { $finish->("short perl response\n") });
1236     $io->on_error (sub { $finish->("sandbox perl stdout: $_[2]\n") if $_[1] });
1237     $err->on_read (sub {
1238 root 1.39 $stderr .= substr $_[0]{rbuf}, 0, 65536 - length $stderr
1239     if length $stderr < 65536;
1240 root 1.36 $_[0]{rbuf} = "";
1241     });
1242     $err->on_eof (sub { $finish->("sandbox perl stderr closed\n") });
1243     $err->on_error (sub { $finish->("sandbox perl stderr: $_[2]\n") if $_[1] });
1244    
1245     $timer = AE::timer $timeout, 0, sub { $finish->("sandbox perl timed out\n") };
1246 root 1.39 my $ok = eval {
1247     $io->push_read (packstring => "N", sub {
1248     my ($io, $payload) = @_;
1249    
1250     return $finish->("unexpected sandbox perl output\n")
1251     if length $io->{rbuf};
1252     $finish->(undef, $payload);
1253     });
1254     $io->push_write (perl_frame $source, @$args);
1255     1
1256     };
1257 root 1.36 $finish->($@) unless $ok;
1258 root 1.1 }
1259    
1260 root 1.33 # resolve a configured workdir by index, numeric string or exact path.
1261 root 1.1 sub fs_workdir {
1262 root 1.21 my ($id) = @_;
1263 root 1.1
1264 root 1.21 $id = 0 if !defined $id and @workdir == 1;
1265 root 1.1
1266 root 1.21 defined $id && !ref $id
1267     or die "select a configured workdir by index or exact path\n";
1268    
1269     my $i;
1270     if ($id =~ /\A[0-9]+\z/) {
1271     $id < @workdir or die "Unknown workdir index '$id'\n";
1272     $i = $id;
1273     } else {
1274     my @match = grep { $id eq $workdir_identity[$_] || $id eq $workdir[$_] } 0 .. $#workdir;
1275     @match == 1 or die @match
1276     ? "Ambiguous configured workdir '$id'\n"
1277     : "Unknown configured workdir '$id'\n";
1278     $i = $match[0];
1279     }
1280 root 1.1
1281 root 1.16 wantarray ? ($workdir[$i], $workdir_identity[$i]) : $workdir[$i]
1282 root 1.1 }
1283    
1284 root 1.21 my $workdir_schema = {
1285     description => "Configured workdir: zero-based index (integer or numeric string), or exact path from workdirs/resolved_workdirs. A removed path fails rather than selecting a different workdir. Required when multiple workdirs are configured.",
1286     anyOf => [
1287     { type => "integer", minimum => 0 },
1288     { type => "string", minLength => 1 },
1289     ],
1290     };
1291    
1292 root 1.45 sub fs_result;
1293    
1294     # command output options affect presentation, never argv or permission scope.
1295     my %output_schema = (
1296     grep => { type => "string", description => "Optional PCRE line filter for stdout only. Runs after bounded capture, without a shell." },
1297     max_lines => { type => "integer", minimum => 1, maximum => 100000, default => 2000,
1298     description => "Maximum returned stdout lines after filtering." },
1299     max_bytes => { type => "integer", minimum => 1, maximum => 1048576, default => 65536,
1300     description => "Maximum returned UTF-8 bytes per stream; stderr is never filtered." },
1301     );
1302    
1303     sub fs_output_options {
1304     my ($args) = @_;
1305    
1306     my %options = (max_lines => $args->{max_lines} // 2000, max_bytes => $args->{max_bytes} // 65536);
1307     for my $name (qw(max_lines max_bytes)) {
1308     $options{$name} =~ /\A[0-9]+\z/ && $options{$name} >= 1
1309     && $options{$name} <= $output_schema{$name}{maximum}
1310     or die "invalid $name\n";
1311     }
1312    
1313     if (exists $args->{grep}) {
1314     defined $args->{grep} && !ref $args->{grep} && $args->{grep} !~ /\0/
1315     && length $args->{grep} <= 65536
1316     or die "grep must be a string without NUL, at most 65536 characters\n";
1317     $grep or die "output filtering needs GNU grep\n";
1318     $options{grep} = AnyEvent::MCP::utf8enc $args->{grep};
1319     }
1320    
1321     \%options
1322     }
1323    
1324     # clipping a UTF-8 stream may leave at most three bytes of an incomplete character.
1325     sub fs_output_text {
1326     my ($bytes, $cut) = @_;
1327    
1328     for (0 .. ($cut ? 3 : 0)) {
1329     my $text = $bytes;
1330     return $text if utf8::decode $text;
1331     chop $bytes;
1332     }
1333    
1334     die "command output is not UTF-8\n";
1335     }
1336    
1337     # preserve child status while bounding capture and, separately, returned output.
1338     sub fs_command {
1339     my ($req, $workdir, $grants, $argv, $input, $timeout, $options) = @_;
1340    
1341     my @command = ((fs_sandbox $workdir, $grants),
1342     $bwrap ? @$argv
1343     : ($^X, "-e", 'chdir shift or die "chdir: $!"; exec @ARGV; die "exec: $!";', $workdir, @$argv));
1344     my ($stdout, $stderr) = ("", "");
1345     my ($stdout_cut, $stderr_cut);
1346    
1347     my $capture = sub {
1348     my ($handle, $out, $cut) = @_;
1349     my $remaining = 1048576 - length $$out;
1350     $$cut = 1 if length $handle->{rbuf} > $remaining;
1351     $$out .= substr $handle->{rbuf}, 0, $remaining if $remaining > 0;
1352     $handle->{rbuf} = "";
1353     0
1354     };
1355    
1356     proc_run $req, \@command, $input, $timeout,
1357     sub { $capture->($_[0], \$stdout, \$stdout_cut) },
1358     sub { $capture->($_[0], \$stderr, \$stderr_cut) },
1359     sub {
1360     my ($status, $timed_out, $failure) = @_;
1361     return $req->error ($failure) if $failure;
1362    
1363     my $result = {
1364     exit => $status >> 8, signal => $status & 127,
1365     timed_out => $timed_out ? JSON::XS::true : JSON::XS::false,
1366     capture_truncated => $stdout_cut || $stderr_cut || $timed_out ? JSON::XS::true : JSON::XS::false,
1367     };
1368    
1369     my $finish = sub {
1370     my $ok = eval {
1371     $stdout_cut = 1 if length $stdout > $options->{max_bytes};
1372     $stderr_cut = 1 if length $stderr > $options->{max_bytes};
1373     my $out = fs_output_text (substr ($stdout, 0, $options->{max_bytes}), $stdout_cut);
1374     my $err = fs_output_text (substr ($stderr, 0, $options->{max_bytes}), $stderr_cut);
1375     my @lines = split /(?<=\n)/, $out;
1376     if (@lines > $options->{max_lines}) {
1377     splice @lines, $options->{max_lines};
1378     $out = join "", @lines;
1379     $stdout_cut = 1;
1380     }
1381     @$result{qw(stdout stderr)} = ($out, $err);
1382     $result->{stdout_truncated} = $stdout_cut || $timed_out ? JSON::XS::true : JSON::XS::false;
1383     $result->{stderr_truncated} = $stderr_cut || $timed_out ? JSON::XS::true : JSON::XS::false;
1384     $result->{truncated} = $stdout_cut || $stderr_cut || $timed_out ? JSON::XS::true : JSON::XS::false;
1385     1
1386     };
1387     return $req->error ($@) unless $ok;
1388     $req->reply (fs_result $result, exists $result->{filter_error});
1389     };
1390    
1391     return $finish->() unless exists $options->{grep};
1392    
1393     {
1394     my $ok = eval { $stdout = AnyEvent::MCP::utf8enc (fs_output_text $stdout, $stdout_cut); 1 };
1395     return $req->error ($@) unless $ok;
1396     }
1397    
1398     # regex execution stays in a timed worker, outside the broker's event loop.
1399     my @filter = ((fs_sandbox $workdir, $grants), $grep, "-aP", "--", $options->{grep});
1400     fs_run $req, \@filter, $stdout, 5, 1048576, 0, sub {
1401     my ($success, $filtered) = @_;
1402     if (!$success || $filtered->{exit} > 1 || $filtered->{signal} || $filtered->{timed_out}) {
1403     my $error = $success ? $filtered->{stderr} : $filtered;
1404     $error = eval { fs_output_text (substr ($error, 0, 4096), 1) } || "no diagnostic";
1405     $result->{filter_error} = "stdout filter failed: $error";
1406     $stdout = "";
1407     $stdout_cut = 1;
1408     } else {
1409     $stdout = $filtered->{stdout};
1410     $stdout_cut ||= $filtered->{truncated};
1411     }
1412     $finish->();
1413     };
1414     };
1415     }
1416    
1417 root 1.33 # wrap raw text as an MCP text result.
1418 root 1.1 sub fs_text_result {
1419     my ($text, $failed) = @_;
1420    
1421     my $response = {
1422     content => [{
1423     type => "text",
1424     text => $text,
1425     }],
1426     };
1427    
1428     $response->{isError} = JSON::XS::true if $failed;
1429    
1430     $response
1431     }
1432    
1433 root 1.33 # wrap a Perl value as an MCP text result.
1434 root 1.1 sub fs_result {
1435     my ($result, $failed) = @_;
1436 root 1.33
1437     my $response = fs_text_result (JSON::XS->new->encode ($result), $failed);
1438 root 1.1 $response->{structuredContent} = $result;
1439 root 1.33
1440 root 1.1 $response
1441     }
1442    
1443     mcp_tool workdirs =>
1444 root 1.45 "List configured workdirs, available VCS clients, sandbox grants, trusted mounts and runtime paths.",
1445 root 1.1 {
1446     type => "object",
1447     properties => {},
1448     additionalProperties => JSON::XS::false,
1449     },
1450     sub {
1451     my ($req, $args) = @_;
1452    
1453     AE::log debug => "workdirs";
1454    
1455     $req->reply (fs_result +{
1456 root 1.33 workdirs => \@workdir_identity,
1457     resolved_workdirs => \@workdir,
1458 root 1.45 vcs_backends => [sort grep defined $vcs_program{$_}, keys %vcs_program],
1459 root 1.33 umask => (sprintf "%04o", $umask),
1460     runtime_paths => \@runtime_path,
1461     mounts => \@mount,
1462     sandbox_grants => \%sandbox_grant,
1463 root 1.10 exec_default_sandbox_grants => \@exec_default_sandbox_grants,
1464     unavailable_sandbox_grants => ["host-proc"],
1465 root 1.1 });
1466     }
1467     ;
1468    
1469 root 1.33 # escape a pathname for compact line-oriented text output.
1470 root 1.1 sub escape_path {
1471     my ($text) = @_;
1472    
1473     $text =~ s/\\/\\\\/g;
1474     $text =~ s/\n/\\n/g;
1475     $text
1476     }
1477    
1478     sub format_iec_number {
1479     my ($number) = @_;
1480    
1481 root 1.33 for my $suffix ("", qw(k M G T P)) {
1482 root 1.1 return "$number$suffix" if $number < 1024 or $suffix eq "P";
1483     $number = int (($number + 512) / 1024);
1484     }
1485     }
1486    
1487     sub fs_find_relative {
1488     my ($base, $path) = @_;
1489    
1490     return "" if $path eq $base;
1491     substr $path, length ($base) + ($base eq "/" ? 0 : 1)
1492     }
1493    
1494     sub format_path_listing {
1495     my ($base, $record, $truncated) = @_;
1496 root 1.33
1497 root 1.1 my (@line, $directory);
1498    
1499     for my $record (sort { $a->[2] cmp $b->[2] } @$record) {
1500     my ($type, $size, $full_path) = @$record;
1501     my $relative = fs_find_relative $base, $full_path;
1502    
1503     if ($type eq "d") {
1504     push @line, "/" . (escape_path $relative);
1505     $directory = $relative;
1506     next;
1507     }
1508    
1509     my ($dir, $name);
1510    
1511     if (!length $relative) {
1512     $name = $full_path =~ s{.*/}{}r;
1513     } else {
1514     ($dir, $name) = $relative =~ m{\A(?:(.*)/)?([^/]*)\z};
1515     $dir //= "";
1516    
1517     if (!defined $directory or $directory ne $dir) {
1518     push @line, "/" . (escape_path $dir);
1519     $directory = $dir;
1520     }
1521     }
1522    
1523     $name = escape_path $name;
1524     push @line, $type eq "f"
1525     ? (join "\t", $type, (format_iec_number $size), $name)
1526     : (join "\t", $type, $name);
1527     }
1528    
1529     push @line, "!truncated" if $truncated;
1530    
1531 root 1.33 join "", map "$_\n", @line
1532 root 1.1 }
1533    
1534     sub fs_list_find {
1535     my ($req, $args, $recurse) = @_;
1536 root 1.33
1537 root 1.1 my $tool = $recurse ? "find" : "list";
1538    
1539 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
1540 root 1.1 my $path = fs_path $workdir, $args->{path} // ".";
1541     my $limit = $args->{limit} // 200;
1542     $limit <= 1000
1543     or die "invalid limit\n";
1544    
1545 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
1546     $args->{reason} // "$tool $path");
1547 root 1.1
1548     if ($recurse) {
1549     AE::log debug => join " ", $tool, $path, (substr $args->{name}, 0, 40);
1550     } else {
1551     AE::log debug => "$tool $path";
1552     }
1553    
1554 root 1.6 my $find_path = AnyEvent::MCP::utf8enc $path;
1555 root 1.1 my @command = ((fs_sandbox $workdir, $granted), $find, $find_path);
1556    
1557     if ($recurse) {
1558 root 1.6 my $needle = AnyEvent::MCP::utf8enc $args->{name};
1559 root 1.1 $needle =~ s/([\\*?\[])/\\$1/g;
1560     push @command, "!", "-type", "d", "-name", "*$needle*";
1561     } else {
1562     push @command, "-maxdepth", "1";
1563     }
1564    
1565     push @command, "-printf", q{%y\0%s\0%p\0};
1566    
1567 root 1.35 my (@record, $seen, $truncated, $failure);
1568 root 1.1 my $stderr = "";
1569    
1570 root 1.35 proc_run $req, \@command, "", 30,
1571     sub {
1572     my ($handle, $eof) = @_;
1573    
1574     while (!$failure and !$truncated
1575     and $handle->{rbuf} =~ s/\A([^\0]*)\0([^\0]*)\0([^\0]*)\0//s) {
1576     my ($type, $size, $raw_path) = ($1, $2, $3);
1577     my $full_path = eval { AnyEvent::MCP::utf8dec $raw_path };
1578    
1579     if ($@) {
1580     $failure = "$tool returned a non-UTF-8 pathname\n";
1581     last;
1582     }
1583 root 1.1
1584 root 1.35 if (++$seen > $limit) {
1585     $truncated = 1;
1586     last;
1587     }
1588 root 1.1
1589 root 1.35 push @record, [$type, $size, $full_path];
1590 root 1.1 }
1591    
1592 root 1.35 $failure ||= "incomplete $tool output\n"
1593     if $eof && length $handle->{rbuf} && !$truncated;
1594     $handle->{rbuf} = "" if $failure or $truncated;
1595     $failure || $truncated
1596     },
1597     sub {
1598     my ($handle) = @_;
1599     my $remaining = 65536 - length $stderr;
1600     $stderr .= substr $handle->{rbuf}, 0, $remaining if $remaining > 0;
1601     $handle->{rbuf} = "";
1602     0
1603     },
1604     sub {
1605     my ($status, $timed_out, $io_failure) = @_;
1606     return if $req->{finished};
1607 root 1.1
1608 root 1.35 my $result;
1609     my $ok = eval {
1610     die "$tool timed out\n" if $timed_out;
1611     die $failure || $io_failure if $failure || $io_failure;
1612 root 1.1
1613 root 1.35 if (($status >> 8 or $status & 127) and !$truncated) {
1614     my $error = eval { AnyEvent::MCP::utf8dec $stderr };
1615     $error = "non-UTF-8 error output" if $@;
1616     die "$tool failed (exit " . ($status >> 8) . ", signal " . ($status & 127) . "): $error\n";
1617     }
1618 root 1.33
1619 root 1.35 $result = fs_text_result (format_path_listing $path, \@record, $truncated);
1620     1
1621     };
1622 root 1.1
1623 root 1.35 if ($ok) {
1624     $req->reply ($result);
1625     } else {
1626     $req->error ($@);
1627 root 1.1 }
1628 root 1.35 };
1629 root 1.1 }
1630    
1631     $find && mcp_tool list =>
1632     "List immediate filesystem entries without following symlinks. "
1633     . "Compact text output uses /relative-directory headers for directories, f<TAB>approx-size<TAB>name "
1634     . "for regular files, TYPE<TAB>name for l/p/s/b/c/? nodes, "
1635     . "with compact size suffixes k/M/G/T/P using IEC powers of 1024, "
1636     . "and !truncated when the result limit was exceeded. Backslash and newline are escaped in names.",
1637     {
1638     type => "object",
1639     properties => {
1640     path => {
1641     type => "string",
1642     description => "Directory to list; defaults to the selected workdir.",
1643     },
1644     limit => {
1645     type => "integer",
1646     minimum => 1,
1647     maximum => 1000,
1648     default => 200,
1649     },
1650 root 1.21 workdir => $workdir_schema,
1651 root 1.1 reason => {
1652     type => "string",
1653     description => "Reason for requesting permission.",
1654     },
1655     },
1656     additionalProperties => JSON::XS::false,
1657     },
1658     sub {
1659     fs_list_find $_[0], $_[1], 0
1660     }
1661     ;
1662    
1663     $find && mcp_tool find =>
1664     "Find non-directory filesystem entries recursively by literal basename substring without following symlinks. Same output format as list.",
1665     {
1666     type => "object",
1667     properties => {
1668     path => {
1669     type => "string",
1670     description => "Path to search recursively; defaults to the selected workdir.",
1671     },
1672     name => {
1673     type => "string",
1674     minLength => 1,
1675     description => "Literal substring of the basename to find.",
1676     },
1677     limit => {
1678     type => "integer",
1679     minimum => 1,
1680     maximum => 1000,
1681     default => 200,
1682     },
1683 root 1.21 workdir => $workdir_schema,
1684 root 1.1 reason => {
1685     type => "string",
1686     description => "Reason for requesting permission.",
1687     },
1688     },
1689     required => ["name"],
1690     additionalProperties => JSON::XS::false,
1691     },
1692     sub {
1693     fs_list_find $_[0], $_[1], 1
1694     }
1695     ;
1696    
1697     my $file_read = q{#perl
1698     my ($path, $offset, $length) = @ARGV;
1699    
1700     open my $fh, "<:raw", $path or return 0, "open: $!";
1701     defined sysseek $fh, $offset, 0 or return 0, "seek: $!";
1702     my $n = sysread $fh, my $data, $length;
1703     defined $n or return 0, "read: $!";
1704    
1705     (1, $data)
1706     };
1707    
1708     $perl && mcp_tool file_read =>
1709 root 1.22 "Read up to 1 MiB by byte offset; returns structured {data: BASE64} on success or {error: STRING} on filesystem failure.",
1710 root 1.1 {
1711     type => "object",
1712     properties => {
1713     path => {
1714     type => "string",
1715     },
1716     offset => {
1717     type => "integer",
1718     minimum => 0,
1719     default => 0,
1720     },
1721     length => {
1722     type => "integer",
1723     minimum => 1,
1724     maximum => 1<<20,
1725     default => 1<<16,
1726     },
1727 root 1.21 workdir => $workdir_schema,
1728 root 1.1 reason => {
1729     type => "string",
1730     description => "Reason for requesting permission.",
1731     },
1732     },
1733     required => ["path"],
1734     additionalProperties => JSON::XS::false,
1735     },
1736     sub {
1737     my ($req, $args) = @_;
1738    
1739 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
1740 root 1.1 my $offset = $args->{offset} // 0;
1741     my $length = $args->{length} // 65536;
1742     $length <= 1<<20
1743     or die "invalid read range\n";
1744     my $path = fs_path $workdir, $args->{path};
1745    
1746 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
1747     $args->{reason} // "read $path");
1748 root 1.1
1749     AE::log debug => join " ", "file-read", $path, $offset, $length;
1750    
1751     perl_eval $req, $workdir, $granted, $file_read,
1752 root 1.39 [AnyEvent::MCP::utf8enc $path, $offset, $length], 30, sub {
1753 root 1.1 my ($success, $result) = @_;
1754 root 1.33
1755 root 1.1 return $req->error ($result)
1756     unless $success;
1757    
1758     @$result == 2
1759     or return $req->error ("invalid file-read response\n");
1760     my ($ok, $value) = @$result;
1761     if ($ok eq "1") {
1762     $req->reply (fs_result {
1763     data => MIME::Base64::encode_base64 ($value, ""),
1764     });
1765     } elsif ($ok eq "0") {
1766     $req->reply (fs_result {
1767 root 1.6 error => AnyEvent::MCP::utf8dec $value,
1768 root 1.1 });
1769     } else {
1770     $req->error ("invalid file-read status\n");
1771     }
1772     };
1773     }
1774     ;
1775    
1776     $sed && mcp_tool read_lines =>
1777 root 1.26 "Read byte-LF-delimited (0x0A) lines by positive line-number ranges. "
1778     . "encoding: utf-8 (strict, default), latin1 (raw bytes as Latin-1 text), or base64 (each range's exact sed output bytes). "
1779 root 1.28 . "Each requested range returns two MCP text items: [range=N,M] metadata (truncated=1 only if output was truncated), then the selected text without added line endings. Order, overlaps and duplicates are preserved; raw-byte output is limited to 1 MiB.",
1780 root 1.1 {
1781     type => "object",
1782     properties => {
1783     path => {
1784     type => "string",
1785     },
1786     ranges => {
1787     type => "array",
1788     description => "Inclusive line ranges, each N or N,M.",
1789     items => {
1790     type => "string",
1791     pattern => q{^[1-9][0-9]*(?:,[1-9][0-9]*)?$},
1792     },
1793     minItems => 1,
1794     maxItems => 20,
1795     },
1796 root 1.26 encoding => {
1797     type => "string",
1798     description => "How each selected byte range is represented: utf-8 (strict, default), latin1, or base64. Line numbers always count LF (0x0A) bytes.",
1799     enum => ["utf-8", "latin1", "base64"],
1800     default => "utf-8",
1801     },
1802 root 1.21 workdir => $workdir_schema,
1803 root 1.1 reason => {
1804     type => "string",
1805     description => "Reason for requesting permission.",
1806     },
1807     },
1808     required => ["path", "ranges"],
1809     additionalProperties => JSON::XS::false,
1810     },
1811     sub {
1812     my ($req, $args) = @_;
1813 root 1.33
1814 root 1.26 my $encoding = $args->{encoding} // "utf-8";
1815     $encoding =~ /\A(?:utf-8|latin1|base64)\z/ or die "invalid read-lines encoding\n";
1816 root 1.1
1817 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
1818 root 1.1 my $path = fs_path $workdir, $args->{path};
1819     my @range = map {
1820     my ($start, $end) = /\A([1-9]\d*)(?:,([1-9]\d*))?\z/
1821     or die "invalid line range\n";
1822     $end //= $start;
1823     $start <= $end
1824     or die "invalid line range\n";
1825     [$_, $start, $end]
1826     } @{$args->{ranges}};
1827    
1828 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
1829     $args->{reason} // "read lines $path");
1830 root 1.1
1831     AE::log debug => join " ", "read-lines", $path, map $_->[0], @range;
1832    
1833     my (@result, $truncated);
1834     my $remaining = 1 << 20;
1835     my $next;
1836     $next = sub {
1837     unless (@range) {
1838     undef $next;
1839 root 1.27 my @content;
1840 root 1.28 for my $i (0 .. $#result) {
1841     my ($range, $body) = @{$result[$i]};
1842     my $header = "[range=$range";
1843     $header .= " truncated=1" if $truncated && $i == $#result;
1844     push @content,
1845     { type => "text", text => "$header]" },
1846     { type => "text", text => $body };
1847 root 1.27 }
1848 root 1.28 push @content, { type => "text", text => "[truncated=1]" }
1849     if $truncated && !@content;
1850 root 1.27 return $req->reply ({ content => \@content });
1851 root 1.1 }
1852    
1853     my ($range, $start, $end) = @{shift @range};
1854     my @command = (
1855     (fs_sandbox $workdir, $granted),
1856     $sed, "-n",
1857     "-e", ($start == $end ? "${start}p" : "${start},${end}p"),
1858 root 1.27 "-e", "${end}Q", # Preserve unterminated final line (GNU sed).
1859 root 1.6 AnyEvent::MCP::utf8enc $path,
1860 root 1.1 );
1861    
1862     fs_run $req, \@command, "", 30, $remaining, 0, sub {
1863     my ($success, $value) = @_;
1864 root 1.33
1865 root 1.1 return $req->error ($value)
1866     unless $success;
1867    
1868     my $body;
1869     my $ok = eval {
1870     die "read-lines timed out\n" if $value->{timed_out};
1871     if ($value->{exit} or $value->{signal}) {
1872 root 1.6 my $stderr = AnyEvent::MCP::utf8dec $value->{stderr};
1873 root 1.1 die "read-lines failed (exit $value->{exit}, signal $value->{signal}): $stderr\n";
1874     }
1875 root 1.26 $body = $encoding eq "utf-8" ? AnyEvent::MCP::utf8dec $value->{stdout}
1876     : $encoding eq "base64" ? MIME::Base64::encode_base64 ($value->{stdout}, "")
1877     : $value->{stdout}; # Unflagged byte strings are Latin-1 to Perl/JSON::XS.
1878 root 1.1 1
1879     };
1880     return $req->error ($@)
1881     unless $ok;
1882    
1883     push @result, [$range, $body];
1884     $remaining -= length $value->{stdout};
1885 root 1.33 if ($value->{truncated} or ($remaining <= 0 and @range)) {
1886 root 1.1 $truncated = 1;
1887     @range = ();
1888     }
1889     $next->();
1890     };
1891     };
1892    
1893     $next->();
1894     }
1895     ;
1896    
1897     my $file_write = q{#perl
1898     my ($path, $offset, $data) = @ARGV;
1899    
1900     use Fcntl qw(O_WRONLY O_CREAT);
1901     sysopen my $fh, $path, O_WRONLY | O_CREAT, 0666 or return 0, "open: $!";
1902    
1903     binmode $fh;
1904     defined sysseek $fh, $offset, 0 or return 0, "seek: $!";
1905     my $n = syswrite $fh, $data;
1906     defined $n or return 0, "write: $!";
1907    
1908     (1, "$n")
1909     };
1910    
1911     $perl && mcp_tool file_write =>
1912 root 1.22 "Write up to 1 MiB of base64 data at a byte offset without truncating; returns structured {bytes: INTEGER} on success or {error: STRING} on filesystem failure.",
1913 root 1.1 {
1914     type => "object",
1915     properties => {
1916     path => {
1917     type => "string",
1918     },
1919     offset => {
1920     type => "integer",
1921     minimum => 0,
1922     default => 0,
1923     },
1924     data => {
1925     type => "string",
1926     },
1927 root 1.21 workdir => $workdir_schema,
1928 root 1.1 reason => {
1929     type => "string",
1930     description => "Reason for requesting permission.",
1931     },
1932     },
1933     required => ["path", "data"],
1934     additionalProperties => JSON::XS::false,
1935     },
1936     sub {
1937     my ($req, $args) = @_;
1938    
1939 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
1940 root 1.1 my $offset = $args->{offset} // 0;
1941     my $data = MIME::Base64::decode_base64 $args->{data};
1942     MIME::Base64::encode_base64 ($data, "") eq $args->{data}
1943     or die "invalid base64 data\n";
1944     length $data <= 1048576
1945     or die "write data exceeds 1 MiB\n";
1946     my $path = fs_path $workdir, $args->{path};
1947    
1948 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "write",
1949     $args->{reason} // "write $path");
1950 root 1.1
1951     AE::log debug => join " ", "file-write", $path, $offset, length ($data);
1952    
1953     perl_eval $req, $workdir, $granted, $file_write,
1954 root 1.39 [AnyEvent::MCP::utf8enc $path, $offset, $data], 30, sub {
1955 root 1.1 my ($success, $result) = @_;
1956 root 1.33
1957 root 1.1 return $req->error ($result)
1958     unless $success;
1959    
1960     @$result == 2
1961     or return $req->error ("invalid file-write response\n");
1962     my ($ok, $value) = @$result;
1963     if ($ok eq "1") {
1964     $value =~ /\A[0-9]+\z/
1965     or return $req->error ("invalid file-write byte count\n");
1966     $req->reply (fs_result {
1967     bytes => 0 + $value,
1968     });
1969     } elsif ($ok eq "0") {
1970     $req->reply (fs_result {
1971 root 1.6 error => AnyEvent::MCP::utf8dec $value,
1972 root 1.1 });
1973     } else {
1974     $req->error ("invalid file-write status\n");
1975     }
1976     };
1977     }
1978     ;
1979    
1980     my $path_op = q{#perl
1981     while (@ARGV) {
1982     my $op = shift;
1983    
1984     if ($op eq "unlink") {
1985     my $path = shift;
1986 root 1.33
1987 root 1.1 unlink $path
1988     or die "unlink $path: $!\n";
1989     } elsif ($op eq "rmdir") {
1990     my $path = shift;
1991 root 1.33
1992 root 1.1 rmdir $path
1993     or die "rmdir $path: $!\n";
1994     } elsif ($op eq "mkdir") {
1995     my ($path, $mode) = splice @ARGV, 0, 2;
1996     $mode = oct $mode;
1997     mkdir $path, $mode
1998     or die "mkdir $path: $!\n";
1999     chmod $mode, $path
2000     or die "chmod $path: $!\n";
2001     } elsif ($op eq "symlink") {
2002     my ($target, $path) = splice @ARGV, 0, 2;
2003     symlink $target, $path
2004     or die "symlink $path: $!\n";
2005     } elsif ($op eq "truncate") {
2006     my ($path, $size) = splice @ARGV, 0, 2;
2007     truncate $path, $size
2008     or die "truncate $path: $!\n";
2009     } elsif ($op eq "chmod") {
2010     my ($path, $mode) = splice @ARGV, 0, 2;
2011     $mode = oct $mode;
2012     chmod $mode, $path
2013     or die "chmod $path: $!\n";
2014     } elsif ($op eq "rename") {
2015     my ($path, $to) = splice @ARGV, 0, 2;
2016     rename $path, $to
2017     or die "rename $path -> $to: $!\n";
2018     } else {
2019     die "invalid path operation '$op'\n";
2020     }
2021     }
2022    
2023     1
2024     };
2025    
2026     $perl && mcp_tool path_op =>
2027     "Perform pathname mutations in fixed order: unlink, rmdir, mkpath, mkdir, symlink, truncate, chmod, rename. "
2028 root 1.22 . "Operations stop at the first failure; earlier changes remain and are not rolled back. "
2029 root 1.37 . "mkpath includes missing components in the combined permission check; symlink targets are link contents, not authority.",
2030 root 1.1 {
2031     type => "object",
2032     properties => {
2033     path => {
2034     type => "string",
2035     },
2036     unlink => {
2037     type => "boolean",
2038     },
2039     rmdir => {
2040     type => "boolean",
2041     },
2042     mkdir => {
2043     description => "Create path as a directory with this final mode, overriding umask.",
2044     anyOf => [
2045     { type => "string", pattern => q{^[0-7]+$} },
2046     { type => "integer", minimum => 0 },
2047     ],
2048     },
2049     mkpath => {
2050     description => "Create missing path components with this final mode, overriding umask.",
2051     anyOf => [
2052     { type => "string", pattern => q{^[0-7]+$} },
2053     { type => "integer", minimum => 0 },
2054     ],
2055     },
2056     symlink => {
2057     type => "string",
2058     minLength => 1,
2059     description => "Create path as a symlink containing this target.",
2060     },
2061     truncate => {
2062     type => "integer",
2063     minimum => 0,
2064     },
2065     chmod => {
2066     anyOf => [
2067     { type => "string", pattern => q{^[0-7]+$} },
2068     { type => "integer", minimum => 0 },
2069     ],
2070     },
2071     rename => {
2072     type => "string",
2073     minLength => 1,
2074     description => "Rename path to this destination.",
2075     },
2076 root 1.21 workdir => $workdir_schema,
2077 root 1.1 reason => {
2078     type => "string",
2079     description => "Reason for requesting permission.",
2080     },
2081     },
2082     required => ["path"],
2083     additionalProperties => JSON::XS::false,
2084     },
2085     sub {
2086     my ($req, $args) = @_;
2087    
2088     ($args->{unlink} or $args->{rmdir}
2089     or grep { exists $args->{$_} } qw(mkdir mkpath symlink truncate chmod rename))
2090     or die "no path operation requested\n";
2091    
2092 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2093 root 1.1 my $path = fs_path $workdir, $args->{path};
2094     my $rename = exists $args->{rename}
2095     ? fs_path $workdir, $args->{rename}
2096     : undef;
2097 root 1.16 die "mkpath requires a path in the selected workdir\n"
2098     if exists $args->{mkpath} && !fs_inside ($path, $workdir);
2099 root 1.1
2100     my @mkpath;
2101     if (exists $args->{mkpath}) {
2102     my $prefix = "";
2103     for (split m{/}, substr $path, 1) {
2104     $prefix .= "/$_";
2105     push @mkpath, $prefix
2106 root 1.6 unless -d AnyEvent::MCP::utf8enc $prefix;
2107 root 1.1 }
2108     }
2109    
2110 root 1.16 my $granted = fs_require_paths $req, $workdir, $identity,
2111 root 1.1 [@mkpath, $path, (defined $rename ? $rename : ())], "write", $args->{reason};
2112    
2113     my @log = map {
2114     ($_ eq "unlink" or $_ eq "rmdir")
2115     ? ($args->{$_} ? $_ : ())
2116     : (exists $args->{$_} ? "$_=$args->{$_}" : ())
2117     } qw(unlink rmdir mkpath mkdir symlink truncate chmod rename);
2118    
2119     AE::log debug => join " ", "path-op", $path, @log;
2120    
2121     my @operation;
2122 root 1.6 my $raw_path = AnyEvent::MCP::utf8enc $path;
2123 root 1.1
2124     push @operation, unlink => $raw_path
2125     if $args->{unlink};
2126     push @operation, rmdir => $raw_path
2127     if $args->{rmdir};
2128 root 1.6 push @operation, mkdir => (AnyEvent::MCP::utf8enc $_), $args->{mkpath}
2129 root 1.1 for @mkpath;
2130     push @operation, mkdir => $raw_path, $args->{mkdir}
2131     if exists $args->{mkdir};
2132 root 1.6 push @operation, symlink => (AnyEvent::MCP::utf8enc $args->{symlink}), $raw_path
2133 root 1.1 if exists $args->{symlink};
2134     push @operation, truncate => $raw_path, $args->{truncate}
2135     if exists $args->{truncate};
2136     push @operation, chmod => $raw_path, $args->{chmod}
2137     if exists $args->{chmod};
2138 root 1.6 push @operation, rename => $raw_path, (AnyEvent::MCP::utf8enc $rename)
2139 root 1.1 if defined $rename;
2140    
2141     perl_eval $req, $workdir, $granted, $path_op,
2142 root 1.39 \@operation, 30, sub {
2143 root 1.1 my ($success, $result) = @_;
2144 root 1.33
2145 root 1.1 return $req->error ($result)
2146     unless $success;
2147    
2148     @$result == 1 and $result->[0] eq "1"
2149     or return $req->error ("invalid path-op response\n");
2150     $req->reply (fs_text_result "ok\n");
2151     };
2152     }
2153     ;
2154    
2155     my $file_patch = q{#perl
2156     use Fcntl qw(O_RDWR O_CREAT O_EXCL);
2157     use Errno qw(EEXIST ENAMETOOLONG);
2158    
2159     my ($patch, $path, $diff, @max_offset) = @ARGV;
2160    
2161     my $create_tempfile = sub {
2162     my ($name) = @_;
2163     utf8::decode $name
2164     or return undef, "invalid UTF-8 tempfile name";
2165    
2166     my $slash = rindex $name, "/";
2167     my $dir = $slash < 0 ? "" : substr $name, 0, $slash + 1;
2168     my $file = substr $name, $slash + 1;
2169    
2170     for (;;) {
2171     my $pathname = "$dir$file";
2172     utf8::encode $pathname;
2173    
2174     if (sysopen my $fh, $pathname, O_RDWR | O_CREAT | O_EXCL, 0600) {
2175     binmode $fh;
2176     return $fh, $pathname
2177     }
2178    
2179     if ($! == EEXIST) {
2180     $file .= "~";
2181     } elsif ($! == ENAMETOOLONG) {
2182     substr $file, (length $file) * .75, 2, "~";
2183     } else {
2184     return undef, "open tempfile: $!"
2185     }
2186     }
2187     };
2188    
2189 root 1.8 # Deliberately self-contained: patch validation must not load host modules.
2190     my $check_patch_output = sub {
2191     my ($status, $output, @max_offset) = @_;
2192     my (@hunks, @unparsed);
2193    
2194     for (split /\n/, $output) {
2195     if (/^Hunk #(\d+) (succeeded|FAILED|ignored) at (-?\d+)(?: with fuzz (\d+))?(?: \(offset (-?\d+) lines?\))?\.\z/) {
2196     push @hunks, [0 + $1, $2 eq "succeeded", 0 + $4, 0 + $5];
2197     } elsif (!length
2198     || /^Hmm\.\.\. +Looks like a unified diff to me\.\.\.\z/
2199     || /^The text leading up to this was:\z/
2200     || /^-+\z/ || /^\|/
2201     || /^patching file / || /^checking file /
2202     || /^Using Plan [AB]\.\.\.\z/ || /^done\z/) {
2203     # GNU patch --verbose framing.
2204     } else {
2205     push @unparsed, $_;
2206     }
2207     }
2208    
2209     $status == 0
2210     or die "patch failed (wait status $status):\n$output";
2211     @unparsed == 0
2212     or die "patch: unrecognized output:\n" . (join "\n", @unparsed) . "\n";
2213     @hunks == @max_offset && @hunks
2214     or die "patch: expected " . @max_offset . " hunks, got " . @hunks . "\n";
2215    
2216     for my $i (0 .. $#hunks) {
2217     my ($number, $success, $fuzz, $offset) = @{$hunks[$i]};
2218    
2219     $number == $i + 1
2220     or die "patch: unexpected hunk number $number\n";
2221     $success
2222     or die "patch: hunk $number failed\n";
2223     !$fuzz
2224     or die "patch: hunk $number used fuzz $fuzz\n";
2225     abs ($offset) <= $max_offset[$i]
2226     or die "patch: hunk $number offset $offset exceeds $max_offset[$i]\n";
2227     }
2228     };
2229    
2230 root 1.1 my $run_patch = sub {
2231     my ($newname, $path, $diff) = @_;
2232    
2233     my ($errfh, $errname) = $create_tempfile->("/tmp/mcp-fs~~patch~~");
2234     $errfh
2235     or return undef, $errname;
2236    
2237     local $ENV{LC_ALL} = "C";
2238     open my $write, "|-", "sh", "-c",
2239     q{exec "$4" --unified --verbose --binary --batch --forward --get=0 --fuzz=0 --reject-file=- --no-backup-if-mismatch -o "$2" "$1" >"$3" 2>&1},
2240     "--", $path, $newname, $errname, $patch
2241     or return undef, "open patch pipe: $!";
2242    
2243     my $write_error;
2244     {
2245     print $write $diff
2246     or $write_error = "write patch: $!";
2247     close $write;
2248     }
2249    
2250     my $status = $?;
2251    
2252     unlink $errname
2253     or return undef, "unlink patch stderr: $!";
2254     defined sysseek $errfh, 0, 0
2255     or return undef, "seek patch stderr: $!";
2256     my $output = do { local $/; <$errfh> };
2257     $output = "" unless defined $output;
2258     $output =~ s/\s+\z//;
2259    
2260     return undef, $write_error
2261     if $write_error and !$status;
2262    
2263     $status, $output
2264     };
2265    
2266     my $replace_file = sub {
2267     my ($path, $before, $newfh, $newname) = @_;
2268    
2269     chown $before->[4], $before->[5], $newfh;
2270     chmod $before->[2], $newfh;
2271    
2272     my @after = lstat $path;
2273     @after
2274     or return undef, "lstat: $!";
2275     join ("\0", @$before[0, 1, 2, 3, 4, 5, 7, 9, 10]) eq join ("\0", @after[0, 1, 2, 3, 4, 5, 7, 9, 10])
2276     or return undef, "file changed while applying patch";
2277     my @new = stat $newfh;
2278     $after[3] == 1
2279     or return undef, "refusing to replace multiply-linked file";
2280     join ("\0", @after[2, 4, 5]) eq join ("\0", @new[2, 4, 5])
2281     or return undef, "cannot preserve file metadata";
2282    
2283     rename $newname, $path
2284     or return undef, "rename: $!";
2285    
2286     $new[7], undef
2287     };
2288    
2289     my @before = lstat $path;
2290     @before
2291     or return 0, "lstat: $!";
2292     -f _
2293     or return 0, "not a regular file";
2294    
2295     my ($newfh, $newname) = $create_tempfile->("$path~~new~~");
2296     $newfh
2297     or return 0, $newname;
2298    
2299     my ($status, $output) = $run_patch->($newname, $path, $diff);
2300     unless (defined $status) {
2301     unlink $newname;
2302     return 0, $output
2303     }
2304    
2305 root 1.8 unless (eval { $check_patch_output->($status, $output, @max_offset); 1 }) {
2306 root 1.1 my $error = $@;
2307     unlink $newname;
2308     return 0, $error
2309     }
2310    
2311     my ($bytes, $error) = $replace_file->($path, \@before, $newfh, $newname);
2312     unlink $newname unless defined $bytes;
2313     defined $bytes
2314     ? (1, "$bytes")
2315     : (0, $error)
2316     };
2317    
2318     $perl && $patch && mcp_tool file_patch =>
2319     "Apply a unified diff to one regular file. Hunk counts are recomputed, fuzz is disabled, "
2320     . "whole-hunk relocation is limited per hunk by distinct old-side lines. "
2321 root 1.10 . "Pure insertion is allowed only at the beginning; elsewhere include old-side context. "
2322     . "The diff argument is Unicode text (converted to UTF-8 bytes for patch), "
2323     . "or base64 of raw patch bytes (autodetected: text has @ hunk markers; "
2324     . "base64 cannot contain @).",
2325 root 1.1 {
2326     type => "object",
2327     properties => {
2328     path => { type => "string" },
2329     diff => { type => "string", minLength => 1 },
2330 root 1.21 workdir => $workdir_schema,
2331 root 1.1 reason => {
2332     type => "string",
2333     description => "Reason for requesting permission.",
2334     },
2335     },
2336     required => ["path", "diff"],
2337     additionalProperties => JSON::XS::false,
2338     },
2339     sub {
2340     my ($req, $args) = @_;
2341    
2342 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2343 root 1.1 my $path = fs_path $workdir, $args->{path};
2344 root 1.10 my $diff = $args->{diff};
2345    
2346     if (index ($diff, "@") >= 0) {
2347     $diff = AnyEvent::MCP::utf8enc $diff;
2348     } else {
2349     my $bytes = MIME::Base64::decode_base64 $diff;
2350     MIME::Base64::encode_base64 ($bytes, "") eq $diff
2351     or die "invalid base64 diff\n";
2352     $diff = $bytes;
2353     }
2354    
2355     my $max_offset;
2356     ($diff, $max_offset) = AnyEvent::MCP::normalize_udiff $diff;
2357 root 1.1 length $diff <= 4<<20
2358     or die "patch exceeds 4 MiB\n";
2359    
2360 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "write",
2361     $args->{reason} // "patch $path");
2362 root 1.1
2363     AE::log debug => join " ", "file-patch", $path, scalar (@$max_offset), "hunks", length ($diff), "bytes";
2364    
2365     perl_eval $req, $workdir, $granted, $file_patch,
2366 root 1.39 [$patch, AnyEvent::MCP::utf8enc $path, $diff, @$max_offset], 30, sub {
2367 root 1.1 my ($success, $result) = @_;
2368 root 1.33
2369 root 1.1 return $req->error ($result)
2370     unless $success;
2371    
2372     @$result == 2
2373     or return $req->error ("invalid file-patch response\n");
2374     my ($ok, $value) = @$result;
2375    
2376     if ($ok eq "1") {
2377     $value =~ /\A[0-9]+\z/
2378     or return $req->error ("invalid file-patch byte count\n");
2379     $req->reply (fs_result {
2380     bytes => 0 + $value,
2381     });
2382     } elsif ($ok eq "0") {
2383     $req->reply (fs_result {
2384 root 1.6 error => AnyEvent::MCP::utf8dec $value,
2385 root 1.1 });
2386     } else {
2387     $req->error ("invalid file-patch status\n");
2388     }
2389     };
2390     }
2391     ;
2392    
2393     $find && $grep && mcp_tool grep =>
2394     "Search regular files of at most 1 MiB recursively without following symlinks or descending .git. "
2395     . "Fixed-string matching by default; mode=pcre uses GNU grep -P. "
2396     . "Stops after the result limit; reports truncation and timeout separately.",
2397     {
2398     type => "object",
2399     properties => {
2400     path => {
2401     type => "string",
2402     description => "Path to search; defaults to the selected workdir.",
2403     },
2404     pattern => {
2405     type => "string",
2406     minLength => 1,
2407     },
2408     mode => {
2409     type => "string",
2410     enum => ["fixed", "pcre"],
2411     default => "fixed",
2412     },
2413     limit => {
2414     type => "integer",
2415     minimum => 1,
2416     maximum => 1000,
2417     default => 200,
2418     description => "Maximum number of matching lines to return.",
2419     },
2420 root 1.21 workdir => $workdir_schema,
2421 root 1.1 reason => {
2422     type => "string",
2423     description => "Reason for requesting permission.",
2424     },
2425     },
2426     required => ["pattern"],
2427     additionalProperties => JSON::XS::false,
2428     },
2429     sub {
2430     my ($req, $args) = @_;
2431    
2432 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2433 root 1.1 my $path = fs_path $workdir, $args->{path} // ".";
2434     my $mode = $args->{mode} // "fixed";
2435     $mode eq "fixed" or $mode eq "pcre"
2436     or die "invalid grep mode\n";
2437     my $limit = $args->{limit} // 200;
2438     $limit <= 1000
2439     or die "invalid limit\n";
2440 root 1.32 my $granted = fs_require_paths ($req, $workdir, $identity, [$path], "read",
2441     $args->{reason} // "grep $path");
2442 root 1.1
2443     AE::log debug => join " ", "grep", $path, $mode, (substr $args->{pattern}, 0, 40);
2444    
2445 root 1.6 my $find_path = AnyEvent::MCP::utf8enc $path;
2446     my $pattern = AnyEvent::MCP::utf8enc $args->{pattern};
2447 root 1.1 my @command = (
2448     (fs_sandbox $workdir, $granted),
2449     $find, $find_path,
2450     "-name", ".git", "-prune", "-o",
2451     "-type", "f",
2452     "-size", "-1048577c",
2453     "-exec",
2454 root 1.22 # GNU find treats any nonzero -exec ... + batch as failure.
2455     # grep uses 1 for no matches, which is a successful empty search;
2456     # preserve all other errors (including find traversal failures).
2457     "/bin/sh", "-c", q{"$@" || [ "$?" -eq 1 ]}, "sh",
2458 root 1.1 $grep, ($mode eq "pcre" ? "-P" : "-F"),
2459     "-I", "-H", "-n", "--color=never", "-e", $pattern,
2460     "{}", "+",
2461     );
2462    
2463     fs_run $req, \@command, "", 30, 1024 * 1024, $limit, sub {
2464     my ($success, $result) = @_;
2465 root 1.33
2466 root 1.1 return $req->error ($result)
2467     unless $success;
2468    
2469     my $response;
2470     my $ok = eval {
2471 root 1.6 my $stdout = AnyEvent::MCP::utf8dec $result->{stdout};
2472     my $stderr = AnyEvent::MCP::utf8dec $result->{stderr};
2473 root 1.1
2474     if (($result->{exit} or $result->{signal})
2475     and !$result->{truncated} and !$result->{timed_out}) {
2476     die "grep failed (exit $result->{exit}, signal $result->{signal}): $stderr\n";
2477     }
2478    
2479     $response = {
2480     stdout => $stdout,
2481     stderr => $stderr,
2482     timed_out => $result->{timed_out},
2483     truncated => $result->{truncated},
2484     };
2485     1
2486     };
2487     return $req->error ($@)
2488     unless $ok;
2489    
2490     $req->reply (fs_result $response);
2491     };
2492     }
2493     ;
2494    
2495     my $stat = q{#perl
2496     map {
2497     my @s = lstat $_;
2498    
2499     pack "(w/a*)*", (@s
2500     ? (1, (-l _ ? "link" : -d _ ? "directory" : -f _ ? "file" : "other"), @s)
2501     : (0, "lstat: $!"))
2502     } @ARGV
2503     };
2504    
2505     $perl && mcp_tool stat =>
2506     "Get lstat metadata for up to 100 paths; symlinks are not followed.",
2507     {
2508     type => "object",
2509     properties => {
2510     paths => {
2511     type => "array",
2512     items => {
2513     type => "string",
2514     },
2515     minItems => 1,
2516     maxItems => 100,
2517     },
2518 root 1.21 workdir => $workdir_schema,
2519 root 1.1 reason => {
2520     type => "string",
2521     description => "Reason for requesting permission.",
2522     },
2523     },
2524     required => ["paths"],
2525     additionalProperties => JSON::XS::false,
2526     },
2527     sub {
2528     my ($req, $args) = @_;
2529    
2530 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2531 root 1.1 ARRAY:: eq ref $args->{paths} and @{$args->{paths}} and @{$args->{paths}} <= 100
2532     or die "stat needs 1..100 paths\n";
2533     my @paths = map { fs_path $workdir, $_ } @{$args->{paths}};
2534 root 1.16 die "stat paths must be inside the selected workdir\n"
2535     if grep { !fs_inside ($_, $workdir) } @paths;
2536 root 1.1
2537 root 1.16 my $granted = fs_require_paths $req, $workdir, $identity, \@paths, "read", $args->{reason};
2538 root 1.1
2539     my @log = @paths;
2540     splice @log, 3, @log - 3, "..." if @log > 3;
2541     AE::log debug => join " ", "stat", @log;
2542    
2543 root 1.6 perl_eval $req, $workdir, $granted, $stat, [map { AnyEvent::MCP::utf8enc $_ } @paths],
2544 root 1.39 30, sub {
2545 root 1.1 my ($success, $result) = @_;
2546 root 1.33
2547 root 1.1 return $req->error ($result)
2548     unless $success;
2549    
2550     @$result == @paths
2551     or return $req->error ("invalid stat response\n");
2552    
2553     my @result;
2554     for my $i (0 .. $#paths) {
2555     my ($ok, @s) = unpack "(w/a*)*", $result->[$i];
2556    
2557     if ($ok) {
2558     my $type = shift @s;
2559     push @result, +{
2560     path => $paths[$i],
2561     type => $type,
2562     size => 0 + $s[7],
2563     mode => (sprintf "%04o", $s[2] & 07777),
2564     uid => 0 + $s[4],
2565     gid => 0 + $s[5],
2566     mtime => 0 + $s[9],
2567     };
2568     } else {
2569     push @result, +{
2570     path => $paths[$i],
2571 root 1.6 error => AnyEvent::MCP::utf8dec $s[0],
2572 root 1.1 };
2573     }
2574     }
2575    
2576     $req->reply (fs_result { result => \@result });
2577     };
2578     }
2579     ;
2580    
2581     mcp_tool exec =>
2582 root 1.10 "Run arbitrary argv " . ($bwrap ? "in bubblewrap" : "UNSANDBOXED with full host-user privileges") . "; UTF-8 stdin is limited to 4 MiB, "
2583 root 1.45 . "stdout/stderr are UTF-8, captured to 1 MiB each and returned with max_lines/max_bytes limits. Optional grep is a PCRE stdout postfilter; stderr is unfiltered. Capture truncation is explicit. Sandbox grants are optional; "
2584 root 1.20 . "explicit grants replace the workdir-read default. A command's exit status, signal or timeout is returned as a normal result, not a tool error. There is no command-prefix safety classification. Timeouts above 300 seconds require "
2585 root 1.2 . "long-running permission; the maximum is six hours.",
2586 root 1.1 {
2587     type => "object",
2588     properties => {
2589 root 1.45 %output_schema,
2590 root 1.21 workdir => $workdir_schema,
2591 root 1.1 argv => {
2592     type => "array",
2593     items => { type => "string" },
2594     minItems => 1,
2595     },
2596 root 1.10 sandbox_grants => {
2597 root 1.1 type => "array",
2598 root 1.20 description => "If omitted, defaults to workdir-read under bubblewrap. If supplied (including []), replaces this default entirely rather than extending it. Without bubblewrap effective grants are always maximum host authority. host-proc is unavailable with bubblewrap.",
2599 root 1.1 items => {
2600     type => "string",
2601 root 1.10 enum => [sort keys %sandbox_grant],
2602 root 1.1 },
2603     },
2604     reason => {
2605     type => "string",
2606     description => "Useful reason for requesting arbitrary command authority.",
2607     },
2608     stdin => { type => "string" },
2609     timeout => {
2610     type => "integer",
2611     minimum => 1,
2612 root 1.2 maximum => 21600,
2613 root 1.1 default => 30,
2614 root 1.2 description => "Seconds; values above 300 also request long-running permission.",
2615 root 1.1 },
2616     },
2617     required => [qw(argv reason)],
2618     additionalProperties => JSON::XS::false,
2619     },
2620     sub {
2621     my ($req, $args) = @_;
2622    
2623 root 1.45 my $output = fs_output_options $args;
2624    
2625 root 1.1 ARRAY:: eq ref $args->{argv} and @{$args->{argv}}
2626     or die "exec needs argv strings without NUL\n";
2627     for my $arg (@{$args->{argv}}) {
2628     defined $arg and !ref $arg and $arg !~ /\0/
2629     or die "exec needs argv strings without NUL\n";
2630     }
2631     length $args->{argv}[0] or die "exec needs an executable\n";
2632    
2633 root 1.16 my ($workdir, $identity) = fs_workdir $args->{workdir};
2634 root 1.2 my $timeout = $args->{timeout} // 30;
2635     $timeout >= 1 && $timeout <= 21600 && $timeout == int $timeout
2636     or die "invalid timeout\n";
2637    
2638 root 1.10 my $sandbox_grants = [exists $args->{sandbox_grants} ? @{$args->{sandbox_grants}} : @exec_default_sandbox_grants];
2639     push @$sandbox_grants, "long-running" if $timeout > 300;
2640     $sandbox_grants = [fs_effective_grants @$sandbox_grants];
2641     my $requested = $permissions->closure ($sandbox_grants);
2642 root 1.1 die "host-proc sandbox authority is unresolved and unavailable in v1\n"
2643 root 1.10 if $bwrap && $requested->{"host-proc"};
2644 root 1.1
2645 root 1.31 my $stdin_text = $args->{stdin} // "";
2646     my $input = AnyEvent::MCP::utf8enc $stdin_text;
2647 root 1.1 length $input <= 4 * 1024 * 1024 or die "stdin exceeds 4 MiB\n";
2648    
2649 root 1.32 # preview Unicode source independently of its encoding for the subprocess.
2650     my $preview_limit = 1 << 16;
2651 root 1.31
2652 root 1.1 my $coder = JSON::XS->new->canonical;
2653     my $subject = $coder->encode ($args->{argv});
2654     my $executable = $coder->encode ($args->{argv}[0]);
2655 root 1.18 my $granted = fs_require_permission ($req, $identity, {
2656 root 1.33 tool => "exec",
2657     action => "exec",
2658     workdir => fs_grant_workdir ($identity, $sandbox_grants),
2659     subject => $subject,
2660     reason => $args->{reason},
2661 root 1.10 sandbox_grants => $sandbox_grants,
2662 root 1.31 (length $input ? (details => [{
2663     label => "stdin",
2664 root 1.33 text => (substr $stdin_text, 0, $preview_limit),
2665 root 1.31 chars => length $stdin_text,
2666     bytes => length $input,
2667 root 1.32 (length $stdin_text > $preview_limit ? (truncated => JSON::XS::true) : ()),
2668 root 1.31 }]) : ()),
2669 root 1.33 subjects => [
2670 root 1.1 {
2671     label => "exact executable and argv",
2672     subject => $subject,
2673     },
2674     {
2675     label => "this executable, arbitrary argv",
2676     subject => "^\\[\Q$executable\E(?:,.*)?\\]\\z",
2677     },
2678     {
2679     label => "any command",
2680     subject => "^",
2681     },
2682     ],
2683     });
2684    
2685 root 1.2 $timeout <= 300 || $permissions->closure ($granted)->{"long-running"}
2686     or die "permission reduced below long-running for exec\n";
2687    
2688 root 1.1 AE::log debug => "exec "
2689     . (substr $subject, 0, 200)
2690     . (length $subject > 200 ? "..." : "")
2691     . (length $input ? " stdin=" . length ($input) : "");
2692    
2693 root 1.45 fs_command $req, $workdir, $granted, $args->{argv}, $input, $timeout, $output;
2694     }
2695     ;
2696    
2697     # fixed operations accept filenames, never caller-supplied VCS options or shell code.
2698     for my $operation (qw(diff status)) {
2699     mcp_tool "vcs-$operation" =>
2700     "Run $operation for Git, SVN, CVS or Mercurial in a configured workdir. "
2701     . "backend is explicit; files contains only paths within that workdir, not options. "
2702     . "Uses a dedicated permission action and fixed grants; CVS also needs home-read and network. "
2703     . "Returns the normal command exit/signal, bounded stdout/stderr and truncation flags. "
2704     . "grep filters stdout using PCRE after the 1 MiB capture cap; max_lines/max_bytes limit returned output. "
2705     . "Clients and their local configuration are trusted; the sandbox enforces the approved filesystem/network grants.",
2706     {
2707     type => "object",
2708     properties => {
2709     workdir => $workdir_schema,
2710     backend => { type => "string", enum => [qw(git svn cvs hg)] },
2711     files => { type => "array", items => { type => "string" }, default => [] },
2712     %output_schema,
2713     },
2714     required => ["backend"],
2715     additionalProperties => JSON::XS::false,
2716     },
2717     sub {
2718     my ($req, $args) = @_;
2719 root 1.33
2720 root 1.45 my $backend = $args->{backend};
2721     defined $backend && !ref $backend && $backend =~ /\A(?:git|svn|cvs|hg)\z/
2722     or die "backend must be git, svn, cvs or hg\n";
2723     my $program = $vcs_program{$backend}
2724     or die "$backend executable is unavailable\n";
2725     my $output = fs_output_options $args;
2726     my ($workdir, $identity) = fs_workdir $args->{workdir};
2727     my $files = $args->{files} // [];
2728     ref $files eq "ARRAY" or die "files must be an array of filenames\n";
2729     my (@files, @paths);
2730     for my $file (@$files) {
2731     my $path = fs_path $workdir, $file;
2732     fs_inside $path, $workdir or die "VCS files must stay within the selected workdir\n";
2733     push @paths, $path;
2734     my $relative = $path eq $workdir ? "." : $workdir eq "/" ? substr ($path, 1) : substr ($path, length ($workdir) + 1);
2735     push @files, "./$relative";
2736     }
2737 root 1.1
2738 root 1.45 my @argv = $backend eq "git"
2739     ? ($program, "--no-pager", "--no-optional-locks", "--literal-pathspecs", "-c", "core.fsmonitor=false",
2740     $operation, ($operation eq "diff" ? qw(--no-ext-diff --no-textconv --no-color --ignore-submodules)
2741     : qw(--short --untracked-files=normal --ignore-submodules)),
2742     "--", @files)
2743     : $backend eq "svn"
2744     ? ($program, "--non-interactive", "--no-auth-cache", $operation,
2745     ($operation eq "diff" ? ("--internal-diff") : ()), "--", map "$_\@", @files)
2746     : $backend eq "cvs"
2747     ? ($program, "-f", $operation, ($operation eq "diff" ? ("-u") : ()), "--", @files)
2748     : ($program, "--noninteractive", "--config", "ui.paginate=never", $operation, "--", @files);
2749    
2750     my @need = fs_effective_grants ("workdir-read", "home-read", ($backend eq "cvs" ? ("network") : ()));
2751     my $granted = fs_require_permission ($req, $identity, {
2752     tool => $req->{tool}, action => "vcs-$operation",
2753     workdir => fs_grant_workdir ($identity, \@need),
2754     subject => $backend, subjects => [
2755     { label => "$backend working copies", subject => $backend },
2756     { label => "all supported VCS clients", subject => "^" },
2757     ],
2758     sandbox_grants => \@need, paths => \@paths,
2759     reason => "$backend $operation in $identity" . (@files ? ": " . join (", ", @files) : " (entire working copy)"),
2760     });
2761     my $have = $permissions->closure ($granted);
2762     $have->{$_} or die "permission reduced below $_ for $req->{tool}\n" for @need;
2763 root 1.1
2764 root 1.45 fs_command $req, $workdir, $granted, \@argv, "", 30, $output;
2765 root 1.1 };
2766 root 1.45 }
2767 root 1.1
2768     mcp_config
2769 root 1.25 name => $name,
2770     idle_exit => 300,
2771 root 1.1 server_info => { name => "mcp-fs", version => "0" },
2772     permissions => $permissions,
2773 root 1.25 on_error => sub { AE::log debug => "mcp-tool $_[1]{tool}: $_[2]" },
2774     on_start => sub {
2775 root 1.2 print STDERR "Permission rules: $permissions->{file}\n"
2776     if $_[0]->isa ("AnyEvent::MCP::Server::HTTP");
2777     },
2778 root 1.45 instructions => "Use workdirs to inspect configured workdirs, mounts and sandbox_grants. Select workdir by zero-based index, numeric string, or exact configured/resolved path; stale paths fail. Paths are relative to the selected workdir; workdir-read/write expose only that workdir. all-workdirs-read/write expose every configured workdir; all-workdirs-read plus workdir-write allows writing only the selected workdir. Absolute paths in another configured workdir require all-workdirs authority; other absolute paths request home/world authority or shared-tmp for host tmp. Multi-path stat must stay within the selected workdir; rename may target another path but requires the combined sandbox grants and can fail with EXDEV at a mount boundary. Symlinks resolve inside the granted sandbox mounts; overlapping workdirs have unspecified access behavior. Trusted mount entries add baseline authority independent of requested grants; world-read retains writable exceptions, world-write ignores them. Exec supports explicit sandbox_grants and requires a useful reason. Prefer vcs-diff/vcs-status with an explicit backend for working-copy inspection; these use fixed argv and automatically request needed grants. Exec and VCS tools share grep/max_lines/max_bytes output options; stderr is unfiltered and capture_truncated means matches beyond the capture cap are unknown.\n";
2779 root 1.1
2780 root 1.13 $stdio ? mcp_stdio : mcp_start %server_options;
2781 root 1.3