[PATCH v3] feat: Add cli option '--pass-fds' for pasta mode.
From: Richard Lawrence
On Sun, Jul 19, 2026 at 03:03:00PM -0500, Richard Lawrence wrote:
From: Richard Lawrence
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
--- 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
Hi Richard,
On Sun, 19 Jul 2026 15:03:00 -0500
Richard Lawrence
From: Richard Lawrence
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.
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.
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.
Thanks for following up. I haven't had a chance to review this in detail yet, but it looks significantly simpler than v2. While I agree with David that the use case should be clearly mentioned in the commit message (it's not entirely clear to me, either, what advantage you get by letting pasta spawn a somehow isolated process), both you and John seem to have the same use case, which I would take as an indication that there's some actual convenience in this. So, as long as it's harmless and sufficiently secure, I don't really have anything against this (except for the pending comments that need to be addressed). Another thing I've been discussing offline with David was the possibility of making pasta spawn the command before closing other open file descriptors. That would be even simpler, and it should be possible to do so without any race condition (contrary to what I previously thought) as the child process waits anyway on a signal from the parent before proceeding. So we could leave those files (optionally) open in the child process taking care of the execvp(), and close them in the parent before the actual networking setup takes place, and before the command is spawned, making sure pasta itself has no possible access to any accidentally leaked file descriptor (which is the security concern here, we don't really care if the spawned command can access them). I haven't really thought this through but it might be worth a try and satisfy your use case anyway, in a simpler way. If that doesn't work for whatever reason I'm ignoring, let's stick to your approach instead. I'll review your patch within a couple of days in that case. Sorry for the delay. -- Stefano
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
From: Richard Lawrence
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
--- 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
Richard, I haven't found the time to review your patch in detail yet
and the whole problem description, just a few notes so far:
On Tue, 21 Jul 2026 04:44:05 +0000
"Lawrence, Richard E"
[...]
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.
By the way, you don't seem to be the only one using a workaround like this, see also: https://github.com/reubenfirmin/bubblewrap-tui#why-pasta
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.
Ah, interesting, thanks for doing that. That should hopefully solve this specific problem. At the same time:
Nevertheless, I feel strongly that passing FD's has other plausible consumers besides specifically bubblewrap.
...this sounds quite likely as well, given it's already two people including you requesting this feature, and the other example of a workaround, even though it's a workaround for what I consider a missing feature in bubblewrap, because it should really have a proper integration (just like Podman, Docker / rootlesskit, libvirt, libkrun, etc. have). See also: https://bugs.passt.top/show_bug.cgi?id=214 for another consequence of a missing integration. But given the likelihood of that, I'm also rather convinced that this option might be useful for this case or for other cases. I just think that we should implement it in the simplest possible way, and if spawning the command before closing file descriptors (which would remain open in the child process only) works as well, I would suggest to go for that (sorry it didn't occur to me as I had a look at v1).
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".
Thanks for that as well, it's definitely a goal of mine too.
If we can reach a consensus regarding the ideal scope of this feature, I will gladly address all your technical complaints.
By the way I think there's consensus, the only issue David was raising here is that the motivation wasn't sufficiently explained or documented. It wasn't clear for me either, judging from issue #204 alone, why bubblewrap couldn't do that. It looks like it could, but it's not implemented.
[...]
-- Stefano
On Tue, Jul 21, 2026 at 04:44:05AM +0000, Lawrence, Richard E wrote:
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.
Thanks!
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.
Sounds like a good plan.
Pasta seemed like the right tool for the job. It can distinguish between local area and wide area outbound traffic.
So, not really relevant to the topic at hand, but a small word of caution here. I'd say pasta definitely aims to be the right tool for this job, but I'm not sure it yet does everything you want. We do distinguish between localhost (127.0.0.1/8 and ::1) traffic and other outbound traffic. We _don't_ otherwise distinguish between network destinations, though, so it won't really protect your network neighbours. We do intend to add forwarding/filtering rules for outbound traffic, but it's not done yet.
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.
Ok, fair point. Delaying the start of the internal command can certainly be done, but it is pretty fiddly.
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.
Ah! I'm not certain, but the problem here might be not to do with nested namespaces per se, but because nsenter usually drops the capabilities it gets by entering the namespace. Or, more precisely, it doesn't by default allow the caps it has to pass on to the command it spawns. Try adding --keep-caps to the nsenter command line - at least if you have a new enough util-linux. I added that option myself ~3 years ago to deal with similar sounding problems (unshare has had a similar option for much longer, I just added the nsenter one).
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.
Right. I certainly agree that passing the fds into your command is a good approach. The question is whether there's a practical way to do that without spawning the command directly from pasta.
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.
Well.. it's a question of the context for each program. nsenter is a system tool, it's not really it's job to restrict what you or it can do beyond what the kernel enforces. pasta, on the other hand is an end user tool that has to process traffic from an untrusted guest. For that reason, we try to have multiple layers of defense to limit the damage even if a malicious guest takes us over completely. That's why we close all open fds on entry, not because it's something that most programs would do.
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.
So, yes I think for the case of bubblewrap specifically it would make sense to do some integration there. But also, agreed, it's certainly not the only case where passing fds is useful.
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.
That's understandable. It does seem like doing this with nsenter is rather trickier than I had thought. Would be nice to see if --keep-caps is the secret ingredient, though.
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.
Ok. You've more than halfway convinced me. Here's what I'd like to see: 1) Check if starting pasta first then running bwrap under nsenter --keep-caps can do the trick. Even if we do make the change to pasta, it would be nice to know if this workaround is usable for older versions. 2) Look into implementing this by delaying isolate_fds() until after the command is spawned. 3) If both of those fail, then I agree the case for --pass-fds is strong and I'll no longer object.
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.
I know the feeling. -- 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
On Wed, Jul 22, 2026 at 12:45:25AM +0200, Stefano Brivio wrote:
Richard, I haven't found the time to review your patch in detail yet and the whole problem description, just a few notes so far:
On Tue, 21 Jul 2026 04:44:05 +0000 "Lawrence, Richard E"
wrote: [...]
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.
By the way, you don't seem to be the only one using a workaround like this, see also:
https://github.com/reubenfirmin/bubblewrap-tui#why-pasta
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.
Ah, interesting, thanks for doing that. That should hopefully solve this specific problem. At the same time:
Nevertheless, I feel strongly that passing FD's has other plausible consumers besides specifically bubblewrap.
...this sounds quite likely as well, given it's already two people including you requesting this feature, and the other example of a workaround, even though it's a workaround for what I consider a missing feature in bubblewrap, because it should really have a proper integration (just like Podman, Docker / rootlesskit, libvirt, libkrun, etc. have). See also:
https://bugs.passt.top/show_bug.cgi?id=214
for another consequence of a missing integration.
But given the likelihood of that, I'm also rather convinced that this option might be useful for this case or for other cases.
I just think that we should implement it in the simplest possible way, and if spawning the command before closing file descriptors (which would remain open in the child process only) works as well, I would suggest to go for that (sorry it didn't occur to me as I had a look at v1).
Right. Note that as well as being (I think) a simpler implementation, that will be a better UX: you don't need to enumerate all the fds and add an option for them. Plus I think it makes more logical sense - our self-isolation steps are to isolate, well, ourselves. They're not really intended for the spawned commaned. -- 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
participants (4)
-
David Gibson
-
Lawrence, Richard E
-
Richard Lawrence
-
Stefano Brivio