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