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