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