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