ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/bin/mcp-fs
Revision: 1.16
Committed: Sun Oct 11 05:16:41 2026 UTC (7 days, 8 hours ago) by root
Branch: MAIN
Changes since 1.15: +100 -49 lines
Log Message:
*** empty log message ***

File Contents

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