Repositories / jai.git

jai.git

Clone (read-only): git clone http://git.guha-anderson.com/git/jai.git

Branch

add .jai directories improve blackout of ~/.jai and storage

add .jai directories
improve blackout of ~/.jai and storage
Author
David Mazieres <dm@uun.org>
Date
2026-03-24 00:22:53 -0700
Commit
8194ef693a6b1bd9d09087c91a286c29fe45ba8f
default_conf.cc
index 08f7e0f..b7b5ca6 100644
--- a/default_conf.cc
+++ b/default_conf.cc
@@ -29,14 +29,14 @@ const std::string jai_defaults =
 
 # storage /some/local/directory/${JAI_USER}/.jai
 
-# The default mode is strict for all named jails and casual for the
-# default jail.  A strict jail runs under the dedicated jai UID and
-# starts with an empty home directory.  A casual jail runs with your
-# own UID and makes your home directory copy-on-write via an overlay
-# mount.  Strict mode cannot grant unrestricted access to directories
-# on NFS file systems.  You will have to use bare mode (which gives
-# you a bare home directory, but still runs with your UID) to expose
-# NFS directories.  Uncomment any of the following to set the mode:
+# The default mode is strict.  A strict jail runs under the dedicated
+# jai UID and starts with an empty home directory.  A casual jail runs
+# with your own UID and makes your home directory copy-on-write via an
+# overlay mount.  Strict mode cannot grant unrestricted access to
+# directories on NFS file systems.  You will have to use bare mode
+# (which gives you a bare home directory, but still runs with your
+# UID) to expose NFS directories.  Uncomment any of the following to
+# set the mode, or override it in individual .jail files:
 
 # mode casual
 # mode bare
@@ -50,7 +50,7 @@ const std::string jai_defaults =
 # casual, but if you define this to anything including "default", then
 # the default mode will be strict.
 
-# name default
+# jail default
 
 # jai launches jailed programs by running bash with the command name
 # in "$0" and the arguments in "@".  Altering command allows you set
@@ -148,3 +148,10 @@ extern const std::string default_conf =
 conf .defaults
 
 )";
+
+extern const std::string default_jail =
+  R"(# Set casual mode for the default jail.
+
+mode casual
+
+)";
fs.cc
index 754a52d..2372816 100644
--- a/fs.cc
+++ b/fs.cc
@@ -398,23 +398,15 @@ set_fd_acl(int fd, const char *acltext, AclType which)
     syserr(R"(acl_set_file("{}", DEFAULT, {}))", fdpath(fd), acltext);
 }
 
-std::expected<std::string, std::system_error>
-try_read_file(int dfd, path file)
+std::string
+read_fd(int fd)
 {
-  Fd fdholder;
-  int fd = dfd;
-  if (!file.empty()) {
-    fdholder = openat(fd, file.c_str(), O_RDONLY | O_CLOEXEC);
-    if (!fdholder)
-      return std::unexpected(
-          std::system_error(errno, std::system_category(), fdpath(fd, file)));
-    fd = *fdholder;
-  }
-
   std::string ret;
-  if (auto sb = xfstat(fd); sb.st_size > 0x100'0000)
+  if (auto sb = xfstat(fd); sb.st_size > 0x100'0000) {
     // Let's not go crazy with sparse files and such
-    err("{}: file too large", fdpath(fd));
+    errno = EFBIG;
+    syserr("{}", fdpath(fd));
+  }
   else if (sb.st_size > 0)
     ret.reserve(sb.st_size);
   for (;;) {
@@ -423,43 +415,62 @@ try_read_file(int dfd, path file)
     if (n == 0)
       return ret;
     if (n < 0)
+      syserr("{}: read", fdpath(fd));
+    ret.append(buf, size_t(n));
+  }
+}
+
+std::expected<std::string, std::system_error>
+try_read_file(int dfd, path file)
+{
+  Fd fdholder;
+  int fd = dfd;
+  if (!file.empty()) {
+    fdholder = openat(fd, file.c_str(), O_RDONLY | O_CLOEXEC);
+    if (!fdholder)
       return std::unexpected(
           std::system_error(errno, std::system_category(), fdpath(fd, file)));
-    ret.append(buf, size_t(n));
+    fd = *fdholder;
   }
+  return read_fd(fd);
 }
 
 Fd
-ensure_file(int dfd, path file, std::string_view contents, int mode)
+ensure_file(int dfd, path file, std::string_view contents, int mode,
+            bool *created)
 {
   assert(!file.empty());
-  for (;;) {
-    if (Fd fd = openat(dfd, file.c_str(), O_RDONLY | O_CLOEXEC)) {
-      if (!S_ISREG(xfstat(*fd).st_mode))
-        err("{}: not a regular file", fdpath(dfd, file));
-      return fd;
-    }
-    if (errno != ENOENT)
-      syserr("{}", fdpath(dfd, file));
-
-    path tmp = cat(file, std::format("~{}~", getpid()));
-    unlinkat(dfd, tmp.c_str(), 0);
-    Defer cleanup{[dfd, &tmp] { unlinkat(dfd, tmp.c_str(), 0); }};
-
-    Fd fd = xopenat(dfd, tmp.c_str(), O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC,
-                    mode);
-    for (size_t i = 0; i < contents.size();) {
-      if (auto n = write(*fd, contents.data() + i, contents.size() - i); n < 0)
-        syserr(R"(write(O_TMPFILE for "{}"))", fdpath(dfd, file));
-      else
-        i += n;
-    }
-    if (fsync(*fd))
-      syserr("fsync(\"{}\")", fdpath(*fd));
-    if (renameat(dfd, tmp.c_str(), dfd, file.c_str()))
-      syserr(R"(rename("{}" -> "{}") in "{}")", tmp.string(), file.string(),
-             fdpath(*fd));
-    cleanup.release();
+
+  if (Fd fd = openat(dfd, file.c_str(), O_RDONLY | O_CLOEXEC)) {
+    if (!S_ISREG(xfstat(*fd).st_mode))
+      err("{}: not a regular file", fdpath(dfd, file));
+    if (created)
+      *created = false;
     return fd;
   }
+  if (errno != ENOENT)
+    syserr("{}", fdpath(dfd, file));
+
+  path tmp = cat(file, std::format("~{}~", getpid()));
+  unlinkat(dfd, tmp.c_str(), 0);
+  Defer cleanup{[dfd, &tmp] { unlinkat(dfd, tmp.c_str(), 0); }};
+
+  Fd fd =
+      xopenat(dfd, tmp.c_str(), O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, mode);
+  for (size_t i = 0; i < contents.size();) {
+    if (auto n = write(*fd, contents.data() + i, contents.size() - i); n < 0)
+      syserr(R"(write(O_TMPFILE for "{}"))", fdpath(dfd, file));
+    else
+      i += n;
+  }
+  if (fsync(*fd))
+    syserr("fsync(\"{}\")", fdpath(*fd));
+  if (renameat(dfd, tmp.c_str(), dfd, file.c_str()))
+    syserr(R"(rename("{}" -> "{}") in "{}")", tmp.string(), file.string(),
+           fdpath(*fd));
+  cleanup.release();
+  if (created)
+    *created = true;
+  fd.reset();                   // have to reopen for reading
+  return xopenat(dfd, file.c_str(), O_RDONLY | O_CLOEXEC);
 }
fs.h
index 22db15d..5defe61 100644
--- a/fs.h
+++ b/fs.h
@@ -265,6 +265,12 @@ xfstat(int fd, path file = {}, FollowLinks follow = kFollow)
   return sb;
 }
 
+std::string read_fd(int fd);
+
+// This tries to read a file.  It will return an error if the file
+// cannot be opened (e.g., because it does not exist), but could still
+// throw if reading the actual file returns an error or allocating the
+// buffer exhausts memory.
 std::expected<std::string, std::system_error> try_read_file(int dfd,
                                                             path file = {});
 
@@ -277,7 +283,8 @@ read_file(int dfd, path file = {})
     throw res.error();
 }
 
-Fd ensure_file(int dfd, path file, std::string_view contents, int mode = 0600);
+Fd ensure_file(int dfd, path file, std::string_view contents, int mode = 0600,
+               bool *created = nullptr);
 
 using ACL = RaiiHelper<acl_free, acl_t>;
 
jai.1.md
index a6f251f..3c554c9 100644
--- a/jai.1.md
+++ b/jai.1.md
@@ -89,21 +89,31 @@ to expose whatever directory contains the changed files (e.g.,
 
 jai allows the use of multiple home directories for different jails.
 To use a home directory other than the default, just give it a name
-with the `-n` option and it will be created on demand.  When you
-specify a home directory with `-n`, strict mode becomes the default
-(unless there is no unprivileged `jai` user on your system, in which
-case jai falls back to bare mode).  It is possible to have multiple
-home overlays by specifying `-mcasual` with `-n`.
+with the `-j` option and it will be created on demand.  If you don't
+specify `-mcasual` or `-mbare`, strict mode will become the default
+for a newly created jail, but you can change this by editing the
+corresponding `.jail` file in `$HOME/.jai` or wherever your storage
+directory is.
 
 # CONFIGURATION
 
-If *cmd* does not contain any slashes, configuration is taken from
-`$HOME/.jai/`*cmd*`.conf`, or, if no such file exists, from
-`$HOME/.jai/default.conf`.
+Configuration comes from three sources: the command line, a `.conf`
+configuration file (which may include other files via `conf` options),
+and a `.jail` file specific to the named jail you are choosing.
+Command-line options override everything, and `.jail` files override
+`.conf` files.
 
-The format of configuration files is a series of lines of the form
-"*option* [*value*]".  *option* can be any long command-line option
-without the leading `--`, for example:
+If you don't specify a `.conf` file on the command line with the `-C`
+option, and if *cmd* does not contain any slashes, jai will first try
+to use `$HOME/.jai/`*cmd*`.conf` if that file exists, and otherwise
+will use `$HOME/.jai/default.conf` (which it will create if needed
+exist).  That way the `.conf` file can specify a jail name, and the
+`.jail` file can set the mode of the jail.
+
+The format of `.conf` and `.jail` configuration files is a series of
+lines of the form "*option* [*value*]" or "*option*`=`*value*.
+*option* can be any long command-line option without the leading `--`,
+for example:
 
     conf .defaults
     mode casual
@@ -111,14 +121,15 @@ without the leading `--`, for example:
     mask Mail
 
 If you want to set an option that requires an argument to the empty
-string, use an `=` sign, as in `storage=`.
+string, use an `=` sign, as in `storage=` to reset the default storage
+location.
 
 Within a configuration file, `conf` acts like an include directive,
 logically replacing the `conf` line with the contents of another
-configuration file.  (Relative paths are relative to `$HOME/.jai/`.)
-jai creates a file `.defaults` with a sensible set of defaults you
-should probably include directly or indirectly in any configuration
-file.
+configuration file.  Relative paths are relative to `$HOME/.jai/` (or
+`$JAI_CONFIG_DIR` if set).  jai creates a file `.defaults` with a
+sensible set of defaults you should probably include directly or
+indirectly in any `.conf` configuration file you write.
 
 jai executes jailed programs with bash.  The `command` directive
 allows you to reconfigure the environment or add command-line options
@@ -129,7 +140,7 @@ following:
     conf default.conf
     mode strict
     dir venv
-    name python
+    jail python
     command source $HOME/venv/bin/activate; "$0" "$@"
 
 If you run `jai python`, this configuration file will load a virtual
@@ -140,22 +151,18 @@ environment before running the command.
 To install claude code in a jail called `claude`:
 
     curl -fsSL https://claude.ai/install.sh | \
-        jai -D -mstrict -n claude bash
+        jai -D -mstrict -j claude bash
 
-To invoke claude code in that same jail, if $HOME/.local/bin is not
+To invoke claude code in that same jail, if `$HOME/.local/bin` is not
 already on your path:
 
-    PATH=$HOME/.local/bin:$PATH jai -n claude claude
+    PATH=$HOME/.local/bin:$PATH jai -j claude claude
 
 To make `jai claude` use the claude jail by default:
 
     cat <<<EOF >$HOME/.jai/claude.conf
     conf .defaults
-    name claude
-
-    # Mode already defaults to strict; change to bare if using NFS
-    mode strict
-
+    jail claude
     command PATH=$HOME/.local/bin:$PATH "$0" "$@"
     EOF
 
@@ -167,11 +174,12 @@ directory and merging them into your claude jail.
 
     # Extract cookies outside jail, merge them inside jail
     xauth extract - $DISPLAY | jai -C claude xauth merge -
-    # Copy a screen region you should be able to paste in claude
+
+    # Now copy a screen region to paste in claude
     import png:- | xclip -selection clipboard -t image/png
 
 A safer way to do this is to write your screengrabs directly into the
-sandbox's /tmp directory as in:
+sandbox's `/tmp` directory as in:
 
     import /run/jai/$USER/tmp/claude/scrn.png
 
@@ -188,12 +196,8 @@ directory:
 To do this by default when invoking `jai codex` (similar for `jai
 opencode`):
 
-    cat <<EOF >$HOME/.jai
+    cat <<EOF >$HOME/.jai/codex.conf
     conf .defaults
-    mode casual
-
-    # no need to specify name, will be "default" by default
-    # name default
 
     # list additional directories to expose
     dir .codex
@@ -233,6 +237,9 @@ opencode`):
   to the current working directory, while in configuration files, they
   are relative to your home directory.
 
+`-x` *dir*, `--xdir` *dir*
+: Reverse the effects of a previous `--dir` *dir* option.
+
 `-D`, `--nocwd`
 : By default, `jai` grants access to the current working directory
   even if it is not specified with `-d`.  This option suppresses that
@@ -254,7 +261,7 @@ opencode`):
   directory (`$HOME/.jai/`*name*`.home`), and jailed code runs with a
   different user id, `jai`.  Id-mapped mounts are used to map `jai` to
   the invoking user in granted directories.  Strict mode is the
-  default when you name a jail (see `--name`), but not for the default
+  default when you name a jail (see `--jail`), but not for the default
   jail.
 
     Bare mode uses an empty directory like strict mode, but runs with
@@ -262,18 +269,24 @@ opencode`):
   can be used for NFS-mounted home directories since NFS does not
   support id-mapped mounts.
 
-`-n` *name*, `--name` *name*
+`-j` *name*, `--jail` *name*
 : jai allows you to have multiple jailed home directories, which may
   be useful when jailing multiple tools that should not have access to
-  each other's API keys.  This option specifies which home directory
-  to use.  If no such jail exists yet, it will be created on demand.
-  When not specified, the default is just `default`.  Note that each
-  name can be associated with both a casual home directory (accessible
-  at `/run/jai/$USER/`*name*`.home`, with changes in
-  `$HOME/.jai/`*name*`.changes`) and a strict/bare home directory (in
-  `$HOME/.jai/`*name*`.home`).  There is no special relation between
-  these home directories, but casual and strict jails by the same name
-  do share the same `/tmp` directory.
+  each other's API keys.  This option specifies which jail to use.  If
+  no such jail exists yet, it will be created on demand and the mode
+  specified (strict by default) will become the default for that jail,
+  though you can change it in the file `$HOME/.jai/`*name*`.jail`.
+
+  Note that if you switch modes, the same *name* can have both a
+  casual home directory (accessible at `/run/jai/$USER/`*name*`.home`,
+  with changes going in `$HOME/.jai/`*name*`.changes`) and a
+  strict/bare home directory (in `$HOME/.jai/`*name*`.home`).  There
+  is no special relation between these two home directories, but all
+  jails by the name *name* share the same `/tmp` directory.
+
+  Note that you are not allowed to use the `jail` configuration option
+  in a `.jail` file or any configuration file included by the `.jail`
+  file.
 
 `--mask` *file*
 : When creating an overlay home directory, create a "whiteout" file to
@@ -361,6 +374,8 @@ uses `\` to escape the next character.
 
 # ENVIRONMENT
 
+The following environment variables affect jai's operation:
+
 `SUDO_USER`, `USER`
 : If jai is invoked with real UID 0 and either of these environment
   variables exists, it will be taken as the user whose home directory
@@ -374,13 +389,18 @@ uses `\` to escape the next character.
   wish to put your private home directories elsewhere in order to use
   casual mode.
 
-`JAI_NAME`
-: Set to the name of the jai instance (specified by `-n` or `--name`)
-  inside the jail.
+Jai sets the following environment variables inside jails:
 
 `JAI_MODE`
 : Set to the mode (strict, bare, or casual) inside a jail.
 
+`JAI_JAIL`
+: Set to the name of the jai instance (specified by `-j` or `--jail`)
+  inside the jail.
+
+`JAI_USER`
+: Set to the name of the user who invoked jai.
+
 # FILES
 
 In the following paths, the location `$HOME/.jai` can be changed by
@@ -398,12 +418,22 @@ setting the `JAI_CONFIG_DIR` environment variable.
   all configuration files with the line `conf .defaults` to get the
   defaults.
 
+In the following paths, the location `$HOME/.jai` is changed by the
+`--storage` option.  In the absence of a `--storage` option, the
+location can be changed by the `JAI_CONFIG_DIR` environment variable.
+
+`$HOME/.jai/`*name*`.jail`
+: Configuration file for jail named *name*, read after and overriding
+  any settings in the `.conf` file.  Usually only sets `mode` for the
+  sandbox, but could also conceivably include `dir` and other options
+  except `jail`, which is disallowed.
+
 `$HOME/.jai/default.changes`, `$HOME/.jai/`*name*`.changes`
 : This "upper" directory is overlaid on your home directory and
   contains changes that have been made inside a casual jail.  Before
   directly changing this directory, tear down and recreate the
   sandboxed home directory with `jai -u`.  The non-default version is
-  used when you specify `-n` *name* on the command line.  If you
+  used when you specify `-j` *name* on the command line.  If you
   specified `--storage=`*dir*, the changes directory will be in *dir*
   instead of `$HOME/.jai`.
 
@@ -422,6 +452,9 @@ setting the `JAI_CONFIG_DIR` environment variable.
   `--storage=`*dir*, the these directories will be under *dir* instead
   of `$HOME/.jai`.
 
+The following paths are always fixed, regardless of environment
+variables or command-line options:
+
 `/run/jai/$USER/default.home`, `/run/jai/$USER/`*name*`.home`
 : Home directories for casual jails.  You can delete files with
   sensitive data in these jail directories to hide theme from jailed
jai.cc
index f8ac7de..2edd090 100644
--- a/jai.cc
+++ b/jai.cc
@@ -20,11 +20,26 @@
 
 path prog;
 
+void
+Config::parse_config_fd(int fd, Options *opts)
+{
+  auto ld = fdpath(fd, true);
+  if (auto [_it, ok] = config_loop_detect_.insert(ld); !ok)
+    err<Options::Error>("configuration loop");
+  Defer _clear([this, ld, pch = parsing_config_file_] {
+    config_loop_detect_.erase(ld);
+    parsing_config_file_ = pch;
+  });
+  parsing_config_file_ = true;
+  auto go = [&](Options *o) { o->parse_file(read_file(fd), ld); };
+  go(opts ? opts : opt_parser().get());
+}
+
 bool
 Config::parse_config_file(path file, Options *opts)
 {
   bool slash = std::ranges::distance(file.begin(), file.end()) > 1;
-  bool fromcwd = slash && !dir_relative_to_home_;
+  bool fromcwd = slash && !parsing_config_file_;
 
   if (struct stat sb;
       !slash && file.extension() != ".conf" &&
@@ -33,26 +48,13 @@ Config::parse_config_file(path file, Options *opts)
       S_ISREG(sb.st_mode))
     file += ".conf";
 
-  auto ld = (fromcwd ? cwd() : homejaipath_) / file;
-  if (auto [_it, ok] = config_loop_detect_.insert(ld); !ok)
-    err<Options::Error>("configuration loop");
-  Defer _clear{[this, ld = std::move(ld), drh = dir_relative_to_home_] {
-    config_loop_detect_.erase(ld);
-    if (!drh)
-      dir_relative_to_home_ = false;
-  }};
-  dir_relative_to_home_ = true;
-
-  auto r = try_read_file(fromcwd ? AT_FDCWD : home_jai(), file);
-  if (!r) {
-    if (r.error().code() == std::errc::no_such_file_or_directory)
+  Fd fd = openat(fromcwd ? AT_FDCWD : home_jai(), file.c_str(), O_RDONLY);
+  if (!fd) {
+    if (errno == ENOENT)
       return false;
-    throw r.error();
+    syserr("{}", file.c_str());
   }
-  if (opts)
-    opts->parse_file(*r, ld.string());
-  else
-    opt_parser()->parse_file(*r, ld.string());
+  parse_config_fd(*fd, opts);
   return true;
 }
 
@@ -146,16 +148,15 @@ Config::asuser(const Credentials *crp)
 }
 
 void
-Config::check_user(int fd, std::string p, bool untrusted_ok)
+Config::check_user(const struct stat &sb, std::string p, bool untrusted_ok)
 {
-  if (auto sb = xfstat(fd); sb.st_uid != user_cred_.uid_) {
+  if (sb.st_uid != user_cred_.uid_) {
     if (!untrusted_ok)
-      err("{}: owned by {} should be owned by {}", p.empty() ? fdpath(fd) : p,
-          sb.st_uid, user_cred_.uid_);
+      err("{}: owned by {} should be owned by {}", p, sb.st_uid,
+          user_cred_.uid_);
     else if (sb.st_uid != untrusted_cred_.uid_)
-      err("{}: owned by {} should be owned by {} or {}",
-          p.empty() ? fdpath(fd) : p, sb.st_uid, user_cred_.uid_,
-          untrusted_cred_.uid_);
+      err("{}: owned by {} should be owned by {} or {}", p, sb.st_uid,
+          user_cred_.uid_, untrusted_cred_.uid_);
   }
 }
 
@@ -419,10 +420,7 @@ Config::make_mnt_ns()
   if (mode_ == kStrict && !strict_ok)
     err("Cannot use strict mode: invalid user {}", kUntrustedUser);
 
-  if (mode_ == kInvalidMode)
-    mode_ = sandbox_name_.empty() ? kCasual : strict_ok ? kStrict : kBare;
-  if (sandbox_name_.empty())
-    sandbox_name_ = "default";
+  assert(!sandbox_name_.empty());
 
   mount_attr attr{
       .attr_set = MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV,
@@ -501,7 +499,7 @@ Config::make_mnt_ns()
     restore_root = asuser();
     Fd dst = openat(-1, d.c_str(), O_DIRECTORY | O_PATH | O_CLOEXEC);
     if (!dst) {
-      if (mode_ == kCasual || (errno != EACCES && errno != ENOENT))
+      if (mode_ != kStrict || (errno != EACCES && errno != ENOENT))
         syserr("{}", d.string());
       restore_root.reset();
       restore_root = asuser(sbcred);
@@ -513,14 +511,12 @@ Config::make_mnt_ns()
   }
 
   xsetns(*newns, CLONE_NEWNS);
-  struct stat sb;
 
-  if (!storagedir_.empty() && stat(storagedir_.c_str(), &sb) == 0 &&
-      S_ISDIR(sb.st_mode)) {
-    // make sure storage directory not exposed
-    auto restore_root = asuser();
-    Fd target = xopenat(AT_FDCWD, storagedir_.c_str(), O_DIRECTORY | O_RDONLY);
-    check_user(*target);
+  auto blockdir = [this, &oldns, &newns, &sbcred](const path &p) {
+    assert(p.is_absolute());
+    auto restore_root = asuser(sbcred);
+    Fd target = xopenat(AT_FDCWD, p, O_DIRECTORY | O_RDONLY);
+    check_user(*target, p, true);
     restore_root.reset();
     Fd empty = xopenat(-1, kRunRoot, O_RDONLY);
     if (!is_dir_empty(*empty))
@@ -532,7 +528,10 @@ Config::make_mnt_ns()
                               .propagation = MS_PRIVATE,
                           });
     xmnt_move(*source, *target);
-  }
+  };
+  blockdir(storagedir_);
+  if (homejaipath_ != storagedir_)
+    blockdir(homejaipath_);
 
   return newns;
 }
@@ -928,12 +927,8 @@ try {
     argv = const_cast<char **>(bashcmd.data());
   }
 
-  setenv("JAI_NAME", sandbox_name_.c_str(), 1);
-  setenv("JAI_MODE",
-         mode_ == kStrict ? "strict"
-         : mode_ == kBare ? "bare"
-                          : "casual",
-         1);
+  setenv("JAI_JAIL", sandbox_name_.c_str(), 1);
+  setenv("JAI_MODE", std::format("{}", mode_).c_str(), 1);
   auto env = make_env();
 
   execvpe(argv0, argv, const_cast<char **>(env.data()));
@@ -945,7 +940,7 @@ try {
 }
 
 std::unique_ptr<Options>
-Config::opt_parser()
+Config::opt_parser(bool dotjail)
 {
   auto ret = std::make_unique<Options>();
   Options &opts = *ret;
@@ -953,10 +948,7 @@ Config::opt_parser()
       "-m", "--mode",
       [this](std::string_view m) {
         static const std::map<std::string, Mode, std::less<>> modemap{
-            {"default", kInvalidMode},
-            {"casual", kCasual},
-            {"bare", kBare},
-            {"strict", kStrict}};
+            {"casual", kCasual}, {"bare", kBare}, {"strict", kStrict}};
         if (auto it = modemap.find(m); it != modemap.end())
           mode_ = it->second;
         else
@@ -972,20 +964,32 @@ Config::opt_parser()
       "-d", "--dir",
       [this](path d) {
         grant_directories_.emplace(
-            canonical(dir_relative_to_home_ ? homepath_ / d : d));
+            canonical(parsing_config_file_ ? homepath_ / d : d));
       },
       "Grant full access to DIR.", "DIR");
   opts(
+      "-x", "--xdir",
+      [this](path d) {
+        grant_directories_.erase(
+            canonical(parsing_config_file_ ? homepath_ / d : d));
+      },
+      "undo the effects of a previous --dir option", "DIR");
+  opts(
       "-D", "--nocwd", [this] { grant_cwd_ = false; },
       "Do not grant access to the current working directory");
-  opts(
-      "-n", "--name",
-      [this](path sb) {
-        if (!name_ok(sb))
-          err<Options::Error>("{}: invalid sandbox name", sb.string());
-        sandbox_name_ = sb;
-      },
-      "Use private or overlay home directory named NAME", "NAME");
+  if (!dotjail)
+    opts(
+        "-j", "--jail",
+        [this](path sb) {
+          if (!name_ok(sb))
+            err<Options::Error>("{}: invalid sandbox name", sb.string());
+          sandbox_name_ = sb;
+        },
+        "Use private or overlay home directory named NAME", "NAME");
+  else
+    opts("-j", "--jail", [](path) {
+      err<Options::Error>("cannot set name from a .jail file or include");
+    });
   opts("--conf", [this, opts = ret.get()](path file) {
     if (!parse_config_file(file, opts))
       err<Options::Error>("{}: configuration file not found", file.string());
@@ -1038,7 +1042,7 @@ Config::opt_parser()
       "--storage",
       [this](std::string_view s) {
         auto sd = var_expand(s);
-        if (dir_relative_to_home_)
+        if (parsing_config_file_)
           storagedir_ = homepath_ / sd;
         else
           storagedir_ = sd;
@@ -1126,6 +1130,7 @@ The default is CMD.conf if it exists, otherwise default.conf)",
 
   ensure_file(conf.home_jai(opt_init), ".defaults", jai_defaults, 0600);
   ensure_file(conf.home_jai(), "default.conf", default_conf, 0600);
+  ensure_file(conf.storage(), "default.jail", default_jail, 0600);
 
   if (opt_init) {
     std::println("You can edit the configuration defaults in {}/.defaults",
@@ -1151,6 +1156,20 @@ The default is CMD.conf if it exists, otherwise default.conf)",
             !conf.parse_config_file(std::format("{}.conf", cmd[0]))) &&
            !conf.parse_config_file("default.conf"))
     conf.parse_config_file("default.conf");
+
+  // Re-parse command line to override files
+  opts->parse_argv(argc, argv);
+
+  bool createwarn = false;
+  if (conf.sandbox_name_.empty())
+    conf.sandbox_name_ = "default";
+  Fd dotjail =
+      ensure_file(conf.storage(), cat(conf.sandbox_name_, ".jail"),
+                  std::format("mode {}\n", conf.mode_), 0600, &createwarn);
+  if (createwarn)
+    warn("created {}", fdpath(*dotjail));
+  conf.parse_config_fd(*dotjail, conf.opt_parser(true).get());
+
   // Re-parse command line to override files
   opts->parse_argv(argc, argv);
 
jai.h
index b3a2af6..b17812d 100644
--- a/jai.h
+++ b/jai.h
@@ -85,11 +85,12 @@ constexpr const char *kRunRoot = "/run/jai";
 
 extern const std::string jai_defaults;
 extern const std::string default_conf;
+extern const std::string default_jail;
 
 struct Config {
-  enum Mode { kInvalidMode, kCasual, kBare, kStrict };
+  enum Mode { kCasual, kBare, kStrict };
 
-  Mode mode_{kInvalidMode};
+  Mode mode_{kStrict};
   PathSet grant_directories_;
   bool grant_cwd_{true};
   std::set<std::string, std::less<>> env_filter_;
@@ -98,7 +99,7 @@ struct Config {
   std::string shellcmd_;
   PathSet mask_files_;
   bool mask_warn_{};
-  bool dir_relative_to_home_{};
+  bool parsing_config_file_{};
 
   std::string user_;
   path homepath_;
@@ -127,8 +128,9 @@ struct Config {
   void exec(int nsfd, char **argv);
   void unmount();
   void unmountall();
-  std::unique_ptr<Options> opt_parser();
+  std::unique_ptr<Options> opt_parser(bool dotjail = false);
 
+  void parse_config_fd(int fd, Options *opts = nullptr);
   bool parse_config_file(path file, Options *opts = nullptr);
   std::vector<const char *> make_env();
 
@@ -139,8 +141,14 @@ struct Config {
 
   [[nodiscard]] static Defer asuser(const Credentials *crp);
   [[nodiscard]] Defer asuser() { return asuser(&user_cred_); }
-  void check_user(int fd, std::string path_for_error = {},
+  void check_user(const struct stat &sb, std::string path_for_error = {},
                   bool untrusted_ok = false);
+  void check_user(int fd, std::string path_for_error = {},
+                  bool untrusted_ok = false)
+  {
+    check_user(xfstat(fd), path_for_error.empty() ? fdpath(fd) : path_for_error,
+               untrusted_ok);
+  }
   Fd ensure_udir(int dfd, const path &p, mode_t perm = 0700,
                  FollowLinks follow = kFollow)
   {
@@ -185,3 +193,21 @@ struct Config {
     }
   }
 };
+
+template<> struct std::formatter<Config::Mode> : std::formatter<const char *> {
+  using super = std::formatter<const char *>;
+  auto format(Config::Mode m, auto &&ctx) const
+  {
+    using enum Config::Mode;
+    switch (m) {
+    case kStrict:
+      return super::format("strict", ctx);
+    case kBare:
+      return super::format("bare", ctx);
+    case kCasual:
+      return super::format("casual", ctx);
+    default:
+      err<std::logic_error>("Config::Mode with bad value {}", int(m));
+    }
+  }
+};