ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/bin/mcp-fs
Revision: 1.5
Committed: Thu Oct 8 14:18:33 2026 UTC (13 days, 2 hours ago) by root
Branch: MAIN
Changes since 1.4: +10 -1 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 #!/opt/bin/perl
2    
3     =head1 NAME
4    
5     mcp-fs - sandboxed filesystem and command tools
6    
7     =head1 SYNOPSIS
8    
9     mcp-fs --stdio /path/to/workdir
10     mcp-fs --name project --port 31741 /path/to/workdir
11    
12     =head1 DESCRIPTION
13    
14     Uses L<AnyEvent::MCP::Server::Simple> to expose filesystem tools and arbitrary
15     commands through bubblewrap. GNU find, grep, patch and sed and a compatible
16     Perl are detected at startup; tools requiring an unavailable utility are
17     omitted. Bubblewrap is required.
18    
19     HTTP is the default and prints a cookie URL. The same token can be used as
20     a bearer token at the clean URL. C<--stdio> or
21     C<PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL=stdio> selects stdin/stdout.
22     See L<AnyEvent::MCP/GLOBAL CONFIGURATION> for named HTTP settings.
23    
24     =head1 OPTIONS
25    
26     =over
27    
28     =item --name NAME
29    
30     Selects named server configuration and authentication. Defaults to
31     C<PERL_ANYEVENT_MCP_MULTIBROKER_ALIAS>, then C<mcp-fs>.
32    
33     =item --stdio
34    
35     Uses stdin/stdout instead of starting an HTTP listener.
36    
37     =item --host ADDRESS
38    
39     Overrides the HTTP listener address.
40    
41     =item --port PORT
42    
43     Overrides the HTTP listener port; zero selects an available port.
44    
45     =item --profile NAME
46    
47     Selects the permission rule file
48     C<$XDG_CONFIG_HOME/anyevent-mcp/NAME.ini>, or
49     C<$HOME/.config/anyevent-mcp/NAME.ini>. Defaults to
50     C<PERL_ANYEVENT_MCP_MULTIBROKER_ALIAS>, then C<default>.
51     This retains the original mcp-fs filenames and allowance rules.
52     The rules path is printed on stderr when starting HTTP, alongside the URL
53     on stdout.
54    
55 root 1.4 =item --ask $method
56    
57     Selects permission prompting: C<helper> (default), C<mcp>, or C<auto>.
58     The default keeps approval local. C<mcp> delegates to client form elicitation;
59     C<auto> uses elicitation when advertised and otherwise the local helper.
60    
61 root 1.1 =item --bwrap PATH
62    
63     Selects the bubblewrap executable. Defaults to C<bwrap> in C<PATH>.
64    
65     =item --runtime-path PATH
66    
67     Adds a baseline runtime mount. Repeating this option replaces the default
68     runtime paths. These paths supply executables and libraries inside the
69     sandbox, independently of requested filesystem capabilities.
70 root 1.5 Defaults also include alternatives, font configuration, local timezone,
71     TLS configuration and certificate trust stores, NSS configuration and DNS
72     configuration where present. They do not include C</etc/passwd> or C</etc/group>.
73 root 1.1
74     =item --host-export PATH
75    
76     Adds a path exposed by the C<system-read> capability. Can be repeated.
77    
78     =back
79    
80     =head1 PERMISSIONS
81    
82     L<AnyEvent::MCP::Server::Permissions> applies ordered allow, deny and ask
83 root 1.4 rules. Each tool checks permissions before starting its worker. Approval
84     defaults to the local helper selected by C<PERL_ANYEVENT_MCP_PERMISSION_HELPER>,
85     which tries YAD, then a terminal, then denies once. Client elicitation requires
86     explicit selection with C<--ask mcp> or C<--ask auto>.
87 root 1.1
88     You can edit the rule file at any time. It is reread on permission checks,
89     so changes apply without restarting the server. Keep it owned by your user
90     and private (mode 0600). See L<AnyEvent::MCP::Server::Permissions> for the
91     rule syntax and ordering.
92    
93     Permission retries retain earlier one-time approvals within the same call.
94     For one worker accessing several paths, every path is checked against the
95     capabilities needed by that worker. Its sandbox uses the intersection of
96     the granted capability closures. An insufficient reduction fails before
97     the worker starts.
98    
99 root 1.2 Exec defaults to a 30-second timeout and permits up to 300 seconds without
100     additional timeout authority. A larger explicit C<timeout>, up to 21600
101     seconds (six hours), also requires C<long-running>. This capability is added
102     to the requested permissions automatically. Cancellation still terminates
103     the worker, regardless of its allowed timeout.
104    
105 root 1.1 The C<host-proc> capability remains unavailable. Runtime mounts and a
106     read-only copy of C<AnyEvent/MCP.pm> provide sandbox machinery. Symlinks
107     resolve within the sandbox's granted mounts.
108    
109     =head1 PATCHES
110    
111     C<file-patch> uses L<AnyEvent::MCP/PATCH UTILITIES> for normalization and
112     validation. GNU patch runs inside the granted sandbox with fuzz zero.
113     Successful output replaces the original by rename after checking its
114     identity, size, timestamps, metadata and link count. These stat checks
115     do not detect every concurrent edit; they are not a filesystem transaction.
116    
117     =cut
118 root 1.2
119 root 1.1 use common::sense;
120    
121     use MIME::Base64 ();
122     use Getopt::Long ();
123     use Cwd ();
124     use POSIX ();
125    
126     use JSON::XS ();
127     use AnyEvent ();
128     use AnyEvent::MCP::Server::Permissions ();
129     use AnyEvent::Handle ();
130     use AnyEvent::Log ();
131     use AnyEvent::Util ();
132    
133     use AnyEvent::MCP::Server::Simple;
134    
135     sub utf8enc($) {
136     my $text = shift;
137     utf8::encode $text;
138     $text
139     }
140    
141     sub utf8dec($) {
142     my $bytes = shift;
143     utf8::decode $bytes
144     or die "invalid UTF-8\n";
145     $bytes
146     }
147    
148     # Resolve an executable name or path to an absolute executable file.
149     sub find_executable {
150     my ($name) = @_;
151     !ref $name and length $name and $name !~ /\0/
152     or return;
153    
154     my @path = $name =~ m{/}
155     ? ($name)
156     : map { (length $_ ? $_ : ".") . "/$name" } split /:/, $ENV{PATH};
157    
158     for my $path (@path) {
159     next unless -f $path and -x _;
160     my $absolute = Cwd::abs_path $path;
161     return $absolute if length $absolute;
162     }
163    
164     undef
165     }
166    
167     my %program_test = (
168     find => [[qw(--version)], qr/^find \(GNU findutils\)/m],
169     grep => [[qw(--version)], qr/^grep \(GNU grep\)/m],
170     patch => [[qw(--version)], qr/^GNU patch /m],
171     perl => [["-e", "use 5.008004"], qr/\A\z/],
172     sed => [["-n", "1p", "/dev/null"], qr/\A\z/],
173     );
174    
175     my %program;
176     for my $name (sort keys %program_test) {
177     my ($args, $match) = @{$program_test{$name}};
178     my $path = find_executable $name;
179     my $output = "";
180     my $ok;
181    
182     if ($path and open my $fh, "-|", $path, @$args) {
183     local $/;
184     $output = <$fh>;
185     $ok = close $fh;
186     }
187    
188     $program{$name} = $path
189     if $ok and $output =~ $match;
190     warn "$name: not detected or wrong version\n"
191     unless $program{$name};
192     }
193    
194     my ($find, $grep, $patch, $perl, $sed) = @program{qw(find grep patch perl sed)};
195    
196     Getopt::Long::Configure qw(bundling no_ignore_case require_order);
197    
198     my $bwrap = "bwrap";
199 root 1.4 my $usage = "usage: $0 [--name NAME] [--stdio] [--host ADDRESS] [--port PORT] [--profile NAME] [--ask helper|mcp|auto] [--bwrap PATH] [--runtime-path PATH ...] [--host-export PATH ...] WORKDIR [WORKDIR ...]\n";
200 root 1.1
201     Getopt::Long::GetOptions
202 root 1.4 "name=s" => \my $name,
203     "stdio" => \my $stdio,
204     "host=s" => \my $host,
205     "port=i" => \my $port,
206     "profile=s" => \my $profile,
207     "ask=s" => \my $ask,
208     "help|h" => \my $help,
209 root 1.1 "bwrap=s" => \$bwrap,
210 root 1.4 "runtime-path=s@" => \my @runtime_path,
211     "host-export=s@" => \my @host_export,
212 root 1.1 or die $usage;
213    
214 root 1.4 die "--ask must be helper, mcp, or auto\n"
215     if defined $ask && $ask !~ /\A(?:helper|mcp|auto)\z/;
216    
217 root 1.1 if ($help) {
218     print $usage;
219     exit;
220     }
221    
222     $name //= $ENV{PERL_ANYEVENT_MCP_MULTIBROKER_ALIAS} // "mcp-fs";
223     $profile //= $ENV{PERL_ANYEVENT_MCP_MULTIBROKER_ALIAS} // "default";
224     $profile =~ /\A[A-Za-z0-9][A-Za-z0-9_.-]*\z/ and @ARGV
225     or die $usage;
226    
227     $bwrap = find_executable $bwrap
228     // die "bubblewrap executable not found: $bwrap\n";
229    
230 root 1.5 @runtime_path = grep -e, qw(
231     /usr /bin /sbin /lib /lib64 /opt
232     /etc/ld.so.cache /etc/alternatives /etc/fonts /etc/localtime
233     /etc/ssl/openssl.cnf /etc/ssl/certs
234     /etc/pki/tls/openssl.cnf /etc/pki/tls/certs /etc/pki/ca-trust
235     /etc/nsswitch.conf /etc/hosts /etc/resolv.conf
236     )
237 root 1.1 unless @runtime_path;
238     for my $path (@runtime_path) {
239     $path =~ m{\A/} and -e $path or die "invalid runtime path '$path'\n";
240     }
241     for my $path (@host_export) {
242     $path =~ m{\A/} and -e $path or die "invalid host export '$path'\n";
243     }
244    
245     my @workdir;
246     for my $path (@ARGV) {
247     my $workdir = Cwd::abs_path $path;
248     -d $workdir or die "invalid workdir '$path'\n";
249     utf8::decode $workdir
250     or die "workdir is not UTF-8\n";
251     push @workdir, $workdir;
252     }
253    
254     my $home = $ENV{HOME};
255     $home =~ m{\A/} and -d $home
256     or die "HOME must name an absolute existing directory\n";
257     $home = Cwd::abs_path $home
258     // die "cannot resolve HOME\n";
259     utf8::decode $home
260     or die "HOME is not a valid UTF-8 path\n";
261    
262     my $config_home = $ENV{XDG_CONFIG_HOME};
263     if (length $config_home) {
264     $config_home =~ m{\A/}
265     or die "permission config directory must be absolute\n";
266     utf8::decode $config_home
267     or die "permission config directory is not UTF-8\n";
268     } else {
269     $config_home = "$home/.config";
270     }
271    
272     my %capability = (
273     "workdir-read" => [],
274     "workdir-write" => ["workdir-read"],
275     "system-read" => [],
276     "home-read" => [],
277     "home-write" => ["home-read"],
278     "world-read" => [qw(workdir-read system-read home-read)],
279     "world-write" => [qw(world-read workdir-write home-write)],
280     "network" => [],
281 root 1.2 "long-running" => [],
282 root 1.1 "shared-tmp" => [],
283     "host-proc" => [],
284     );
285    
286     my @exec_default_capabilities = ("workdir-read");
287    
288     my $umask = umask;
289    
290     # Keep the existing profile filename so saved rules survive the migration.
291     my $permissions = AnyEvent::MCP::Server::Permissions->new (
292     capabilities => \%capability,
293 root 1.4 (defined $ask ? (ask => $ask) : ()),
294 root 1.1 name => $profile,
295     file => "$config_home/anyevent-mcp/$profile.ini",
296     );
297    
298     # The patch worker loads the shared validator inside its sandbox.
299     my $mcp_module = Cwd::abs_path $INC{"AnyEvent/MCP.pm"}
300     or die "cannot resolve AnyEvent::MCP runtime path\n";
301    
302     # Normalize an API pathname lexically against its selected workdir.
303     sub fs_path {
304     my ($workdir, $path) = @_;
305    
306     !ref $path and length $path and $path !~ /\0/
307     or die "path must be a nonempty string without NUL\n";
308     $path = "$workdir/$path" unless $path =~ m{\A/};
309    
310     my @part;
311    
312     for (split m{/+}, $path) {
313     next if !length or $_ eq ".";
314     if ($_ eq "..") {
315     pop @part;
316     } else {
317     push @part, $_;
318     }
319     }
320    
321     "/" . join "/", @part
322     }
323    
324     # Test pathname containment without resolving symlinks.
325     sub fs_inside {
326     my ($path, $directory) = @_;
327    
328     $directory eq "/" or $path eq $directory or (index $path, "$directory/") == 0
329     }
330    
331     # Map a pathname and access mode to the narrowest filesystem capability.
332     sub fs_path_capability {
333     my ($path, $mode) = @_;
334    
335     return "workdir-$mode"
336     if grep { fs_inside $path, $_ } @workdir;
337     return "home-$mode" if fs_inside $path, $home;
338     return "shared-tmp" if (fs_inside $path, "/tmp") or (fs_inside $path, "/var/tmp");
339    
340     "world-$mode"
341     }
342    
343     # Offer persistent permission scopes for one concrete pathname.
344     sub fs_subjects {
345     my ($workdir, $path) = @_;
346    
347     my $directory = $path =~ s{/[^/]*\z}{}r || "/";
348     my $prefix = $directory eq "/" ? "/" : "$directory/";
349     my $quote = sub {
350     my $text = quotemeta $_[0];
351     $text =~ s/\\(\s)/sprintf "\\x{%x}", ord $1/ge;
352     $text
353     };
354     my $exact = $path =~ /[\r\n]|\s\z/ ? "^" . $quote->($path) . "\\z" : $path;
355     my @choices = (
356     { label => "exact path", subject => $exact },
357     { label => "containing directory", subject => "^" . $quote->($prefix) . "[^/]*\\z" },
358     { label => "directory tree", subject => "^" . $quote->($prefix) },
359     );
360    
361     push @choices, { label => "workdir", subject => "^" . $quote->($workdir) . ($workdir eq "/" ? "" : "(?:/|\\z)") }
362     if (fs_inside $path, $workdir);
363    
364     push @choices, { label => "all paths", subject => "^/" };
365    
366     \@choices
367     }
368    
369     # One worker needs authority allowed for every path it will access.
370     sub fs_require_paths {
371     my ($req, $workdir, $paths, $mode, $reason) = @_;
372    
373     my %need = map { (fs_path_capability $_, $mode) => 1 } @$paths;
374     my ($have, %seen);
375    
376     for my $path (@$paths) {
377     next if $seen{$path}++;
378     my $grant = $req->require_permission ({
379     tool => $req->{tool},
380     subject => $path,
381     subjects => (fs_subjects $workdir, $path),
382     capabilities => [sort keys %need],
383     reason => $reason // "$req->{tool} $path",
384     });
385    
386     my $allowed = $permissions->closure ($grant);
387     if ($have) {
388     delete @$have{grep !$allowed->{$_}, keys %$have};
389     } else {
390     $have = $allowed;
391     }
392     }
393    
394     for my $capability (sort keys %need) {
395     $have->{$capability}
396     or die "permission reduced below $capability for $req->{tool}\n";
397     }
398    
399     [sort keys %$have]
400     }
401    
402     # Test whether the granted capability set makes this pathname writable.
403     sub fs_writable {
404     my ($path, $have) = @_;
405    
406     $have->{"world-write"}
407     or ($have->{"workdir-write"} and (grep { fs_inside $path, $_ } @workdir))
408     or ($have->{"home-write"} and (fs_inside $path, $home))
409     or ($have->{"shared-tmp"} and ((fs_inside $path, "/tmp") or (fs_inside $path, "/var/tmp")))
410     }
411    
412     # Build the bubblewrap argv implementing the granted sandbox authority.
413     sub fs_sandbox {
414     my ($workdir, $capabilities) = @_;
415     my $have = $permissions->closure ($capabilities);
416     die "host-proc sandbox authority is unresolved and unavailable in v1\n"
417     if $have->{"host-proc"};
418    
419     my @command = ($bwrap, qw(
420     --die-with-parent --new-session
421     --unshare-user --unshare-pid --unshare-ipc --unshare-uts
422     --unshare-cgroup-try --disable-userns --cap-drop ALL
423     ));
424    
425     push @command, "--unshare-net" unless $have->{network};
426    
427     push @command, $have->{"world-write"} ? "--bind-try" : "--ro-bind-try", "/", "/"
428     if $have->{"world-read"};
429    
430     my @scope_mount;
431    
432     for my $path (@runtime_path) {
433     my $mount = (fs_writable $path, $have) ? "--bind-try" : "--ro-bind-try";
434     push @scope_mount, $mount, $path, $path;
435     }
436    
437     if ($have->{"system-read"}) {
438     for my $path (@host_export) {
439     my $mount = (fs_writable $path, $have) ? "--bind-try" : "--ro-bind-try";
440     push @scope_mount, $mount, $path, $path;
441     }
442     }
443    
444     if ($have->{"home-read"}
445     and (!$have->{"world-read"} or ($have->{"home-write"} and !$have->{"world-write"}))) {
446     my $mount = (fs_writable $home, $have) ? "--bind-try" : "--ro-bind-try";
447     push @scope_mount, $mount, $home, $home;
448     }
449    
450     if ($have->{"workdir-read"}) {
451     for my $path (@workdir) {
452     next
453     if $have->{"world-read"}
454     and (!$have->{"workdir-write"} or $have->{"world-write"});
455     next
456     if fs_inside $path, $home
457     and $have->{"home-read"}
458     and (!$have->{"workdir-write"} or $have->{"home-write"});
459     my $mount = (fs_writable $path, $have) ? "--bind-try" : "--ro-bind-try";
460     push @scope_mount, $mount, $path, $path;
461     }
462     }
463    
464     # Replace special host mounts even when broad filesystem authority was asked.
465     push @command, qw(--tmpfs /sys --tmpfs /run --dev /dev);
466     push @command, "--proc", "/proc";
467    
468     for my $tmp (qw(/tmp /var/tmp)) {
469     if ($have->{"shared-tmp"}) {
470     push @command, "--bind-try", $tmp, $tmp;
471     } else {
472     push @command, "--tmpfs", $tmp;
473     }
474     }
475    
476     # Explicit mounts override the private baseline where bwrap/kernel permit it.
477     push @command, @scope_mount;
478     push @command, "--ro-bind", $mcp_module, "/run/anyevent-mcp.pm";
479    
480     for my $path (qw(/etc/resolv.conf /etc/hosts)) {
481     my $mount = (fs_writable $path, $have) ? "--bind-try" : "--ro-bind-try";
482     push @command, $mount, $path, $path;
483     }
484    
485     push @command, "--remount-ro", "/"
486     unless $have->{"world-write"};
487    
488     my $sandbox_path = $ENV{PATH} // "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin";
489    
490     push @command,
491     "--clearenv",
492     "--setenv", "PATH", $sandbox_path,
493     "--setenv", "LC_ALL", "C.UTF-8",
494     "--setenv", "HOME", $home,
495     "--chdir", $have->{"workdir-read"} ? $workdir : "/tmp",
496     "--";
497    
498     @command
499     }
500    
501     AnyEvent::detect; # proc_spawn creates child watchers after they might have died
502    
503     # Spawn one process with a bidirectional stdin/stdout handle and separate stderr.
504     sub proc_spawn {
505     my ($program, @argv) = @_;
506    
507     my ($parent, $child) = AnyEvent::Util::portable_socketpair
508     or die "socketpair: $!\n";
509     my ($error, $child_error) = AnyEvent::Util::portable_pipe
510     or die "pipe: $!\n";
511    
512     my $pid = fork;
513     defined $pid or die "fork: $!\n";
514    
515     unless ($pid) {
516     open STDIN, "<&", $child or POSIX::_exit 126;
517     open STDOUT, ">&", $child or POSIX::_exit 126;
518     open STDERR, ">&", $child_error or POSIX::_exit 126;
519    
520     close $parent;
521     close $error;
522     close $child;
523     close $child_error;
524    
525     AnyEvent::Util::close_all_fds_except 0, 1, 2;
526    
527     exec {$program} $program, @argv;
528     print STDERR "exec $program: $!\n";
529     POSIX::_exit 126;
530     }
531    
532     close $child;
533     close $child_error;
534    
535     my $io = AnyEvent::Handle->new (fh => $parent);
536     my $err = AnyEvent::Handle->new (fh => $error);
537     my $proc = { pid => $pid, done => [] };
538    
539     # Keep the watcher alive independently so a fatal I/O error still reaps.
540     $proc->{child} = AE::child $pid, sub {
541     my $status = $_[1];
542    
543     $proc->{status} = $status;
544     delete $proc->{pid};
545     delete $proc->{child};
546     $_->($status) for @{delete $proc->{done}};
547     };
548    
549     ($io, $err, $proc)
550     }
551    
552     # Wrap proc_spawn in one self-owned AnyEvent::Handle. destroy releases all
553     # attached process state, including the self-reference that keeps it alive.
554     sub proc_handle {
555     my (@command) = @_;
556    
557     my ($io, $err, $proc) = proc_spawn @command;
558    
559     $io->{proc} = $proc;
560     $io->{proc_err} = $err;
561     $io->{proc_self} = $io;
562    
563     $io
564     }
565    
566     # Signal a spawned process while its pid is still owned by us.
567     sub proc_kill {
568     my ($proc, $signal) = @_;
569    
570     my $pid = $proc->{pid} or return;
571    
572     $signal //= "KILL";
573     kill $signal => $pid
574     }
575    
576     # Run a callback once a spawned process has been reaped.
577     sub proc_on_exit {
578     my ($proc, $done) = @_;
579    
580     return $done->($proc->{status})
581     if defined $proc->{status};
582    
583     push @{$proc->{done}}, $done;
584     }
585    
586     # Run one subprocess with bounded capture, timeout and sandbox teardown.
587     sub fs_run {
588     my ($req, $command, $input, $timeout, $byte_limit, $line_limit, $done) = @_;
589    
590     my ($truncated, $line_truncated, $timed_out);
591     my ($stdout, $stderr) = ("", "");
592    
593     my $capture = sub {
594     my ($target, $data, $lines) = @_;
595     return if $lines and $line_truncated;
596     return unless length $data;
597     my $remaining = $byte_limit - (length $stdout) - (length $stderr);
598     $truncated = 1 if length $data > $remaining;
599     $$target .= substr $data, 0, $remaining
600     if $remaining > 0;
601    
602     if ($lines and $line_limit and ($$target =~ tr/\n//) > $line_limit) {
603     my $end = -1;
604     $end = index $$target, "\n", $end + 1
605     for 1 .. $line_limit;
606     substr $$target, $end + 1, length $$target, "";
607     $truncated = $line_truncated = 1;
608     return 1
609     }
610    
611     0
612     };
613    
614     my $io = proc_handle @$command;
615     my $err = $io->{proc_err};
616     my $proc = $io->{proc};
617     $req->{on_cancel} = sub { proc_kill $proc };
618     my $open = 2;
619     my ($status, $finished, $timer);
620    
621     my $complete = sub {
622     return
623     if $finished or $open or !defined $status;
624     $finished = 1;
625     undef $timer;
626     $io->destroy;
627     return if $req->{finished};
628     delete $req->{on_cancel};
629    
630     $done->(1, +{
631     stdout => $stdout,
632     stderr => $stderr,
633     exit => $status >> 8,
634     signal => $status & 127,
635     timed_out => $timed_out ? JSON::XS::true : JSON::XS::false,
636     truncated => $truncated ? JSON::XS::true : JSON::XS::false,
637     });
638     };
639    
640     for my $stream ([$io, \$stdout, 1], [$err, \$stderr, 0]) {
641     my ($handle, $target, $lines) = @$stream;
642     my $stream_done;
643     my $finish = sub {
644     return if $stream_done++;
645     --$open;
646     $complete->();
647     };
648    
649     $handle->on_read (sub {
650     proc_kill $proc
651     if $capture->($target, $_[0]{rbuf}, $lines);
652     $_[0]{rbuf} = "";
653     });
654     $handle->on_eof (sub {
655     $finish->();
656     });
657     $handle->on_error (sub {
658     return unless $_[1];
659     proc_kill $proc;
660     $capture->($target, $_[0]{rbuf}, $lines);
661     $_[0]{rbuf} = "";
662     $finish->();
663     });
664     }
665    
666     my $ok = eval {
667     $io->push_write ($input) if length $input;
668     $io->push_shutdown;
669     1
670     };
671     unless ($ok) {
672     proc_kill $proc;
673     $io->destroy;
674     die $@;
675     }
676    
677     $timer = AE::timer $timeout, 0, sub {
678     $timed_out = 1;
679     proc_kill $proc;
680     };
681    
682     proc_on_exit $proc, sub {
683     $status = $_[0];
684     $complete->();
685     };
686     }
687    
688     my $perl_slave = q{#perl
689     binmode STDIN;
690     binmode STDOUT;
691     $SIG{PIPE} = "IGNORE";
692     $| = 1;
693    
694     for (;;) {
695     4 == read STDIN, my $buf, 4
696     or last;
697     my $len = unpack "N", $buf;
698     $len == read STDIN, $buf, $len
699     or die "short perl frame\n";
700    
701     my @result = do {
702     (my $source, local @ARGV) = unpack "(w/a*)*", $buf;
703     ("+", eval $source)
704     };
705    
706     @result = ("-", $@)
707     if $@;
708    
709     print pack "N/a*", pack "(w/a*)*", @result
710     or die "stdout: $!\n"
711     }
712     };
713    
714     sub perl_frame {
715     pack "N/a*", pack "(w/a*)*", @_
716     }
717    
718     sub perl_unframe {
719     my ($data) = @_;
720     length $data >= 4
721     or die "short perl response\n";
722     my $len = unpack "N", $data;
723     length $data == 4 + $len
724     or die "invalid perl response length\n";
725    
726     unpack "x4 (w/a*)*", $data
727     }
728    
729     sub perl_eval {
730     my ($req, $workdir, $capabilities, $source, $args, $timeout, $limit, $done) = @_;
731     my @command = ((fs_sandbox $workdir, $capabilities), $perl, "-e", $perl_slave);
732    
733     fs_run $req, \@command, (perl_frame $source, @$args), $timeout, $limit, 0, sub {
734     my ($success, $result) = @_;
735     return $done->(0, $result)
736     unless $success;
737    
738     my (@response, $error);
739     my $ok = eval {
740     die "sandbox perl timed out\n" if $result->{timed_out};
741     die "sandbox perl output exceeded limit\n" if $result->{truncated};
742     if ($result->{exit} or $result->{signal}) {
743     my $stderr = utf8dec $result->{stderr};
744     die "sandbox perl failed (exit $result->{exit}, signal $result->{signal}): $stderr\n";
745     }
746    
747     @response = perl_unframe $result->{stdout};
748     my $status = shift @response;
749     defined $status or die "missing perl response status\n";
750     if ($status eq "-") {
751     $error = utf8dec $response[0];
752     } elsif ($status ne "+") {
753     die "invalid perl response status\n";
754     }
755     1
756     };
757    
758     return $done->(0, $@) unless $ok;
759     return $done->(0, $error) if defined $error;
760     $done->(1, \@response);
761     };
762     }
763    
764     # Resolve the optional workdir index to one configured workdir.
765     sub fs_workdir {
766     my ($i) = @_;
767    
768     $i = 0 if !defined $i and @workdir == 1;
769    
770     !ref $i and $i =~ /\A[0-9]+\z/ and $i < @workdir
771     or die "select a configured workdir by its zero-based index\n";
772    
773     $workdir[$i]
774     }
775    
776     # Wrap raw text as an MCP text result.
777     sub fs_text_result {
778     my ($text, $failed) = @_;
779    
780     my $response = {
781     content => [{
782     type => "text",
783     text => $text,
784     }],
785     };
786    
787     $response->{isError} = JSON::XS::true if $failed;
788    
789     $response
790     }
791    
792     # Wrap a Perl value as an MCP text result.
793     sub fs_result {
794     my ($result, $failed) = @_;
795     my $text = JSON::XS->new->encode ($result);
796     my $response = fs_text_result $text, $failed;
797     $response->{structuredContent} = $result;
798     $response
799     }
800    
801     mcp_tool workdirs =>
802     "List configured workdirs and capability/runtime configuration.",
803     {
804     type => "object",
805     properties => {},
806     additionalProperties => JSON::XS::false,
807     },
808     sub {
809     my ($req, $args) = @_;
810    
811     AE::log debug => "workdirs";
812    
813     $req->reply (fs_result +{
814     workdirs => \@workdir,
815     umask => sprintf ("%04o", $umask),
816     runtime_paths => \@runtime_path,
817     host_exports => \@host_export,
818     capabilities => \%capability,
819     exec_default_capabilities => \@exec_default_capabilities,
820     unavailable_capabilities => ["host-proc"],
821     });
822     }
823     ;
824    
825     # Escape a pathname for compact line-oriented text output.
826     sub escape_path {
827     my ($text) = @_;
828    
829     $text =~ s/\\/\\\\/g;
830     $text =~ s/\n/\\n/g;
831     $text
832     }
833    
834     sub format_iec_number {
835     my ($number) = @_;
836     my @suffix = ("", qw(k M G T P));
837    
838     for my $suffix (@suffix) {
839     return "$number$suffix" if $number < 1024 or $suffix eq "P";
840     $number = int (($number + 512) / 1024);
841     }
842     }
843    
844     sub fs_find_relative {
845     my ($base, $path) = @_;
846    
847     return "" if $path eq $base;
848     substr $path, length ($base) + ($base eq "/" ? 0 : 1)
849     }
850    
851     sub format_path_listing {
852     my ($base, $record, $truncated) = @_;
853     my (@line, $directory);
854    
855     for my $record (sort { $a->[2] cmp $b->[2] } @$record) {
856     my ($type, $size, $full_path) = @$record;
857     my $relative = fs_find_relative $base, $full_path;
858    
859     if ($type eq "d") {
860     push @line, "/" . (escape_path $relative);
861     $directory = $relative;
862     next;
863     }
864    
865     my ($dir, $name);
866    
867     if (!length $relative) {
868     $name = $full_path =~ s{.*/}{}r;
869     } else {
870     ($dir, $name) = $relative =~ m{\A(?:(.*)/)?([^/]*)\z};
871     $dir //= "";
872    
873     if (!defined $directory or $directory ne $dir) {
874     push @line, "/" . (escape_path $dir);
875     $directory = $dir;
876     }
877     }
878    
879     $name = escape_path $name;
880     push @line, $type eq "f"
881     ? (join "\t", $type, (format_iec_number $size), $name)
882     : (join "\t", $type, $name);
883     }
884    
885     push @line, "!truncated" if $truncated;
886    
887     my $text = join "\n", @line;
888     $text .= "\n" if @line;
889     $text
890     }
891    
892     sub fs_list_find {
893     my ($req, $args, $recurse) = @_;
894     my $tool = $recurse ? "find" : "list";
895    
896     my $workdir = fs_workdir $args->{workdir};
897     my $path = fs_path $workdir, $args->{path} // ".";
898     my $limit = $args->{limit} // 200;
899     $limit <= 1000
900     or die "invalid limit\n";
901    
902     my $capability = fs_path_capability $path, "read";
903    
904     my $granted = $req->require_permission ({
905     tool => $tool,
906     subject => $path,
907     subjects => (fs_subjects $workdir, $path),
908     capabilities => [$capability],
909     reason => $args->{reason} // "$tool $path",
910     });
911    
912     if ($recurse) {
913     AE::log debug => join " ", $tool, $path, (substr $args->{name}, 0, 40);
914     } else {
915     AE::log debug => "$tool $path";
916     }
917    
918     my $find_path = utf8enc $path;
919     my @command = ((fs_sandbox $workdir, $granted), $find, $find_path);
920    
921     if ($recurse) {
922     my $needle = utf8enc $args->{name};
923     $needle =~ s/([\\*?\[])/\\$1/g;
924     push @command, "!", "-type", "d", "-name", "*$needle*";
925     } else {
926     push @command, "-maxdepth", "1";
927     }
928    
929     push @command, "-printf", q{%y\0%s\0%p\0};
930    
931     my (@record, $seen, $truncated, $timed_out, $failure);
932     my $stderr = "";
933     my $io = proc_handle @command;
934     my $err = $io->{proc_err};
935     my $proc = $io->{proc};
936     $req->{on_cancel} = sub { proc_kill $proc };
937     my $open = 2;
938     my ($stdout_done, $stderr_done, $status, $finished, $timer);
939    
940     my $complete = sub {
941     return
942     if $finished or $open or !defined $status;
943     $finished = 1;
944     undef $timer;
945     $io->destroy;
946     return if $req->{finished};
947     delete $req->{on_cancel};
948    
949     my $result;
950     my $ok = eval {
951     die "$tool timed out\n" if $timed_out;
952     die $failure if $failure;
953    
954     if (($status >> 8 or $status & 127) and !$truncated) {
955     my $error = eval { utf8dec $stderr };
956     $error = "non-UTF-8 error output" if $@;
957     die "$tool failed (exit " . ($status >> 8) . ", signal " . ($status & 127) . "): $error\n";
958     }
959    
960     $result = fs_text_result (format_path_listing $path, \@record, $truncated);
961     1
962     };
963    
964     if ($ok) {
965     $req->reply ($result);
966     } else {
967     $req->error ($@);
968     }
969     };
970    
971     my $finish = sub {
972     my ($stream_done) = @_;
973     return if $$stream_done++;
974     --$open;
975     $complete->();
976     };
977    
978     my $parse = sub {
979     my ($handle) = @_;
980    
981     while (!$failure and !$truncated
982     and $handle->{rbuf} =~ s/\A([^\0]*)\0([^\0]*)\0([^\0]*)\0//s) {
983     my ($type, $size, $raw_path) = ($1, $2, $3);
984     my $full_path = eval { utf8dec $raw_path };
985    
986     if ($@) {
987     $failure = "$tool returned a non-UTF-8 pathname\n";
988     proc_kill $proc;
989     last;
990     }
991    
992     if (++$seen > $limit) {
993     $truncated = 1;
994     proc_kill $proc;
995     last;
996     }
997    
998     push @record, [$type, $size, $full_path];
999     }
1000    
1001     $handle->{rbuf} = "" if $failure or $truncated;
1002     };
1003    
1004     $io->on_read (sub {
1005     $parse->($_[0]);
1006     });
1007     $io->on_eof (sub {
1008     $parse->($_[0]);
1009     $failure ||= "incomplete $tool output\n"
1010     if length $_[0]{rbuf} and !$truncated;
1011     $finish->(\$stdout_done);
1012     });
1013     $io->on_error (sub {
1014     return unless $_[1];
1015     $failure ||= "$tool stdout: $_[2]\n"
1016     unless $truncated or $timed_out;
1017     proc_kill $proc;
1018     $finish->(\$stdout_done);
1019     });
1020    
1021     my $capture_stderr = sub {
1022     my ($handle) = @_;
1023     my $remaining = 65536 - length $stderr;
1024     $stderr .= substr $handle->{rbuf}, 0, $remaining if $remaining > 0;
1025     $handle->{rbuf} = "";
1026     };
1027    
1028     $err->on_read (sub {
1029     $capture_stderr->($_[0]);
1030     });
1031     $err->on_eof (sub {
1032     $capture_stderr->($_[0]);
1033     $finish->(\$stderr_done);
1034     });
1035     $err->on_error (sub {
1036     return unless $_[1];
1037     $capture_stderr->($_[0]);
1038     $failure ||= "$tool stderr: $_[2]\n"
1039     unless $truncated or $timed_out;
1040     proc_kill $proc;
1041     $finish->(\$stderr_done);
1042     });
1043    
1044     my $ok = eval { $io->push_shutdown; 1 };
1045     unless ($ok) {
1046     my $error = $@;
1047     proc_kill $proc;
1048     $io->destroy;
1049     die $error;
1050     }
1051    
1052     $timer = AE::timer 30, 0, sub {
1053     $timed_out = 1;
1054     proc_kill $proc;
1055     };
1056    
1057     proc_on_exit $proc, sub {
1058     $status = $_[0];
1059     $complete->();
1060     };
1061     }
1062    
1063     $find && mcp_tool list =>
1064     "List immediate filesystem entries without following symlinks. "
1065     . "Compact text output uses /relative-directory headers for directories, f<TAB>approx-size<TAB>name "
1066     . "for regular files, TYPE<TAB>name for l/p/s/b/c/? nodes, "
1067     . "with compact size suffixes k/M/G/T/P using IEC powers of 1024, "
1068     . "and !truncated when the result limit was exceeded. Backslash and newline are escaped in names.",
1069     {
1070     type => "object",
1071     properties => {
1072     path => {
1073     type => "string",
1074     description => "Directory to list; defaults to the selected workdir.",
1075     },
1076     limit => {
1077     type => "integer",
1078     minimum => 1,
1079     maximum => 1000,
1080     default => 200,
1081     },
1082     workdir => {
1083     type => "integer",
1084     minimum => 0,
1085     description => "Configured workdir index; required when more than one workdir is configured.",
1086     },
1087     reason => {
1088     type => "string",
1089     description => "Reason for requesting permission.",
1090     },
1091     },
1092     additionalProperties => JSON::XS::false,
1093     },
1094     sub {
1095     fs_list_find $_[0], $_[1], 0
1096     }
1097     ;
1098    
1099     $find && mcp_tool find =>
1100     "Find non-directory filesystem entries recursively by literal basename substring without following symlinks. Same output format as list.",
1101     {
1102     type => "object",
1103     properties => {
1104     path => {
1105     type => "string",
1106     description => "Path to search recursively; defaults to the selected workdir.",
1107     },
1108     name => {
1109     type => "string",
1110     minLength => 1,
1111     description => "Literal substring of the basename to find.",
1112     },
1113     limit => {
1114     type => "integer",
1115     minimum => 1,
1116     maximum => 1000,
1117     default => 200,
1118     },
1119     workdir => {
1120     type => "integer",
1121     minimum => 0,
1122     description => "Configured workdir index; required when more than one workdir is configured.",
1123     },
1124     reason => {
1125     type => "string",
1126     description => "Reason for requesting permission.",
1127     },
1128     },
1129     required => ["name"],
1130     additionalProperties => JSON::XS::false,
1131     },
1132     sub {
1133     fs_list_find $_[0], $_[1], 1
1134     }
1135     ;
1136    
1137     my $file_read = q{#perl
1138     my ($path, $offset, $length) = @ARGV;
1139    
1140     open my $fh, "<:raw", $path or return 0, "open: $!";
1141     defined sysseek $fh, $offset, 0 or return 0, "seek: $!";
1142     my $n = sysread $fh, my $data, $length;
1143     defined $n or return 0, "read: $!";
1144    
1145     (1, $data)
1146     };
1147    
1148     $perl && mcp_tool file_read =>
1149     "Read up to 1 MiB by byte offset; returns either base64 data or an errno string.",
1150     {
1151     type => "object",
1152     properties => {
1153     path => {
1154     type => "string",
1155     },
1156     offset => {
1157     type => "integer",
1158     minimum => 0,
1159     default => 0,
1160     },
1161     length => {
1162     type => "integer",
1163     minimum => 1,
1164     maximum => 1<<20,
1165     default => 1<<16,
1166     },
1167     workdir => {
1168     type => "integer",
1169     minimum => 0,
1170     description => "Configured workdir index; required when more than one workdir is configured.",
1171     },
1172     reason => {
1173     type => "string",
1174     description => "Reason for requesting permission.",
1175     },
1176     },
1177     required => ["path"],
1178     additionalProperties => JSON::XS::false,
1179     },
1180     sub {
1181     my ($req, $args) = @_;
1182    
1183     my $workdir = fs_workdir $args->{workdir};
1184     my $offset = $args->{offset} // 0;
1185     my $length = $args->{length} // 65536;
1186     $length <= 1<<20
1187     or die "invalid read range\n";
1188     my $path = fs_path $workdir, $args->{path};
1189    
1190     my $capability = fs_path_capability $path, "read";
1191    
1192     my $granted = $req->require_permission ({
1193     tool => "file-read",
1194     subject => $path,
1195     subjects => (fs_subjects $workdir, $path),
1196     capabilities => [$capability],
1197     reason => $args->{reason} // "read $path",
1198     });
1199    
1200     AE::log debug => join " ", "file-read", $path, $offset, $length;
1201    
1202     perl_eval $req, $workdir, $granted, $file_read,
1203     [utf8enc $path, $offset, $length], 30, $length + 4096, sub {
1204     my ($success, $result) = @_;
1205     return $req->error ($result)
1206     unless $success;
1207    
1208     @$result == 2
1209     or return $req->error ("invalid file-read response\n");
1210     my ($ok, $value) = @$result;
1211     if ($ok eq "1") {
1212     $req->reply (fs_result {
1213     data => MIME::Base64::encode_base64 ($value, ""),
1214     });
1215     } elsif ($ok eq "0") {
1216     $req->reply (fs_result {
1217     error => utf8dec $value,
1218     });
1219     } else {
1220     $req->error ("invalid file-read status\n");
1221     }
1222     };
1223     }
1224     ;
1225    
1226     $sed && mcp_tool read_lines =>
1227     "Read UTF-8 text by positive line-number ranges. "
1228     . "Ranges are returned separately in request order; overlaps and duplicates are preserved.",
1229     {
1230     type => "object",
1231     properties => {
1232     path => {
1233     type => "string",
1234     },
1235     ranges => {
1236     type => "array",
1237     description => "Inclusive line ranges, each N or N,M.",
1238     items => {
1239     type => "string",
1240     pattern => q{^[1-9][0-9]*(?:,[1-9][0-9]*)?$},
1241     },
1242     minItems => 1,
1243     maxItems => 20,
1244     },
1245     workdir => {
1246     type => "integer",
1247     minimum => 0,
1248     description => "Configured workdir index; required when more than one workdir is configured.",
1249     },
1250     reason => {
1251     type => "string",
1252     description => "Reason for requesting permission.",
1253     },
1254     },
1255     required => ["path", "ranges"],
1256     additionalProperties => JSON::XS::false,
1257     },
1258     sub {
1259     my ($req, $args) = @_;
1260    
1261     my $workdir = fs_workdir $args->{workdir};
1262     my $path = fs_path $workdir, $args->{path};
1263     my @range = map {
1264     my ($start, $end) = /\A([1-9]\d*)(?:,([1-9]\d*))?\z/
1265     or die "invalid line range\n";
1266     $end //= $start;
1267     $start <= $end
1268     or die "invalid line range\n";
1269     [$_, $start, $end]
1270     } @{$args->{ranges}};
1271    
1272     my $capability = fs_path_capability $path, "read";
1273     my $granted = $req->require_permission ({
1274     tool => "read-lines",
1275     subject => $path,
1276     subjects => (fs_subjects $workdir, $path),
1277     capabilities => [$capability],
1278     reason => $args->{reason} // "read lines $path",
1279     });
1280    
1281     AE::log debug => join " ", "read-lines", $path, map $_->[0], @range;
1282    
1283     my (@result, $truncated);
1284     my $remaining = 1 << 20;
1285     my $next;
1286     $next = sub {
1287     unless (@range) {
1288     undef $next;
1289     my $text = "";
1290     for (@result) {
1291     my ($range, $body) = @$_;
1292     $text .= "[$range]\n$body";
1293     $text .= "\n"
1294     if length $body and substr ($body, -1) ne "\n";
1295     }
1296     $text .= "!truncated\n" if $truncated;
1297     return $req->reply (fs_text_result $text);
1298     }
1299    
1300     my ($range, $start, $end) = @{shift @range};
1301     my @command = (
1302     (fs_sandbox $workdir, $granted),
1303     $sed, "-n",
1304     "-e", ($start == $end ? "${start}p" : "${start},${end}p"),
1305     "-e", "${end}q",
1306     utf8enc $path,
1307     );
1308    
1309     fs_run $req, \@command, "", 30, $remaining, 0, sub {
1310     my ($success, $value) = @_;
1311     return $req->error ($value)
1312     unless $success;
1313    
1314     my $body;
1315     my $ok = eval {
1316     die "read-lines timed out\n" if $value->{timed_out};
1317     if ($value->{exit} or $value->{signal}) {
1318     my $stderr = utf8dec $value->{stderr};
1319     die "read-lines failed (exit $value->{exit}, signal $value->{signal}): $stderr\n";
1320     }
1321     $body = utf8dec $value->{stdout};
1322     1
1323     };
1324     return $req->error ($@)
1325     unless $ok;
1326    
1327     push @result, [$range, $body];
1328     $remaining -= length $value->{stdout};
1329     if ($value->{truncated}) {
1330     $truncated = 1;
1331     @range = ();
1332     } elsif ($remaining <= 0 and @range) {
1333     $truncated = 1;
1334     @range = ();
1335     }
1336     $next->();
1337     };
1338     };
1339    
1340     $next->();
1341     }
1342     ;
1343    
1344     my $file_write = q{#perl
1345     my ($path, $offset, $data) = @ARGV;
1346    
1347     use Fcntl qw(O_WRONLY O_CREAT);
1348     sysopen my $fh, $path, O_WRONLY | O_CREAT, 0666 or return 0, "open: $!";
1349    
1350     binmode $fh;
1351     defined sysseek $fh, $offset, 0 or return 0, "seek: $!";
1352     my $n = syswrite $fh, $data;
1353     defined $n or return 0, "write: $!";
1354    
1355     (1, "$n")
1356     };
1357    
1358     $perl && mcp_tool file_write =>
1359     "Write up to 1 MiB of base64 data at a byte offset without truncating; returns bytes written or an errno string.",
1360     {
1361     type => "object",
1362     properties => {
1363     path => {
1364     type => "string",
1365     },
1366     offset => {
1367     type => "integer",
1368     minimum => 0,
1369     default => 0,
1370     },
1371     data => {
1372     type => "string",
1373     },
1374     workdir => {
1375     type => "integer",
1376     minimum => 0,
1377     description => "Configured workdir index; required when more than one workdir is configured.",
1378     },
1379     reason => {
1380     type => "string",
1381     description => "Reason for requesting permission.",
1382     },
1383     },
1384     required => ["path", "data"],
1385     additionalProperties => JSON::XS::false,
1386     },
1387     sub {
1388     my ($req, $args) = @_;
1389    
1390     my $workdir = fs_workdir $args->{workdir};
1391     my $offset = $args->{offset} // 0;
1392     my $data = MIME::Base64::decode_base64 $args->{data};
1393     MIME::Base64::encode_base64 ($data, "") eq $args->{data}
1394     or die "invalid base64 data\n";
1395     length $data <= 1048576
1396     or die "write data exceeds 1 MiB\n";
1397     my $path = fs_path $workdir, $args->{path};
1398    
1399     my $capability = fs_path_capability $path, "write";
1400    
1401     my $granted = $req->require_permission ({
1402     tool => "file-write",
1403     subject => $path,
1404     subjects => (fs_subjects $workdir, $path),
1405     capabilities => [$capability],
1406     reason => $args->{reason} // "write $path",
1407     });
1408     $permissions->closure ($granted)->{$capability}
1409     or die "permission reduced below $capability for $path\n";
1410    
1411     AE::log debug => join " ", "file-write", $path, $offset, length ($data);
1412    
1413     perl_eval $req, $workdir, $granted, $file_write,
1414     [utf8enc $path, $offset, $data], 30, 4096, sub {
1415     my ($success, $result) = @_;
1416     return $req->error ($result)
1417     unless $success;
1418    
1419     @$result == 2
1420     or return $req->error ("invalid file-write response\n");
1421     my ($ok, $value) = @$result;
1422     if ($ok eq "1") {
1423     $value =~ /\A[0-9]+\z/
1424     or return $req->error ("invalid file-write byte count\n");
1425     $req->reply (fs_result {
1426     bytes => 0 + $value,
1427     });
1428     } elsif ($ok eq "0") {
1429     $req->reply (fs_result {
1430     error => utf8dec $value,
1431     });
1432     } else {
1433     $req->error ("invalid file-write status\n");
1434     }
1435     };
1436     }
1437     ;
1438    
1439     my $path_op = q{#perl
1440     while (@ARGV) {
1441     my $op = shift;
1442    
1443     if ($op eq "unlink") {
1444     my $path = shift;
1445     unlink $path
1446     or die "unlink $path: $!\n";
1447     } elsif ($op eq "rmdir") {
1448     my $path = shift;
1449     rmdir $path
1450     or die "rmdir $path: $!\n";
1451     } elsif ($op eq "mkdir") {
1452     my ($path, $mode) = splice @ARGV, 0, 2;
1453     $mode = oct $mode;
1454     mkdir $path, $mode
1455     or die "mkdir $path: $!\n";
1456     chmod $mode, $path
1457     or die "chmod $path: $!\n";
1458     } elsif ($op eq "symlink") {
1459     my ($target, $path) = splice @ARGV, 0, 2;
1460     symlink $target, $path
1461     or die "symlink $path: $!\n";
1462     } elsif ($op eq "truncate") {
1463     my ($path, $size) = splice @ARGV, 0, 2;
1464     truncate $path, $size
1465     or die "truncate $path: $!\n";
1466     } elsif ($op eq "chmod") {
1467     my ($path, $mode) = splice @ARGV, 0, 2;
1468     $mode = oct $mode;
1469     chmod $mode, $path
1470     or die "chmod $path: $!\n";
1471     } elsif ($op eq "rename") {
1472     my ($path, $to) = splice @ARGV, 0, 2;
1473     rename $path, $to
1474     or die "rename $path -> $to: $!\n";
1475     } else {
1476     die "invalid path operation '$op'\n";
1477     }
1478     }
1479    
1480     1
1481     };
1482    
1483     $perl && mcp_tool path_op =>
1484     "Perform pathname mutations in fixed order: unlink, rmdir, mkpath, mkdir, symlink, truncate, chmod, rename. "
1485     . "mkpath permission-checks each missing component separately; symlink targets are link contents, not authority.",
1486     {
1487     type => "object",
1488     properties => {
1489     path => {
1490     type => "string",
1491     },
1492     unlink => {
1493     type => "boolean",
1494     },
1495     rmdir => {
1496     type => "boolean",
1497     },
1498     mkdir => {
1499     description => "Create path as a directory with this final mode, overriding umask.",
1500     anyOf => [
1501     { type => "string", pattern => q{^[0-7]+$} },
1502     { type => "integer", minimum => 0 },
1503     ],
1504     },
1505     mkpath => {
1506     description => "Create missing path components with this final mode, overriding umask.",
1507     anyOf => [
1508     { type => "string", pattern => q{^[0-7]+$} },
1509     { type => "integer", minimum => 0 },
1510     ],
1511     },
1512     symlink => {
1513     type => "string",
1514     minLength => 1,
1515     description => "Create path as a symlink containing this target.",
1516     },
1517     truncate => {
1518     type => "integer",
1519     minimum => 0,
1520     },
1521     chmod => {
1522     anyOf => [
1523     { type => "string", pattern => q{^[0-7]+$} },
1524     { type => "integer", minimum => 0 },
1525     ],
1526     },
1527     rename => {
1528     type => "string",
1529     minLength => 1,
1530     description => "Rename path to this destination.",
1531     },
1532     workdir => {
1533     type => "integer",
1534     minimum => 0,
1535     description => "Configured workdir index; required when more than one workdir is configured.",
1536     },
1537     reason => {
1538     type => "string",
1539     description => "Reason for requesting permission.",
1540     },
1541     },
1542     required => ["path"],
1543     additionalProperties => JSON::XS::false,
1544     },
1545     sub {
1546     my ($req, $args) = @_;
1547    
1548     ($args->{unlink} or $args->{rmdir}
1549     or grep { exists $args->{$_} } qw(mkdir mkpath symlink truncate chmod rename))
1550     or die "no path operation requested\n";
1551    
1552     my $workdir = fs_workdir $args->{workdir};
1553     my $path = fs_path $workdir, $args->{path};
1554     my $rename = exists $args->{rename}
1555     ? fs_path $workdir, $args->{rename}
1556     : undef;
1557    
1558     my @mkpath;
1559     if (exists $args->{mkpath}) {
1560     my $prefix = "";
1561     for (split m{/}, substr $path, 1) {
1562     $prefix .= "/$_";
1563     push @mkpath, $prefix
1564     unless -d utf8enc $prefix;
1565     }
1566     }
1567    
1568     my $granted = fs_require_paths $req, $workdir,
1569     [@mkpath, $path, (defined $rename ? $rename : ())], "write", $args->{reason};
1570    
1571     my @log = map {
1572     ($_ eq "unlink" or $_ eq "rmdir")
1573     ? ($args->{$_} ? $_ : ())
1574     : (exists $args->{$_} ? "$_=$args->{$_}" : ())
1575     } qw(unlink rmdir mkpath mkdir symlink truncate chmod rename);
1576    
1577     AE::log debug => join " ", "path-op", $path, @log;
1578    
1579     my @operation;
1580     my $raw_path = utf8enc $path;
1581    
1582     push @operation, unlink => $raw_path
1583     if $args->{unlink};
1584     push @operation, rmdir => $raw_path
1585     if $args->{rmdir};
1586     push @operation, mkdir => (utf8enc $_), $args->{mkpath}
1587     for @mkpath;
1588     push @operation, mkdir => $raw_path, $args->{mkdir}
1589     if exists $args->{mkdir};
1590     push @operation, symlink => (utf8enc $args->{symlink}), $raw_path
1591     if exists $args->{symlink};
1592     push @operation, truncate => $raw_path, $args->{truncate}
1593     if exists $args->{truncate};
1594     push @operation, chmod => $raw_path, $args->{chmod}
1595     if exists $args->{chmod};
1596     push @operation, rename => $raw_path, (utf8enc $rename)
1597     if defined $rename;
1598    
1599     perl_eval $req, $workdir, $granted, $path_op,
1600     \@operation, 30, 4096, sub {
1601     my ($success, $result) = @_;
1602     return $req->error ($result)
1603     unless $success;
1604    
1605     @$result == 1 and $result->[0] eq "1"
1606     or return $req->error ("invalid path-op response\n");
1607     $req->reply (fs_text_result "ok\n");
1608     };
1609     }
1610     ;
1611    
1612     my $file_patch = q{#perl
1613     require "/run/anyevent-mcp.pm";
1614     use Fcntl qw(O_RDWR O_CREAT O_EXCL);
1615     use Errno qw(EEXIST ENAMETOOLONG);
1616    
1617     my ($patch, $path, $diff, @max_offset) = @ARGV;
1618    
1619     my $create_tempfile = sub {
1620     my ($name) = @_;
1621     utf8::decode $name
1622     or return undef, "invalid UTF-8 tempfile name";
1623    
1624     my $slash = rindex $name, "/";
1625     my $dir = $slash < 0 ? "" : substr $name, 0, $slash + 1;
1626     my $file = substr $name, $slash + 1;
1627    
1628     for (;;) {
1629     my $pathname = "$dir$file";
1630     utf8::encode $pathname;
1631    
1632     if (sysopen my $fh, $pathname, O_RDWR | O_CREAT | O_EXCL, 0600) {
1633     binmode $fh;
1634     return $fh, $pathname
1635     }
1636    
1637     if ($! == EEXIST) {
1638     $file .= "~";
1639     } elsif ($! == ENAMETOOLONG) {
1640     substr $file, (length $file) * .75, 2, "~";
1641     } else {
1642     return undef, "open tempfile: $!"
1643     }
1644     }
1645     };
1646    
1647     my $run_patch = sub {
1648     my ($newname, $path, $diff) = @_;
1649    
1650     my ($errfh, $errname) = $create_tempfile->("/tmp/mcp-fs~~patch~~");
1651     $errfh
1652     or return undef, $errname;
1653    
1654     local $ENV{LC_ALL} = "C";
1655     open my $write, "|-", "sh", "-c",
1656     q{exec "$4" --unified --verbose --binary --batch --forward --get=0 --fuzz=0 --reject-file=- --no-backup-if-mismatch -o "$2" "$1" >"$3" 2>&1},
1657     "--", $path, $newname, $errname, $patch
1658     or return undef, "open patch pipe: $!";
1659    
1660     my $write_error;
1661     {
1662     print $write $diff
1663     or $write_error = "write patch: $!";
1664     close $write;
1665     }
1666    
1667     my $status = $?;
1668    
1669     unlink $errname
1670     or return undef, "unlink patch stderr: $!";
1671     defined sysseek $errfh, 0, 0
1672     or return undef, "seek patch stderr: $!";
1673     my $output = do { local $/; <$errfh> };
1674     $output = "" unless defined $output;
1675     $output =~ s/\s+\z//;
1676    
1677     return undef, $write_error
1678     if $write_error and !$status;
1679    
1680     $status, $output
1681     };
1682    
1683     my $replace_file = sub {
1684     my ($path, $before, $newfh, $newname) = @_;
1685    
1686     chown $before->[4], $before->[5], $newfh;
1687     chmod $before->[2], $newfh;
1688    
1689     my @after = lstat $path;
1690     @after
1691     or return undef, "lstat: $!";
1692     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])
1693     or return undef, "file changed while applying patch";
1694     my @new = stat $newfh;
1695     $after[3] == 1
1696     or return undef, "refusing to replace multiply-linked file";
1697     join ("\0", @after[2, 4, 5]) eq join ("\0", @new[2, 4, 5])
1698     or return undef, "cannot preserve file metadata";
1699    
1700     rename $newname, $path
1701     or return undef, "rename: $!";
1702    
1703     $new[7], undef
1704     };
1705    
1706     my @before = lstat $path;
1707     @before
1708     or return 0, "lstat: $!";
1709     -f _
1710     or return 0, "not a regular file";
1711    
1712     my ($newfh, $newname) = $create_tempfile->("$path~~new~~");
1713     $newfh
1714     or return 0, $newname;
1715    
1716     my ($status, $output) = $run_patch->($newname, $path, $diff);
1717     unless (defined $status) {
1718     unlink $newname;
1719     return 0, $output
1720     }
1721    
1722     unless (eval { AnyEvent::MCP::check_patch_output ($status, $output, \@max_offset); 1 }) {
1723     my $error = $@;
1724     unlink $newname;
1725     return 0, $error
1726     }
1727    
1728     my ($bytes, $error) = $replace_file->($path, \@before, $newfh, $newname);
1729     unlink $newname unless defined $bytes;
1730     defined $bytes
1731     ? (1, "$bytes")
1732     : (0, $error)
1733     };
1734    
1735     $perl && $patch && mcp_tool file_patch =>
1736     "Apply a unified diff to one regular file. Hunk counts are recomputed, fuzz is disabled, "
1737     . "whole-hunk relocation is limited per hunk by distinct old-side lines. "
1738     . "Pure insertion is allowed only at the beginning; elsewhere include old-side context.",
1739     {
1740     type => "object",
1741     properties => {
1742     path => { type => "string" },
1743     diff => { type => "string", minLength => 1 },
1744     workdir => {
1745     type => "integer",
1746     minimum => 0,
1747     description => "Configured workdir index; required when more than one workdir is configured.",
1748     },
1749     reason => {
1750     type => "string",
1751     description => "Reason for requesting permission.",
1752     },
1753     },
1754     required => ["path", "diff"],
1755     additionalProperties => JSON::XS::false,
1756     },
1757     sub {
1758     my ($req, $args) = @_;
1759    
1760     my $workdir = fs_workdir $args->{workdir};
1761     my $path = fs_path $workdir, $args->{path};
1762     my ($diff, $max_offset) = AnyEvent::MCP::normalize_udiff $args->{diff};
1763     $diff = utf8enc $diff;
1764     length $diff <= 4<<20
1765     or die "patch exceeds 4 MiB\n";
1766    
1767     my $capability = fs_path_capability $path, "write";
1768     my $granted = $req->require_permission ({
1769     tool => "file-patch",
1770     subject => $path,
1771     subjects => (fs_subjects $workdir, $path),
1772     capabilities => [$capability],
1773     reason => $args->{reason} // "patch $path",
1774     });
1775     $permissions->closure ($granted)->{$capability}
1776     or die "permission reduced below $capability for $path\n";
1777    
1778     AE::log debug => join " ", "file-patch", $path, scalar (@$max_offset), "hunks", length ($diff), "bytes";
1779    
1780     perl_eval $req, $workdir, $granted, $file_patch,
1781     [$patch, utf8enc $path, $diff, @$max_offset], 30, 65536, sub {
1782     my ($success, $result) = @_;
1783     return $req->error ($result)
1784     unless $success;
1785    
1786     @$result == 2
1787     or return $req->error ("invalid file-patch response\n");
1788     my ($ok, $value) = @$result;
1789    
1790     if ($ok eq "1") {
1791     $value =~ /\A[0-9]+\z/
1792     or return $req->error ("invalid file-patch byte count\n");
1793     $req->reply (fs_result {
1794     bytes => 0 + $value,
1795     });
1796     } elsif ($ok eq "0") {
1797     $req->reply (fs_result {
1798     error => utf8dec $value,
1799     });
1800     } else {
1801     $req->error ("invalid file-patch status\n");
1802     }
1803     };
1804     }
1805     ;
1806    
1807     $find && $grep && mcp_tool grep =>
1808     "Search regular files of at most 1 MiB recursively without following symlinks or descending .git. "
1809     . "Fixed-string matching by default; mode=pcre uses GNU grep -P. "
1810     . "Stops after the result limit; reports truncation and timeout separately.",
1811     {
1812     type => "object",
1813     properties => {
1814     path => {
1815     type => "string",
1816     description => "Path to search; defaults to the selected workdir.",
1817     },
1818     pattern => {
1819     type => "string",
1820     minLength => 1,
1821     },
1822     mode => {
1823     type => "string",
1824     enum => ["fixed", "pcre"],
1825     default => "fixed",
1826     },
1827     limit => {
1828     type => "integer",
1829     minimum => 1,
1830     maximum => 1000,
1831     default => 200,
1832     description => "Maximum number of matching lines to return.",
1833     },
1834     workdir => {
1835     type => "integer",
1836     minimum => 0,
1837     description => "Configured workdir index; required when more than one workdir is configured.",
1838     },
1839     reason => {
1840     type => "string",
1841     description => "Reason for requesting permission.",
1842     },
1843     },
1844     required => ["pattern"],
1845     additionalProperties => JSON::XS::false,
1846     },
1847     sub {
1848     my ($req, $args) = @_;
1849    
1850     my $workdir = fs_workdir $args->{workdir};
1851     my $path = fs_path $workdir, $args->{path} // ".";
1852     my $mode = $args->{mode} // "fixed";
1853     $mode eq "fixed" or $mode eq "pcre"
1854     or die "invalid grep mode\n";
1855     my $limit = $args->{limit} // 200;
1856     $limit <= 1000
1857     or die "invalid limit\n";
1858     my $capability = fs_path_capability $path, "read";
1859    
1860     my $granted = $req->require_permission ({
1861     tool => "grep",
1862     subject => $path,
1863     subjects => (fs_subjects $workdir, $path),
1864     capabilities => [$capability],
1865     reason => $args->{reason} // "grep $path",
1866     });
1867    
1868     AE::log debug => join " ", "grep", $path, $mode, (substr $args->{pattern}, 0, 40);
1869    
1870     my $find_path = utf8enc $path;
1871     my $pattern = utf8enc $args->{pattern};
1872     my @command = (
1873     (fs_sandbox $workdir, $granted),
1874     $find, $find_path,
1875     "-name", ".git", "-prune", "-o",
1876     "-type", "f",
1877     "-size", "-1048577c",
1878     "-exec",
1879     $grep, ($mode eq "pcre" ? "-P" : "-F"),
1880     "-I", "-H", "-n", "--color=never", "-e", $pattern,
1881     "{}", "+",
1882     );
1883    
1884     fs_run $req, \@command, "", 30, 1024 * 1024, $limit, sub {
1885     my ($success, $result) = @_;
1886     return $req->error ($result)
1887     unless $success;
1888    
1889     my $response;
1890     my $ok = eval {
1891     my $stdout = utf8dec $result->{stdout};
1892     my $stderr = utf8dec $result->{stderr};
1893    
1894     if (($result->{exit} or $result->{signal})
1895     and !$result->{truncated} and !$result->{timed_out}) {
1896     die "grep failed (exit $result->{exit}, signal $result->{signal}): $stderr\n";
1897     }
1898    
1899     $response = {
1900     stdout => $stdout,
1901     stderr => $stderr,
1902     timed_out => $result->{timed_out},
1903     truncated => $result->{truncated},
1904     };
1905     1
1906     };
1907     return $req->error ($@)
1908     unless $ok;
1909    
1910     $req->reply (fs_result $response);
1911     };
1912     }
1913     ;
1914    
1915     my $stat = q{#perl
1916     map {
1917     my @s = lstat $_;
1918    
1919     pack "(w/a*)*", (@s
1920     ? (1, (-l _ ? "link" : -d _ ? "directory" : -f _ ? "file" : "other"), @s)
1921     : (0, "lstat: $!"))
1922     } @ARGV
1923     };
1924    
1925     $perl && mcp_tool stat =>
1926     "Get lstat metadata for up to 100 paths; symlinks are not followed.",
1927     {
1928     type => "object",
1929     properties => {
1930     paths => {
1931     type => "array",
1932     items => {
1933     type => "string",
1934     },
1935     minItems => 1,
1936     maxItems => 100,
1937     },
1938     workdir => {
1939     type => "integer",
1940     minimum => 0,
1941     description => "Configured workdir index; required when more than one workdir is configured.",
1942     },
1943     reason => {
1944     type => "string",
1945     description => "Reason for requesting permission.",
1946     },
1947     },
1948     required => ["paths"],
1949     additionalProperties => JSON::XS::false,
1950     },
1951     sub {
1952     my ($req, $args) = @_;
1953    
1954     my $workdir = fs_workdir $args->{workdir};
1955     ARRAY:: eq ref $args->{paths} and @{$args->{paths}} and @{$args->{paths}} <= 100
1956     or die "stat needs 1..100 paths\n";
1957     my @paths = map { fs_path $workdir, $_ } @{$args->{paths}};
1958    
1959     my $granted = fs_require_paths $req, $workdir, \@paths, "read", $args->{reason};
1960    
1961     my @log = @paths;
1962     splice @log, 3, @log - 3, "..." if @log > 3;
1963     AE::log debug => join " ", "stat", @log;
1964    
1965     perl_eval $req, $workdir, $granted, $stat, [map { utf8enc $_ } @paths],
1966     30, 65536, sub {
1967     my ($success, $result) = @_;
1968     return $req->error ($result)
1969     unless $success;
1970    
1971     @$result == @paths
1972     or return $req->error ("invalid stat response\n");
1973    
1974     my @result;
1975     for my $i (0 .. $#paths) {
1976     my ($ok, @s) = unpack "(w/a*)*", $result->[$i];
1977    
1978     if ($ok) {
1979     my $type = shift @s;
1980     push @result, +{
1981     path => $paths[$i],
1982     type => $type,
1983     size => 0 + $s[7],
1984     mode => (sprintf "%04o", $s[2] & 07777),
1985     uid => 0 + $s[4],
1986     gid => 0 + $s[5],
1987     mtime => 0 + $s[9],
1988     };
1989     } else {
1990     push @result, +{
1991     path => $paths[$i],
1992     error => utf8dec $s[0],
1993     };
1994     }
1995     }
1996    
1997     $req->reply (fs_result { result => \@result });
1998     };
1999     }
2000     ;
2001    
2002     mcp_tool exec =>
2003     "Run arbitrary argv in bubblewrap; UTF-8 stdin is limited to 4 MiB, "
2004     . "stdout/stderr are decoded as UTF-8 and captured to 1 MiB combined. Capabilities are optional; "
2005 root 1.2 . "there is no command-prefix safety classification. Timeouts above 300 seconds require "
2006     . "long-running permission; the maximum is six hours.",
2007 root 1.1 {
2008     type => "object",
2009     properties => {
2010     workdir => {
2011     type => "integer",
2012     minimum => 0,
2013     description => "Configured workdir index; required when more than one workdir is configured.",
2014     },
2015     argv => {
2016     type => "array",
2017     items => { type => "string" },
2018     minItems => 1,
2019     },
2020     capabilities => {
2021     type => "array",
2022     description => "Optional authority override from workdirs; explicit [] requests the minimal sandbox. host-proc is currently unavailable.",
2023     items => {
2024     type => "string",
2025     enum => [sort keys %capability],
2026     },
2027     },
2028     reason => {
2029     type => "string",
2030     description => "Useful reason for requesting arbitrary command authority.",
2031     },
2032     stdin => { type => "string" },
2033     timeout => {
2034     type => "integer",
2035     minimum => 1,
2036 root 1.2 maximum => 21600,
2037 root 1.1 default => 30,
2038 root 1.2 description => "Seconds; values above 300 also request long-running permission.",
2039 root 1.1 },
2040     },
2041     required => [qw(argv reason)],
2042     additionalProperties => JSON::XS::false,
2043     },
2044     sub {
2045     my ($req, $args) = @_;
2046    
2047     ARRAY:: eq ref $args->{argv} and @{$args->{argv}}
2048     or die "exec needs argv strings without NUL\n";
2049     for my $arg (@{$args->{argv}}) {
2050     defined $arg and !ref $arg and $arg !~ /\0/
2051     or die "exec needs argv strings without NUL\n";
2052     }
2053     length $args->{argv}[0] or die "exec needs an executable\n";
2054    
2055     my $workdir = fs_workdir $args->{workdir};
2056 root 1.2 my $timeout = $args->{timeout} // 30;
2057     $timeout >= 1 && $timeout <= 21600 && $timeout == int $timeout
2058     or die "invalid timeout\n";
2059    
2060     my $capabilities = [exists $args->{capabilities} ? @{$args->{capabilities}} : @exec_default_capabilities];
2061     push @$capabilities, "long-running" if $timeout > 300;
2062 root 1.1 my $requested = $permissions->closure ($capabilities);
2063     die "host-proc sandbox authority is unresolved and unavailable in v1\n"
2064     if $requested->{"host-proc"};
2065    
2066     my $input = $args->{stdin} // "";
2067     $input = utf8enc $input;
2068     length $input <= 4 * 1024 * 1024 or die "stdin exceeds 4 MiB\n";
2069    
2070     my $coder = JSON::XS->new->canonical;
2071     my $subject = $coder->encode ($args->{argv});
2072     my $executable = $coder->encode ($args->{argv}[0]);
2073     my $granted = $req->require_permission ({
2074     tool => "exec",
2075     subject => $subject,
2076     reason => $args->{reason},
2077     capabilities => $capabilities,
2078     subjects => [
2079     {
2080     label => "exact executable and argv",
2081     subject => $subject,
2082     },
2083     {
2084     label => "this executable, arbitrary argv",
2085     subject => "^\\[\Q$executable\E(?:,.*)?\\]\\z",
2086     },
2087     {
2088     label => "any command",
2089     subject => "^",
2090     },
2091     ],
2092     });
2093    
2094 root 1.2 $timeout <= 300 || $permissions->closure ($granted)->{"long-running"}
2095     or die "permission reduced below long-running for exec\n";
2096    
2097 root 1.1 AE::log debug => "exec "
2098     . (substr $subject, 0, 200)
2099     . (length $subject > 200 ? "..." : "")
2100     . (length $input ? " stdin=" . length ($input) : "");
2101    
2102     my @command = ((fs_sandbox $workdir, $granted), @{$args->{argv}});
2103     fs_run $req, \@command, $input, $timeout, 1024 * 1024, 0, sub {
2104     my ($success, $result) = @_;
2105     return $req->error ($result)
2106     unless $success;
2107    
2108     my $response;
2109     my $ok = eval {
2110     $result->{$_} = utf8dec $result->{$_} for qw(stdout stderr);
2111     $response = fs_result $result,
2112     $result->{exit} || $result->{signal} || $result->{timed_out};
2113     1
2114     };
2115    
2116     if ($ok) {
2117     $req->reply ($response);
2118     } else {
2119     $req->error ($@);
2120     }
2121     };
2122     }
2123     ;
2124    
2125     mcp_config
2126     name => $name,
2127     server_info => { name => "mcp-fs", version => "0" },
2128     permissions => $permissions,
2129     on_error => sub { AE::log debug => "mcp-tool $_[1]{tool}: $_[2]" },
2130 root 1.2 on_start => sub {
2131     print STDERR "Permission rules: $permissions->{file}\n"
2132     if $_[0]->isa ("AnyEvent::MCP::Server::HTTP");
2133     },
2134 root 1.1 instructions => "Use workdirs to inspect configured workdirs and capabilities. Paths are relative to the selected workdir; workdir-read/write expose all configured workdirs. Absolute paths request home/world authority, or shared-tmp for host tmp. Symlinks resolve inside the granted sandbox mounts. Runtime paths are baseline sandbox machinery; system-read exposes configured host exports. Exec requires explicit capabilities and a useful reason.\n";
2135    
2136     my %listen;
2137    
2138     $listen{host} = $host if defined $host;
2139     $listen{port} = $port if defined $port;
2140    
2141 root 1.2 $stdio ? mcp_stdio : mcp_start %listen;
2142 root 1.3