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