Blinker
⚠️ SLOP WARNING: This project was vibe coded. Proceed with caution.
Blinker is a CLI LLM coding agent that is built around safety. Named after horse blinkers, the core concept is similar: limit what your LLM can see and act on, so it stays on track and doesn't stray into parts of your filesystem where it has no business being. Access to different parts of your filesystem are managed by a set of policies, which give you fine-grained control over what your want your agent to be able to do (with or without supervision).
Access is controlled by bwrap on Linux and sandbox-exec on Mac, which gives
kernel-level enforcement of different filesystem and network permissions.
Agents are presented with a set of capabilities, all of which are funneled
through a set of guiding policies, which can be customized per-project.
It comes as two commands. blinker is the sandbox: it runs any program
under a named policy (see The sandbox command).
blinker-agent is the coding agent, which runs its commands in the same
sandbox.
Pre-commitment
Unlike other sandboxing approaches, agents are not presented with a single
bash tool that lets them run code that is judged safe or unsafe by a regex or
a classifier. Instead, agents are presented with a tool that lets agents
self-classify what they are doing as safe or unsafe. Safe actions run
immediately under a strictly locked down security policy which strictly
prevents any unsafe activities. For example, you might want to configure your
project so that all read/write activity in the project directory is allowed
without prompting the user (e.g. find . -name '*.go' or sed -i 's/foo/baz/g' *.go), but only allow writes to the .git/ directory (e.g. git commit -a)
with explicit user confirmation. With that policy, an agent can choose to invoke
Safe_Bash("find . -name '*.go'") or Unsafe_Bash("git commit -a"). The
former will run without prompting the user and the latter will require manual
confirmation. If the LLM runs Safe_Bash("git commit -a"), there will be no
user prompt, but the command will fail due to filesystem permission errors, and
the LLM will be instructed that it must use the unsafe tool (with user
confirmation) instead if it wants write access to .git/.
Install and run
Blinker needs Go to build, and bubblewrap
(bwrap) on Linux; macOS has sandbox-exec built in.
make # builds ./blinker and ./blinker-agent
sudo make install # or: make install PREFIX=~/.local
cd your/project
blinker-agent # the first run asks which LLM backend to use
Blinker works with any OpenAI-compatible API: Anthropic, OpenAI, llama.cpp,
vLLM, SGLang, Ollama, and others. blinker-agent setup (or /setup inside it)
chooses the backend again. Inside blinker, /help lists the commands, and
/policy edits the project's access policies.
Usage
Blinker runs as a REPL inside your project directory. On first launch it asks which
LLM backend to use; configure or switch backends later with /setup. Throughout a
session, type / followed by a command name for built-in actions:
/help— list all available commands and their descriptions./setup— choose or change the LLM backend and API settings./config— edit the settings inagent.jsonandconfig.json, one at a time, with help for each. Enter edits a value, toggles an on/off setting, or steps through a setting's choices; lists are separated by commas. Each change is checked as a loaded file is and saved at once, and applies the next time blinker starts. Its last row opens your ownAGENTS.mdin$EDITOR./policy— open the project's two access policies in$EDITOR;/policy globalopens the agent's own, which every project's include. Blinker reloads policies when the editor exits./policy REQUEST— have the model propose a policy change from a natural-language request, such as/policy let Safe_Bash download files from crates.io. You review the diff before anything is saved./show PATH...— grant the model temporary read access to paths outside the current policy, e.g./show ../other-project. Use/hide PATH...to revoke./tool edit NAME— open a custom tool script in$EDITOR. New tools start from a template./compact— free context: replace all but the latest five tool outputs with a short note, and have the model write notes on the conversation so far, which replace all but its latest steps. Your requests are kept word for word. The saved history keeps everything./clear— end the current conversation and start fresh.
Blinker compacts on its own when a conversation passes three quarters of the model's
context: first by removing old tool output, then, if that is not enough, by
summarizing. It asks the server for the context size (vLLM, SGLang and llama.cpp say);
if the server does not say, /setup asks you, and stores it as context_window in
agent.json.
Instructions
When a conversation starts, blinker adds two AGENTS.md files, if they exist, to the
model's system prompt, and says which it loaded:
~/.config/blinker/AGENTS.md: your own instructions, for every project. Edit it from/config.AGENTS.mdat the top of the project: the project's instructions. Blinker reads it inside the sandbox in safe mode, as the model's Read would, so the policy decides whether it can be read. The model is told the project wrote it, not you, and that it cannot change blinker's rules or the policy.
Each file is read once per conversation, so the model cannot change its own system
prompt by rewriting one; /clear picks up changes. A file over 32 KB, or not UTF-8
text, is skipped with a notice. The model is told to read an AGENTS.md in a
subdirectory before working there. Blinker reads no other instruction files, such as
CLAUDE.md; a symlink named AGENTS.md works.
Approvals
When a tool needs your approval, blinker shows the proposed diff. You can:
- Allow — accept the change exactly as proposed.
- Deny — reject it, optionally telling the model what to do differently.
- Edit in vimdiff — adjust the diff yourself; whatever you save is written, and the model sees how it differs from its original proposal.
On Linux, an Unsafe_Bash script can also be previewed, so you approve
what it changes rather than how it's written (a sed -i, say). It runs with
its writes kept aside, in overlays, and you see the files it would change, as
a diff, with its output. Apply the changes makes exactly those, and only
if nothing they touch has changed since; Discard them keeps none, and the
model sees neither the changes nor the output. A preview has no network,
and keeps what it writes outside the project, such as caches and $TMPDIR,
only for its own run. It can still use Unix sockets and FIFOs in
directories its policy lets it read, as any command can (see Known
limitations): a preview is safe only from what its
policy keeps from it.
A typical workflow looks like:
cd my-project # navigate into a project
blinker # start a new session
# describe what you want done; blinker works through it
/clear # start over when needed
Built-in tools
- Read(path) - read a file's contents
- Write(path, contents) - write to a file
- Patch(path, diff) - apply a diff to a file
- Safe_Bash(script) - run a bash script without user confirmation
- Unsafe_Bash(script) - run a bash script with user confirmation
Blinker also ships these as tools, which you can change or replace:
- Define(name) - show where a name is defined, using Universal Ctags
- Usage(name) - show where a name is used (ripgrep, or grep)
- Commit(message, files...) - commit the given files to git, with hooks off,
adding
Co-authored-by: blinker (MODEL) <blinker@bruce-hill.com>to the message - Fetch(url) - fetch a page from a list of documentation sites and package
registries, save it as Markdown in the session's temp directory, and tell
the model its headings' lines and where the URL's
#fragmentis. The model then reads the parts it needs with Read or Safe_Bash, which don't ask you. curl downloads the page in the sandbox; blinker converts it itself, so Fetch has no script to change, though aFetch.shof yours replaces it. It asks before each call, since a URL can carry anything the model has read; to let it run without asking, allow it in a safe policy:tool allow Fetch
For all tools, permissions are controlled by the access policy. The file tools do their reading and writing inside the sandbox too, so the kernel checks every access.
When a write needs your approval, blinker shows the whole diff. You can allow it, deny it (optionally telling the model what to change), or edit it in vimdiff first: the current file is on the left, read-only, and the proposed one on the right. Whatever you save is written, and the model is told how it differs from what it proposed.
Write replaces an existing file only if the model has read it, and it has not
changed since: edits you or a command made in the meantime are not lost
without the model seeing them. If the file changes while you decide on a
write, nothing is written. (Patch needs no Read: its hunks must match the
file as it is, and the rest of the file is kept.) After /clear, or once
compaction drops old tool outputs, the model must read files again.
When Commit is allowed without asking, blinker commits and shows the commit's
hash and message. When it asks, you review the message and the full diff in
a scrolling pane (↑/↓, pgup/pgdn, home/end) and can commit, deny (optionally
telling the model what to change), or edit it in vimdiff: the message and
each changed text file get a tab, and the edits to the files are written to
the working tree. Then you review the edited commit again. Blinker commits
exactly the tree you reviewed, and only the files given. Anything else that
was staged stays staged, and git hooks do not run. A Commit.sh of your own
replaces this with your script; /tool edit Commit starts one from the
template, since the built-in Commit is part of blinker, not a script.
To render diffs with another program, such as
delta, set diff_command in
agent.json to the program and its arguments, for example
["delta", "--width={width}", "--wrap-max-lines=unlimited"]. It reads the
unified diff on stdin and writes it to stdout; {width} becomes the terminal
width. Only its colors are kept, and if it fails, blinker shows the diff
itself. Approval prompts show whatever the program prints, so choose options
that show the whole diff (delta truncates very long lines unless told not to).
Tools
A tool is a bash script that describes itself in a header. The model can call
it like a built-in tool, with checked arguments. Tools live in
~/.config/blinker/tools/<Name>.sh. A file there replaces the built-in tool of
the same name.
#!/bin/bash
# blinker-tool
# description: Format a Go file in place with gofmt.
# arg file path: The Go file to format.
# write: **
gofmt -l -w -- "$1"
The header runs from # blinker-tool to the first line that isn't a comment:
description: TEXTtells the model what the tool does. Repeat the line for more text.arg NAME TYPE: TEXTdeclares one argument. The type isstring,path,integer,booleanorenum(a|b|c). A...after the last argument's type (path...) makes it take a list.hide: PATH,read: PATHandwrite: PATHgive the tool file access beyond the policy while it runs.network: HOSTlets it reach a host. The floor still applies to tools.timeout: DURATIONsets its time limit, such as5m.
Blinker checks the arguments and passes them to the script as $1, $2, and
so on (a list as the rest of "$@"). They are never pasted into the script's
text. Paths are passed absolute, and must be readable by the tool.
A tool's extra access reaches everything its script runs. Never give extra
access to a tool that runs code from the project (make, test runners,
package scripts): the model can write that code. A tool with no extra access is
just a named command, as safe as Safe_Bash.
A tool does nothing until a policy allows it. A file dropped into the tools
directory can't grant itself access. A policy's tool rules, allow or
deny, say which tools may run (see The agent's
policies):
- One the safe policy allows runs without asking, on top of
Safe_Bash's access. - One only the unsafe policy allows runs once the user approves each call,
on top of
Unsafe_Bash's access. - One neither allows is kept from the model.
# type access pattern
tool allow Format
agent-safe allows Define and Usage, and agent-unsafe all four built-in
tools, so Commit and Fetch ask first.
/tool edit NAME opens a tool in $EDITOR. A new tool starts from a
template.
Policy control
A policy is a text file with one rule per line. For any path, network host, variable or tool, the last rule that matches wins:
# type access pattern
include agent-safe
file write .
file hide **/.env
file write python_project/.env
file read ~/go
host allow proxy.golang.org
Reading top to bottom, this policy says:
- First come the rules of the policy named
agent-safe. - Everything in the project can be read and written.
.envfiles are hidden, exceptpython_project/.env.~/gois readable.- Commands may fetch Go modules.
Every rule has three columns: the type (file, host, env or tool),
the access, and the pattern. The columns are separated by spaces or tabs,
and the pattern is the rest of the line, so it can contain spaces. A line
can't end with a space, since a space there would be invisible ([ ]
matches a space at the end of a name). Lines starting with # are
comments, and can't share a line with a rule. The comment lines directly
above a rule are its note: blinker shows the note when you review a change
to the rule, and removes it along with the rule when /show or /hide
replaces the rule. Comments separated from a rule by a blank line, and the
# type access pattern header, are never removed.
include NAME stands for the rules of the named policy NAME where the
line is, so the rules after it win where both match (see The sandbox
command for where named policies are). The agent
runs commands under two policies for each project, a safe one and an
unsafe one (see The agent's policies), and
blinker runs a program under one.
A file rule's access is hide, read, or write (which includes read).
Paths are globs, and every one is anchored. Unlike in .gitignore, a name
without a / doesn't match at any depth:
- A path starting with
/is absolute, and one starting with~/is under your home directory. - Anything else is relative to the project, and
.is the project itself.go.modis only the one at the top of the project;**/go.modis every one. - Symlinks in a path outside the project are followed, up to its first
wildcard:
~/.vimrclinked into a dotfiles repo covers the file there, and commands see the link. A rule whose link a command could replace is an error. Symlinks in the project are never followed, since its contents aren't yours to vouch for. - A trailing
/says the path is a directory (~/.cache/uv/). If it is missing, blinker makes it before each command when the rule lets commands write it and it is outside the project, as a tool's cache in a home commands can't write must be. If commands may not write it, but may write where it would be, it is blocked as an empty, read-only directory, not an empty file, so a program that lists it finds nothing.
Within a path:
*and?match within one path component.[abc]matches one of a set of characters.- A
**component matches any number of directories, including none. - Unlike in the shell, names starting with
.are not special:*and?match a leading., so*.goincludes.x.go, and**reaches into hidden directories such as.git. This makeshiderules thorough (**/*.keyhides.secret.key), but means a broad grant such asfile write write *also covers.envand.git, unless a later rule hides them. - A path that is exactly
$NAME, such as$PATH, covers each directory in that environment variable (separated by:). Relative entries are skipped, and so are home, the project, and directories containing them, soPATH=$HOME:...can't open your whole home directory. - A path may start with
${NAME:-default}: the directory the variable names, or else the default, then the rest of the path, as in${XDG_CACHE_HOME:-~/.cache}/uv. The default may be another such (${UV_CACHE_DIR:-${XDG_CACHE_HOME:-~/.cache}/uv}). The variable counts only if it holds one absolute path, without wildcards, that neither is nor contains home or the project; otherwise the default does, as the XDG spec says of a relative path. The installed policies use this to follow the XDG base directories and the variables tools use to move theirs, such asCARGO_HOME, and pass those variables on, so the program looks where the rule grants.
A rule that matches a directory covers everything inside it, so .git and
.git/** mean the same thing.
Host rules work the same way. A host is a name, *.example.com (its
subdomains only), or *. The access is allow, deny, or ask, which
asks you, under the agent's unsafe policy or blinker (see The sandbox
command); Safe_Bash never asks, so for it ask
denies. A host no rule matches is denied; agent-unsafe has host ask *, so Unsafe_Bash asks about hosts no other rule names. You can allow
a host for the rest of the script, for Unsafe_Bash until blinker exits,
or always, which adds a rule for it to the project's unsafe policy.
Environment variables
Commands get only the environment variables the policy passes them. A few
basics always are (PATH, HOME, USER, LANG, LC_*, TERM and
similar) unless a rule says otherwise. Env rules pass more, by name or glob:
env allow GOFLAGS
env deny GITHUB_TOKEN
The last matching rule wins, and the access is allow or deny. The
toolchains policy, which the agent's include, passes common toolchain
variables (GOPATH,
CARGO_HOME, CC, LD_LIBRARY_PATH, C_INCLUDE_PATH and so on), and has
read rules for $PATH, $LD_LIBRARY_PATH, $LIBRARY_PATH, $CPATH,
$C_INCLUDE_PATH, $CPLUS_INCLUDE_PATH, $PKG_CONFIG_PATH and $MANPATH.
Its rules for configuration and caches follow the XDG base directories
and the variables tools move theirs with, such as
${CARGO_HOME:-~/.cargo} (see the paths above).
Blinker also sets a few variables for every command, over your own:
AGENT=blinker and AI_AGENT=blinker (the emerging conventions for
telling tools an agent is running them), NO_COLOR=1, PAGER,
GIT_PAGER and MANPAGER set to cat, GIT_TERMINAL_PROMPT=0,
GIT_EDITOR=true and PYTHONUNBUFFERED=1, so tools don't color their
output or wait for a terminal that isn't there. AI_MODEL is the model's
name, so a script can credit it (Co-authored-by: $AI_AGENT ($AI_MODEL));
a name with spaces or unusual characters is left out.
A variable passed to a command can be printed by it, so the model can see it. One only the unsafe policy passes, the model sees only through a script you approved.
The agent's policies
Each project has two policies, in blinker's config directory, never in the project, so a cloned repository can't grant itself access:
~/.config/blinker/policies/projects/<name>/safe.policy: whatSafe_Bash, and the file tools without asking, get.~/.config/blinker/policies/projects/<name>/unsafe.policy: whatUnsafe_Bash, and the file tools once you approve, get.
<name> is the project's path from your home directory (or the whole
path, outside it) with a short hash of the whole path, such as
src-blinker-1a2b3c4d. Each file's project line names the project's
directory, such as project /home/me/src/blinker. They are separate: a
change to one doesn't change the other.
A new project's policies start by including the agent's policy for their
mode, and the rules after that win where both match. These are named
policies, built in, which /policy global copies to
~/.config/blinker/policies/ for you to change for every project:
agent-safe: the project, toolchains, dotfiles and caches can be read, and Define and Usage run.agent-unsafe: the project, state and build caches can be written too,Unsafe_Bashasks before connecting anywhere, and every tool runs once you approve.agent-safestarts fromread, andagent-unsafefromwrite, each withtoolchains;readandwriteend withhide-secrets, which hides.envfiles.
An include is concatenation, so a broad rule after it (such as .) undoes
narrower ones it included. Blinker warns about any rule that a later rule
completely overrides.
Inside blinker:
/policyopens the project's two policies in$EDITOR, and/policy globalthe agent's. Blinker loads them again when the editor exits; if one is not valid, the previous policies stay in force./policy REQUESThas the model change a policy as you ask, such as/policy let Safe_Bash fetch crates from crates.io. The model sees only your request and the policies, not the conversation, so nothing it read earlier can steer the change. It changes one file at a time, the project's unless you ask for every project. You review the change as a diff, with each added or removed rule in plain words and any new warnings, and can save it, edit it in vimdiff, ask for changes, or cancel. Nothing is saved without your approval./show PATH...lets the model read paths without asking, such as/show ../other-project, with a rule in the safe policy, and in the unsafe one too if it gave less./hide PATH...keeps the model from paths in every mode, with a rule in each policy.
/show and /hide add a rule to the end of each policy they change, and
delete the rules of its own that the new one supersedes: the rule for the
same path, and, for /hide, the rules for anything inside it. /show
never reveals what a narrower rule hides: rules for things inside the path
(such as **/.env*) move after the new one, or are copied there from what
the policy includes. A pattern that hides the path itself and may hide
things inside it too (**/.env* for a directory .envs) can't be moved
either way, so /show refuses, and you edit the policy with /policy.
The floor
Some access is never possible, whatever the policy says:
- Credentials can't be read:
~/.ssh,~/.aws,~/.gnupg, and*.pemand SSH key files in the project. - Files that would change what runs outside the sandbox later, whatever
program you run, can't be written: shell and editor startup files
(fish's and zsh's directories in
~/.configtoo), git's, tmux's and direnv's configuration, what runs at login (systemd user units,~/.config/autostart,~/.config/environment.d, Plasma's session scripts), and the directories in$PATH(other than ones in the project). Those in~/.configand~/.local/shareare protected where$XDG_CONFIG_HOMEand$XDG_DATA_HOMEput them too. A program's own configuration elsewhere in~/.configis its policy's to give. - Git repositories' configuration and hooks can't be written either, since
git runs them the next time you use it and no diff shows them: the
config,config.worktreeandhooksof the project's repository (or the one holding the project), of its submodules and worktrees, and of other repositories in the project. A.gitfile (a worktree's) can't be pointed elsewhere. The rest of.gitis the policy's to give, so commands can still commit, but notgit init,git configorgit remote add: run those yourself. Repositories found by pattern are protected only as they exist: one a command creates in the project, or a submodule's missinghooks, is not, so check before using git there. - Other users' homes can be neither read nor written: each home in
/etc/passwd(service accounts' too, such as/var/lib/postgres) and in/home(/Userson macOS). Run as root, the OS's permissions no longer keep a command out of them, and~is root's. A home holding the project (sudo blinkerin your own project, say) is treated as yours is: only its credentials and startup files are protected. - The system's secrets can't be read, for the same reason:
/etc/shadow, SSH host keys,/etc/ssl/private, Wi-Fi and VPN keys, and others (SystemSecretsininternal/sandbox/bwrap.goandseatbelt.go). The list can't be complete, so avoid opening more of the system than a command needs when running it as root. - Neither is possible for blinker's own config, policies or history.
- Run from your home directory (or any user's), or from a system temp dir
itself (
/tmp, not/tmp/foo), blinker can read and write nothing in it, so you can ask the model questions without it seeing your files. Commands still have their own temp dir, and the system's directories (/usr). - Commands never get
BLINKER_*, agent sockets (SSH_AUTH_SOCKand others), variables that make the shell run code first (BASH_ENV,ENV,BASH_FUNC_*,PROMPT_COMMAND),LD_PRELOAD,LD_AUDIT,DYLD_*, or proxy settings other than blinker's. The agent's commands never get its API key variables (ANTHROPIC_API_KEY,OPENAI_API_KEY) either;blinker, which has no key of its own, lets a policy pass them, as theclaudeandcodexpolicies do.
The sandbox command
blinker [-p POLICY[+POLICY...]] [--preview] COMMAND [ARG...] runs a program in the current
directory under the agent's sandbox, with a named policy in place of
the agent's two: the command's own policy if it has one (see Commands'
policies), else read, unless -p (or --policy)
names another. Options go before the command; everything from the command's name on is its own (put
-- before a command whose name starts with -). A named policy is a policy
in a file of its own:
# type access pattern
file write .
file hide **/.env*
host allow proxy.golang.org
env allow GOFLAGS
blinker ./untrusted-script
blinker -p build go test ./...
blinker -p write+web ./fetch-and-convert
On Linux, --preview runs the command the way the agent previews a
script: with no network and its writes kept aside. When it ends, blinker
shows on the terminal what it would change in the project, as a diff, and
asks whether to apply that; anything else it wrote, such as caches, is
dropped. Without -p, a preview's policy is the command's own if it has
one, else write, not read. With no terminal to ask on, the changes are
discarded. Blinker exits
with the command's status if the changes are applied, and otherwise with the
command's failure, or 1.
blinker --preview ./reformat-everything
-p a+b joins policies: a's rules, then b's, so b's win where both
match, as if one policy included each in turn. A name with no policy of
its own means that command's (-p go+rustc).
Blinker comes with these policies, built in (make install also puts them in
$(PREFIX)/share/blinker/policies):
-
read: read the project and the programs in$PATH; write nothing but$TMPDIR; no network. -
write:read, but the project is writable, and commands get the XDG base directory variables. -
build:write, plus what toolchains need: their configuration, libraries and variables, their caches ($XDG_CACHE_HOMEand so on), which it may write, and the policies ofgo,cargo,npm,pnpmanduv, which it includes, with their registries. A program you do not trust can leave things in those caches that your later builds outside the sandbox use, or send what it reads to a registry. -
web: any host, and nothing else, not even the programs in$PATH: join it to another, as in-p write+web. -
agent-safe,agent-unsafe, andtoolchainsandhide-secrets, which they include: the agent's (see The agent's policies).blinkerignores their tool rules.
read and write have no network access. A policy can include others
(see Commands' policies). Blinker looks for POLICY.policy in
~/.config/blinker/policies/ first, then in blinker/policies/ under
$XDG_DATA_HOME (~/.local/share) and each of $XDG_DATA_DIRS
(/usr/local/share and /usr/share), so a file of your own replaces an
installed one. Failing those, it uses the copy built into it, so
make && ./blinker COMMAND works without installing anything. blinker --help lists the policies it finds. On macOS with
another prefix (such as Homebrew's /opt/homebrew), add its share to
$XDG_DATA_DIRS. Commands can neither read nor write any of these
directories.
Commands' policies
A command named without a path, such as blinker claude, gets its own
policy if it has one: commands/claude.policy in those same directories.
Only a program found in $PATH outside the current directory gets it, so
a project's own claude script, or one in a directory of the project in
$PATH, runs under read; blinker runs the program it found.
A policy can include another, by name (a command's, if there is no
policy by that name), or commands/NAME for a command's outright. The line stands for that policy's rules, where it is, so the
rules after it win where both match:
include write
file write ${XDG_CACHE_HOME:-~/.cache}/uv/
host allow pypi.org
An included policy can include others in turn; each is included once,
where it is first included, and an include that leads back to a policy
it is in is an error. An included rule makes no missing directory: only
the policy you run makes its own, so blinker claude, whose policy
includes build, which includes npm's, does not make ~/.npm.
Blinker comes with policies for these commands, meant to let each run as it would outside the sandbox, with nothing to warn about: the program's own files, and the hosts it needs, but not the rest of your home or its telemetry. That leaves ways out, such as a program changing its own settings or plugins, which run outside the sandbox later; the header of each file says which.
claude,codex,gemini:build, plus the agent's own directory, and the network, but not the vendor's telemetry. Claude Code's directory is$CLAUDE_CONFIG_DIR, or else$XDG_CONFIG_HOME/claude(~/.config/claude), which the policy setsCLAUDE_CONFIG_DIRto: with its configuration in~/.claude.json, it would need to write home itself. Set the variable the same way outside the sandbox, and move~/.claude/*and~/.claude.jsonthere, so both share it.vim,nvim:build, for the language servers they run, plus their configuration, plugins and state, and GitHub and the package registries for plugin managers and Mason.nano:write, plus its configuration and history.curl,wget:write, plus any host, their configuration and~/Downloads, so they save files there or in the project, and nowhere else. wget keeps its HSTS records in~/.wget-hstsonly once that file exists, as it cannot make a file in home.go,cargo,npm,pnpm,uv:write, plus the package registry and the tool's own directories.npxincludesnpm's.make:build.rustc:write, plus the toolchains rustup installed.java, andjavac,jarandjshell, which include it:write, plus the JDK$JAVA_HOMEnames and the user preferences in~/.java.
A host ask rule has blinker ask you before the command connects. In the
box, the status line asks, y allows the host until the command ends, and
n denies it; until you answer, your keys are blinker's, not the
command's. With the command's output piped or redirected, blinker asks on
your terminal, and holds what the command writes there until you answer:
the command writes to a terminal of its own, which blinker copies to
yours. Ctrl-C still stops the command there: a program before blinker in
a pipeline that reads your terminal (cat | blinker ...) gets your keys
first. With no terminal at all (run from cron, say), nobody can be
asked, so blinker denies the host, and says so.
A host a policy denies by name, such as a telemetry service, is denied
quietly; blinker reports only the hosts no rule names. A policy can also
set a variable for its command, in place of your own value: env set CLAUDE_CONFIG_DIR ${XDG_CONFIG_HOME:-~/.config}/claude (a leading ~/ is
home, and ${NAME:-default} is as in a path). It can't set one the floor
keeps from commands, or one blinker sets itself.
The floor, the sandbox settings in config.json and the basic environment
variables apply as they do in the agent, but nothing of the agent's
policies does: a program installed under home needs a rule such as
file read $PATH. The command gets none of the variables the agent sets to
tell it an agent runs it. It exits with the command's status, or 125 if
blinker could not run it.
Run from a terminal, the command gets a terminal of its own, drawn in a box
in yours: blinker runs it on a pseudo-terminal and draws what
a terminal emulator makes of its output, so only text and colors reach your
terminal. The command cannot set your clipboard or window title, answer
your terminal's queries into your shell, leave your terminal in a strange
mode, or draw outside the box, whose border and status line are blinker's.
The command has nearly your whole window, and the box grows with its
output up to that size; a full-screen program (an editor, htop) gets it
all at once, and the mouse if it asks for it. A command done within 50ms,
as most are, shows no box at all. Ctrl-] stops the command.
When it exits, the box goes and blinker prints all its output into your
terminal's history (the last 100,000 lines that scrolled off the top, and
the screen), and exits with its status. Piped or redirected, the command
gets blinker's stdin and stdout as they are.
What the command shows in the box is still its own: while it runs, it can draw anything inside the box, a password prompt included.
Where things are kept
~/.config/blinker/config.json: the sandbox's settings, which both commands use.~/.config/blinker/policies/: your named policies, the agent's included; the installed ones are in/usr/local/share/blinker/policies/(or under yourPREFIX).~/.config/blinker/agent.json: the agent's settings, including the API key (readable only by you).~/.config/blinker/policies/projects/: the agent's policies for each project.~/.config/blinker/tools/: your tools.~/.config/blinker/AGENTS.md: your instructions for the model.~/.local/state/blinker/history/: conversations, as XML.
$XDG_CONFIG_HOME and $XDG_STATE_HOME move these, on macOS too.
Known limitations
- On Linux, a file the policy hides (such as
.env) still shows up in directory listings, as an empty character device. Its contents are hidden. - Globs are matched against the files that exist when a command starts. A file
that appears while it runs, whether the command or you create it, isn't
covered by them, and the floor's globs (
*.pemand so on) are only checked within the project. - On Linux, write access is per directory for creating files. A path a rule
names that doesn't exist yet is kept from being created: while a command
runs, an empty read-only file stands in its place (blinker removes it
once no command is running). But a command may create a file a pattern would hide or make
read-only (a new
.envwhere**/.env*hides them), since patterns only match what exists. And where only a pattern grants writing (**/*.mdin a read-only project),Safe_Bashcan edit existing matching files but not create new ones;WriteandPatchcan. - On Linux, a hidden or read-only path is protected by a mount over the file
that was there when the command started. If that file is replaced while a
command runs (saved by writing a new file and renaming it over the old,
as
sed -i,git configand many editors do), the running command sees the new file with the access of the directory it's in: a hidden.envbecomes readable, and a read-only.git/configwritable where the project is. So don't replace or create secrets while a command, such as a dev server, is running. macOS checks paths by name and doesn't have this limit. - A directory with a protected path inside it can't be renamed or removed by a command (on Linux it is a mount point), since the rule would no longer apply under the new name.
- A preview applies files' contents, modes, symlinks, new directories and
removals. Hard links it makes become separate files, and extended
attributes it sets aren't carried over. What it writes is held in the
session's dir under
/tmpuntil applied or discarded, in memory where/tmpis a tmpfs. A preview can still connect to Unix sockets, and open FIFOs, in directories it may read, as any command can: the services behind them (Docker's, an SSH agent's) act on what it sends them, outside the preview. A writable directory with a mount under it (inside a container, say) can't be previewed: the overlay needs a single file system. A file a rule makes writable on its own is read-only in a preview, and a preview that leaves a file it can't read (chmod 000) fails. - The network proxy decides by host name, and can't see inside a TLS tunnel: a command allowed one host can reach any other site served by the same front (a CDN) by naming it inside the encrypted connection.
- The network namespace doesn't cover Unix sockets in the filesystem: a
command can connect to a server's socket in any directory it can read.
/runand the system's temp dirs, where most of them live, aren't visible at all, except for a project inside a temp dir (/tmp/foo, not/tmpitself): there the rest of that temp dir gets your rules, as any other directory does, so a rule that shows it shows its sockets too (tmux's, X11's). Each session still has its own temp dir. - On macOS there is no process namespace, so a background process a command starts can outlive it, keeping that command's access.
- The macOS backend hasn't been tested yet.
- Without a known context size, long conversations are compacted only when you ask
(
/compact), so they can outgrow the model's context window.
Dependencies
Blinker is written in Go and has a small set of dependencies focused on building a safe, efficient CLI interface:
| Dependency | Purpose |
|---|---|
| bubbletea/v2 | Terminal UI framework (tea model-based approach) |
| bubbles/v2 | Reusable TUI components (list, textinput, spinner, etc.) |
| lipgloss/v2 | CSS-like styling for terminal elements |
| x/ansi | ANSI escape sequence parsing and rendering |
| x/term | Secure terminal input handling |
| x/sys | Low-level OS primitives and syscalls |
All other dependencies are transitive, pulled in by the Charm ecosystem libraries above. Blinker deliberately keeps its direct dependency count low — six packages total — to minimize attack surface and align with its security-first design philosophy.
The build also requires:
- Go 1.27 or later (
go buildand module resolution) - On Linux: bubblewrap (
bwrap) for sandbox enforcement - On macOS:
sandbox-exec(built into the OS)
No network access or download steps are needed beyond standard go mod download.
Safety by design
Unlike other coding agent projects, this project is designed to use a language with good safety guarantees and avoid having a large dependency graph full of untrusted code.