Download
777 lines 39.8 KiBMarkdownModified 2026-10-31 raw

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 in agent.json and config.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 own AGENTS.md in $EDITOR.
  • /policy — open the project's two access policies in $EDITOR; /policy global opens 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.md at 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

  1. Read(path) - read a file's contents
  2. Write(path, contents) - write to a file
  3. Patch(path, diff) - apply a diff to a file
  4. Safe_Bash(script) - run a bash script without user confirmation
  5. Unsafe_Bash(script) - run a bash script with user confirmation

Blinker also ships these as tools, which you can change or replace:

  1. Define(name) - show where a name is defined, using Universal Ctags
  2. Usage(name) - show where a name is used (ripgrep, or grep)
  3. 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
  4. 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 #fragment is. 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 a Fetch.sh of 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: TEXT tells the model what the tool does. Repeat the line for more text.
  • arg NAME TYPE: TEXT declares one argument. The type is string, path, integer, boolean or enum(a|b|c). A ... after the last argument's type (path...) makes it take a list.
  • hide: PATH, read: PATH and write: PATH give the tool file access beyond the policy while it runs. network: HOST lets it reach a host. The floor still applies to tools.
  • timeout: DURATION sets its time limit, such as 5m.

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.
  • .env files are hidden, except python_project/.env.
  • ~/go is 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.mod is only the one at the top of the project; **/go.mod is every one.
  • Symlinks in a path outside the project are followed, up to its first wildcard: ~/.vimrc linked 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 *.go includes .x.go, and ** reaches into hidden directories such as .git. This makes hide rules thorough (**/*.key hides .secret.key), but means a broad grant such as file write write * also covers .env and .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, so PATH=$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 as CARGO_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: what Safe_Bash, and the file tools without asking, get.
  • ~/.config/blinker/policies/projects/<name>/unsafe.policy: what Unsafe_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_Bash asks before connecting anywhere, and every tool runs once you approve.
  • agent-safe starts from read, and agent-unsafe from write, each with toolchains; read and write end with hide-secrets, which hides .env files.

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:

  • /policy opens the project's two policies in $EDITOR, and /policy global the agent's. Blinker loads them again when the editor exits; if one is not valid, the previous policies stay in force.
  • /policy REQUEST has 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 *.pem and 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 ~/.config too), 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 ~/.config and ~/.local/share are protected where $XDG_CONFIG_HOME and $XDG_DATA_HOME put them too. A program's own configuration elsewhere in ~/.config is 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.worktree and hooks of the project's repository (or the one holding the project), of its submodules and worktrees, and of other repositories in the project. A .git file (a worktree's) can't be pointed elsewhere. The rest of .git is the policy's to give, so commands can still commit, but not git init, git config or git 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 missing hooks, 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 (/Users on 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 blinker in 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 (SystemSecrets in internal/sandbox/bwrap.go and seatbelt.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_SOCK and 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 the claude and codex policies 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_HOME and so on), which it may write, and the policies of go, cargo, npm, pnpm and uv, 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, and toolchains and hide-secrets, which they include: the agent's (see The agent's policies). blinker ignores 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 sets CLAUDE_CONFIG_DIR to: 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.json there, 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-hsts only 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. npx includes npm's.
  • make: build.
  • rustc: write, plus the toolchains rustup installed.
  • java, and javac, jar and jshell, which include it: write, plus the JDK $JAVA_HOME names 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 your PREFIX).
  • ~/.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 (*.pem and 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 .env where **/.env* hides them), since patterns only match what exists. And where only a pattern grants writing (**/*.md in a read-only project), Safe_Bash can edit existing matching files but not create new ones; Write and Patch can.
  • 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 config and many editors do), the running command sees the new file with the access of the directory it's in: a hidden .env becomes readable, and a read-only .git/config writable 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 /tmp until applied or discarded, in memory where /tmp is 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. /run and 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 /tmp itself): 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 build and 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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
# 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](https://en.wikipedia.org/wiki/Blinkers_(horse_tack)), 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](#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](https://github.com/containers/bubblewrap)
(`bwrap`) on Linux; macOS has `sandbox-exec` built in.

```sh
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 in `agent.json` and `config.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 own
  `AGENTS.md` in `$EDITOR`.
- `/policy` — open the project's two access policies in `$EDITOR`; `/policy global`
  opens 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.md` at 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](#known-limitations)): a preview is safe only from what its
policy keeps from it.

A typical workflow looks like:

```sh
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

1. Read(path) - read a file's contents
2. Write(path, contents) - write to a file
3. Patch(path, diff) - apply a diff to a file
4. Safe_Bash(script) - run a bash script without user confirmation
5. Unsafe_Bash(script) - run a bash script with user confirmation

Blinker also ships these as [tools](#tools), which you can change or replace:

6. Define(name) - show where a name is defined, using
   [Universal Ctags](https://ctags.io)
7. Usage(name) - show where a name is used (ripgrep, or grep)
8. 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
9. 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 `#fragment` is. 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 a `Fetch.sh` of 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](https://github.com/dandavison/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.

```bash
#!/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: TEXT` tells the model what the tool does. Repeat the line for
  more text.
- `arg NAME TYPE: TEXT` declares one argument. The type is `string`, `path`,
  `integer`, `boolean` or `enum(a|b|c)`. A `...` after the last argument's
  type (`path...`) makes it take a list.
- `hide: PATH`, `read: PATH` and `write: PATH` give the tool file access beyond
  the policy while it runs. `network: HOST` lets it reach a host. The floor
  still applies to tools.
- `timeout: DURATION` sets its time limit, such as `5m`.

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](#the-agents-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.
- `.env` files are hidden, except `python_project/.env`.
- `~/go` is 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](#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](#the-agents-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.mod` is only the one at the top of the project; `**/go.mod` is every
  one.
- Symlinks in a path outside the project are followed, up to its first
  wildcard: `~/.vimrc` linked 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 `*.go` includes `.x.go`, and `**` reaches into
  hidden directories such as `.git`. This makes `hide` rules thorough
  (`**/*.key` hides `.secret.key`), but means a broad grant such as
  `file write write *` also covers `.env` and `.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,
  so `PATH=$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 as
  `CARGO_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](#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`: what
  `Safe_Bash`, and the file tools without asking, get.
- `~/.config/blinker/policies/projects/<name>/unsafe.policy`: what
  `Unsafe_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_Bash` asks before connecting anywhere, and every tool runs once
  you approve.
- `agent-safe` starts from `read`, and `agent-unsafe` from `write`, each
  with `toolchains`; `read` and `write` end with `hide-secrets`, which
  hides `.env` files.

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:
- `/policy` opens the project's two policies in `$EDITOR`, and `/policy
  global` the agent's. Blinker loads them again when the editor exits; if
  one is not valid, the previous policies stay in force.
- `/policy REQUEST` has 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 `*.pem` and
  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 `~/.config` too), 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 `~/.config` and `~/.local/share` are protected where
  `$XDG_CONFIG_HOME` and `$XDG_DATA_HOME` put them too. A program's own
  configuration elsewhere in `~/.config` is 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.worktree` and `hooks` of the project's repository (or
  the one holding the project), of its submodules and worktrees, and of
  other repositories in the project. A `.git` file (a worktree's) can't be
  pointed elsewhere. The rest of `.git` is the policy's to give, so
  commands can still commit, but not `git init`, `git config` or
  `git 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 missing `hooks`, 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` (`/Users` on 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 blinker` in 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
  (`SystemSecrets` in `internal/sandbox/bwrap.go` and `seatbelt.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_SOCK` and
  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 the `claude` and `codex` policies 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](#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
```

```sh
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.

```sh
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_HOME` and so on),
  which it may write, and the policies of `go`, `cargo`, `npm`, `pnpm` and
  `uv`, 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`, and `toolchains` and `hide-secrets`, which
  they include: the agent's (see [The agent's
  policies](#the-agents-policies)). `blinker` ignores their tool rules.

`read` and `write` have no network access. A policy can `include` others
(see [Commands' policies](#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 sets `CLAUDE_CONFIG_DIR` to: 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.json` there, 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-hsts` only 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. `npx` includes `npm`'s.
- `make`: `build`.
- `rustc`: `write`, plus the toolchains rustup installed.
- `java`, and `javac`, `jar` and `jshell`, which include it: `write`, plus
  the JDK `$JAVA_HOME` names 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 your `PREFIX`).
- `~/.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 (`*.pem` and 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 `.env` where `**/.env*` hides them), since patterns only
  match what exists. And where only a pattern grants writing (`**/*.md` in a
  read-only project), `Safe_Bash` can edit existing matching files but not
  create new ones; `Write` and `Patch` can.
- 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 config` and many editors do), the running command sees
  the new file with the access of the directory it's in: a hidden `.env`
  becomes readable, and a read-only `.git/config` writable 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 `/tmp` until applied or discarded, in memory where
  `/tmp` is 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.
  `/run` and 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 `/tmp` itself): 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](https://github.com/charmbracelet/bubbletea) | Terminal UI framework (tea model-based approach) |
| [bubbles/v2](https://github.com/charmbracelet/bubbles) | Reusable TUI components (list, textinput, spinner, etc.) |
| [lipgloss/v2](https://github.com/charmbracelet/lipgloss) | CSS-like styling for terminal elements |
| [x/ansi](https://github.com/charmbracelet/x/tree/main/ansi) | ANSI escape sequence parsing and rendering |
| [x/term](https://pkg.go.dev/golang.org/x/term) | Secure terminal input handling |
| [x/sys](https://pkg.go.dev/golang.org/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 build` and module resolution)
- On Linux: **[bubblewrap](https://github.com/containers/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.