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