Hello David, 

Thank you for your feedback. I see your point that I haven't made the case why this feature is worthwhile. I would like to explain how I came to my position. 

I want to do bubblewrap + pasta so that I can give my personal agent sandboxed code execution to support SKILLS.md extensions. A very common pattern in SKILLS.md is python code with inline script metadata dependencies so that uvx can generate ephemeral virtual environments on demand to run the scripts. This strategy requires granting access to the internet, but I still want to deny access to ports on localhost. I don't want a confused agent or malicious third party scripts to have the opportunity to tinker with other services running on my machine or those of my office neighbors. Pasta seemed like the right tool for the job. It can distinguish between local area and wide area outbound traffic. 

I tried many, many combinations for getting bwrap and pasta to get along. 

I assume that pasta should be running before the sandboxed command begins to run in the container, since almost the first thing that would happen is the package manager uv reaching out to the internet to gather the script's dependencies. If pasta joins the namespace created by bwrap, then I have to somehow delay the start of the script execution until pasta signals that it is ready. That is a very scary proposition to me; I am not sure how I would do it even in theory. So that makes me want to start pasta first and then start bubblewrap. 

On the other hand, since bwrap can't yet join existing network namespaces, it turned out to be a really difficult problem. I tried wrapping bubblewrap in nsenter, but that exposed a really subtle issue with nested namespaces. I don't 100% understand it, but in short, inner namespaces tend to implicity drop capabilities if they aren't very precisely managed. So the result of my attempt was that some commands like ping would not work inside the inner namespace because the user in the inner namespace didn't get mapped to the range of users in the outer namespace who are allowed to execute ping

I tried executing bubblewrap within pasta. And it's so close to being what I wanted! It's a simple one-liner and for some reason I don't understand, avoids the issue with the nested namespace. But it still isn't what I wanted, because another thing I like to do is deliver bwrap's many many args to it via a file descriptor (--args FD) to simplify the process name for ps and avoid leaking private information to anyone else running on the same node. But since pasta couldn't pass FD's through to bubblewrap, so now I have to wrap my bubblewrap command inside some other bespoke script whose job is to open a temporary file I am serving somewhere, which just becomes another opportunity to leak my private information. 

Sidebar: if you think about it, the only reason the nsenter strategy can even be considered a "workaround" for pasta not having --pass-fd is that nsenter does have the feature to pass FD's through (in fact, it is the default). That's not a good look for pasta. 

Sidebar: Yes, it can be said that my problem would be solved on the bubblewrap side if only bubblewrap could join an existing network namespace. I also submitted the PR to create that feature. Nevertheless, I feel strongly that passing FD's has other plausible consumers besides specifically bubblewrap.

In the end, I felt super, super frustrated because what seemed like an easy problem turned out to be nearly impossible. In my workplace, I am considered the local expert on containers, and even I couldn't figure it out. My LLM coding agent couldn't figure it out either. This would be completely hopeless for some gamer trying to isolate network access for a multiplayer game installed via flatpak.

I teach the container shortcourse at my institution, and I genuinely care about the future of this technology. It is my hope to help make modern lightweight and secure container technologies as user-friendly as possible, so that more people will choose them over "sudo docker". If we can reach a consensus regarding the ideal scope of this feature, I will gladly address all your technical complaints. 

If you read this far, I am grateful for your attention, and I apologise for rambling. It is late here, and my brain don't work so good just before bed.

Richard Lawrence






From: David Gibson
Sent: Sunday, July 19, 2026 10:25 PM
To: Lawrence, Richard E
Cc: passt-dev@passt.top; jbash@jbash.com; Lawrence, Richard E
Subject: Re: [PATCH v3] feat: Add cli option '--pass-fds' for pasta mode.

On Sun, Jul 19, 2026 at 03: 03: 00PM -0500, Richard Lawrence wrote: > From: Richard Lawrence <rarensu@ tamu. edu> > > When pasta mode is used to launch an executable (`pasta [COMMAND]`) and that executable accepts inputs in the form
ZjQcmQRYFpfptBannerStart
This Message Is From an External Sender
This message came from outside your organization.
 
ZjQcmQRYFpfptBannerEnd
On Sun, Jul 19, 2026 at 03:03:00PM -0500, Richard Lawrence wrote: > From: Richard Lawrence <rarensu@tamu.edu> > > When pasta mode is used to launch an executable (`pasta [COMMAND]`) and that executable accepts inputs in the form of arbitrary file descriptors (such as `bwrap`), then passt should not stand in the way of the parent process handing off those file descriptors to the child process. See bug 204 for additional discussion. Nit: wrap commit messages at 80 columns, please. > The `pass-fds` option accepts a comma-separated list of file > descriptor numbers. `conf_pass_fds()` parses the command line > argument, then `isolate_fds()` skips closing the specified fds by > calling `close_range()` on the gaps between them. I'm still not really seeing the value of this option, over starting the namespace separately, passing the fds you want, and then connecting pasta.  Or you can do it the other way around: start with a placeholder process, then use nsenter to put the "real" workload into the same ns, along with whatever fds you want (this approach can be a bit fiddly to discover the right pid in the parent namespace, though). In other words, neither here, nor on the bug have I seen any response to my comments in https://bugs.passt.top/show_bug.cgi?id=204#c3 I have comments on the implementation below, but addressing all those would not be enough for me to approve this patch, without a case for why the feature is sufficiently valuable in the first place. > Additionally, the tap fd is safely relocated to the lowest unused fd > number which it at least 3, to avoid accidentally overwriting an > existing fd. Thank you for removing the confusing Co-authored tags.  Obviously I can't be certain but this patch still reads like it was LLM assisted, which should be acknowledged in some way.  My personal preference would be the `Assisted-by` tag recently adopted by the Linux kernel, but Stefano and I haven't discussed that yet. > Signed-off-by: Richard Lawrence <rlawrence@tamu.edu> > --- >  conf.c      | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++- >  conf.h      |  3 +++ >  isolation.c | 73 ++++++++++++++++++++++++++++++++++++++++++---------- >  passt.1     |  5 ++++ >  4 files changed, 140 insertions(+), 15 deletions(-) > > diff --git a/conf.c b/conf.c > index 0fcba5c..729816d 100644 > --- a/conf.c > +++ b/conf.c > @@ -732,7 +732,9 @@ pasta_opts: >   " Don't copy all addresses to namespace\n" >   "  --ns-mac-addr ADDR Set MAC address on tap interface\n" >   "  --no-splice Disable inbound socket splicing\n" > - "  --splice-only Only enable loopback forwarding\n"); > + "  --splice-only Only enable loopback forwarding\n" > + "  --pass-fds FDS Comma-separated list of fds to pass to\n" > + " the spawned command\n"); >   >   passt_exit(status); >  } > @@ -1183,6 +1185,71 @@ int conf_tap_fd(int argc, char **argv) >   return val; >  } >   > +/** > + * conf_pass_fds() - Read fds as supplied by --pass-fds command line option > + * @argc: Argument count > + * @argv: Command line options > + * @fds: Array where we store the parsed fds > + * @max_fds: Maximum size of the array > + * > + * Return: number of parsed fds, or -1 if option not specified > + */ > +int conf_pass_fds(int argc, char **argv, int *fds, int max_fds) > +{ > + const struct option opt[] = { { "pass-fds", required_argument, NULL, 33 }, > +      { 0 }, }; I'd be inclined to parse --fd and --pass-fds in a single early pass, rather than in two functions since we need them at the same time > + const char *fdsarg = NULL; > + int name, fds_cnt = 0; > + int old_opterr; > + > + old_opterr = opterr; Um.. why?  getopt_long() is thoroughly non-reentrant and saving the error code isn't going to make it so. > + opterr = 0; > + optind = 0; > + do { > + name = getopt_long(argc, argv, "-:", opt, NULL); > + if (name == 33) > + fdsarg = optarg; > + } while (name != -1); > + opterr = old_opterr; > + > + if (!fdsarg) > + return -1; > + > + const char *orig_fdsarg = fdsarg; Although it's permitted by C11 we don't use inline declarations, by convention. > + while (*fdsarg) { > + unsigned long val; > + char *endptr; > + > + val = strtoul(fdsarg, &endptr, 10); > + if (fdsarg == endptr) { > + die("Invalid character in --pass-fds option '%s' (near '%s')", > +    orig_fdsarg, fdsarg); > + } The recently added parse_unsigned() and related functions can do this more nicely.  But.. it also occurs to me that having the moderately complex parsing of a comma separated list is unnecessary.  Seems simpler to change it to --pass-fd, which takes only a single fd, and can be specified multiple times. > + > + if (val > INT_MAX) { > + die("Invalid file descriptor in --pass-fds option '%s' (near '%s')", > +    orig_fdsarg, fdsarg); > + } > + > + if (fds_cnt >= max_fds) > + die("Too many file descriptors in --pass-fds"); > + > + fds[fds_cnt++] = (int)val; > + > + if (*endptr == ',') > + fdsarg = endptr + 1; > + else if (*endptr == '\0') > + fdsarg = endptr; > + else { > + die("Invalid character in --pass-fds option '%s' (near '%s')", > +    orig_fdsarg, endptr); > + } > + } > + > + return fds_cnt; > +} > + >  /** >   * conf_addr() - Configure guest address with -a option >   * @c: Execution context > @@ -1331,6 +1398,7 @@ void conf(struct ctx *c, int argc, char **argv) >   {"stats", required_argument, NULL, 31 }, >   {"conf-path", required_argument, NULL, 'c' }, >   {"chroot-fallback", no_argument, NULL, 32 }, > + {"pass-fds", required_argument, NULL, 33 }, >   { 0 }, >   }; >   const char *optstring = "+dqfel:hs:c:F:I:p:P:m:a:n:M:g:i:o:D:S:H:461t:u:T:U:"; > @@ -1572,6 +1640,10 @@ void conf(struct ctx *c, int argc, char **argv) >   case 32: >   c->chroot_fallback = true; >   break; > + case 33: > + if (c->mode != MODE_PASTA) > + die("--pass-fds is for pasta mode only"); > + break; >   case 'd': >   c->debug = 1; >   c->quiet = 0; > diff --git a/conf.h b/conf.h > index 19bf9bc..28a9acc 100644 > --- a/conf.h > +++ b/conf.h > @@ -6,7 +6,10 @@ >  #ifndef CONF_H >  #define CONF_H >   > +#define PASS_FDS_MAX 1024 > + >  enum passt_modes conf_mode(int argc, char *argv[]); > +int conf_pass_fds(int argc, char **argv, int *fds, int max_fds); >  int conf_tap_fd(int argc, char **argv); >  void conf(struct ctx *c, int argc, char **argv); >  void conf_listen_handler(struct ctx *c, uint32_t events); > diff --git a/isolation.c b/isolation.c > index 94cbe7f..25f599b 100644 > --- a/isolation.c > +++ b/isolation.c > @@ -249,32 +249,77 @@ void isolate_initial(void) >  } >   >  /* > - * isolate_fds() - Close leaked files, but not --fd, stdin, stdout, stderr > + * isolate_fds() - Close leaked files, but not --fd, --pass-fds, standard streams >   * @argc: Argument count > - * @argv: Command line options, as we need to skip any file given via --fd > + * @argv: Command line options >   * >   * Should: > - *  - close all open files except for standard streams and the one from --fd > + *  - close all open files except for standard streams, --fd, and --pass-fds >   *  - move the --fd descriptor out of the range 0-2 >   * >   * Return: new fd number for descriptor from --fd, or -1 if not specified >   */ >  int isolate_fds(int argc, char **argv) >  { > - int fd, close_from = STDERR_FILENO + 1; > + int fds[PASS_FDS_MAX + 1]; > + int fds_cnt = 0; > + int prev_fd; > + int next_fd; > + int tap_fd; > + int rc = 0; > + int i; > + > + tap_fd = conf_tap_fd(argc, argv); > + if (tap_fd >= 0 && tap_fd < 3) { Use STD*_FILENO constants. > + /* Move the tap fd to a safer location */ > + int new_fd = fcntl(tap_fd, F_DUPFD, STDERR_FILENO + 1); > + > + if (new_fd < 0) > + die_perror("Could not relocate --fd descriptor"); > + > + close(tap_fd); > + tap_fd = new_fd; > + } > + > + if (tap_fd >= 0) > + /* Keep the tap fd */ > + fds[fds_cnt++] = tap_fd; > + > + rc = conf_pass_fds(argc, argv, fds + fds_cnt, PASS_FDS_MAX); This is only correct because fds_cnt can't exceed 1 at this point, which is dangerously subtle.  It would probably be more natural to parse --pass-fds first, then append --fd. > + if (rc > 0) > + /* Keep the pass-fds */ > + fds_cnt += rc; >   > - fd = conf_tap_fd(argc, argv); > + rc = 0; >   > - if (fd >= 0) { > - /* Move the passed fd to a more convenient location */ > - if (fd != close_from && > -    (dup2(fd, close_from) != close_from || > -     close(fd))) > - die_perror("Could not move --fd descriptor"); > - fd = close_from++; > + /* Keep standard streams */ > + prev_fd = STDERR_FILENO; > + > + while (1) { > + next_fd = -1; > + > + /* Find the next-lowest fd to keep */ > + for (i = 0; i < fds_cnt; i++) { > + if (fds[i] > prev_fd && (next_fd == -1 || fds[i] < next_fd)) > + next_fd = fds[i]; > + } > + > + if (next_fd == -1) > + break; > + > + if (next_fd > prev_fd + 1) { > + /* Close fds between two kept fds */ > + if (close_range(prev_fd + 1, next_fd - 1, CLOSE_RANGE_UNSHARE)) > + rc = -1; > + } > + prev_fd = next_fd; >   } > + > + /* Close all other fds */ > + if (close_range(prev_fd + 1, ~0U, CLOSE_RANGE_UNSHARE)) > + rc = -1; >   > - if (close_range(close_from, ~0U, CLOSE_RANGE_UNSHARE)) { > + if (rc) { >   if (errno == ENOSYS || errno == EINVAL) { If the failure was not in the last close_range(), errno has been clobbered before you test it here. It's a bit simpler than the earlier version, but this still seems very involved for something that can be avoided by a different way of invoking your program. >   /* This probably means close_range() or the >   * CLOSE_RANGE_UNSHARE flag is not supported by the > @@ -288,7 +333,7 @@ int isolate_fds(int argc, char **argv) >   } >   } >   > - return fd; > + return tap_fd; >  } >   >  /** > diff --git a/passt.1 b/passt.1 > index 995590a..bcd3aff 100644 > --- a/passt.1 > +++ b/passt.1 > @@ -755,6 +755,11 @@ of local traffic in pasta\fR in the \fBNOTES\fR for more details. >  Do not create a tap device in the namespace. In this mode, \fIpasta\fR only >  forwards loopback traffic between namespaces. >   > +.TP > +.BR \-\-pass\-fds " " \fIfds\fR > +Pass a comma-separated list of file descriptors to the spawned command. > +These file descriptors will be kept open. "Will be kept open" is not necessarily very clear to the audience of this document. It's also true in a way that isn't ideal - even after we've fork()ed off the command, we don't close() the remaining fds, weakening more than strictly necessary the security benefits of isolate_fds(). > + >  .SH EXAMPLES >   >  .SS \fBpasta > -- > 2.52.0 > -- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson