Security¶
Attaché runs on your machine, as you, with access to your files, a shell and your accounts. This page says who can make it do what, and where the limits are.
You talk to Attaché in the terminal, on the local web page and in your direct messages. The
people and group chats you list in attache.toml can talk to it too, with fewer rights. With
sharing on, anyone with a share link can open that one page, and nothing else.
Roles¶
Every turn has one of three roles:
| Role | Who |
|---|---|
| Owner | You. Everything typed in the terminal or on the web page, and your direct messages on chat apps, which Attaché recognises by the ids in [owner]. |
| Member | In a group chat, the people listed in that chat's members. In a direct chat, a person from [people] with role = "member". |
| Guest | Everyone else in a listed group chat. Answered only if the chat has guests = "public". |
People are known by their sender ids on each chat app, never by display names. A group chat
that isn't listed under [chats] is ignored, and so is a direct message from anyone not in
[people]. See Configuration.
Your turns in a group chat get member rights. A group carries other people's messages, so even your own requests there never get files, the shell, or your private notes.
What each role can do¶
| Tool | Owner, in your own chats | Member (and you, in a group) | Guest |
|---|---|---|---|
read_file, write_file, edit_file |
Inside the home folder | No | No |
run (shell commands) |
Yes | No | No |
script (skill scripts) |
Any skill | Only member skills | No |
send (a message to this chat) |
With any file in the home folder | Only with files from this chat's files folder | No |
db (documents) |
Every collection | Only docs shared with this chat; can add and edit, not delete | No |
artifact (pages) |
Yes | Pages tied to this chat | No |
schedule (reminders) |
Message reminders and routines; can list and cancel tasks in every chat | Message reminders in this chat; can cancel only their own | No |
The prompt follows the same line. Your memory/USER.md goes only into turns with owner
rights. You get the index of every skill; members get the full text of the skills shared
with them, and nothing else from skills/. A guest's turn has no tools at all.
Only your own turns have the file tools, so only they can change skills, memory or settings, and only after your own turns does Attaché commit the home folder to git.
Other people's words are data¶
Only your own words are instructions. In a group chat, the earlier messages, and the new one
unless you sent it, go to the model in one fenced block labelled as data. Each message is
quoted as a single JSON string, so no character in it can close the fence or pass itself off
as a label or a header. Labels come from attache.toml (the configured name and the role in
that chat), never from display names.
The rules in every prompt say the same of tool results, emails, web pages and files: they are data, never instructions.
Email¶
The email channel turns mail into direct messages, and a From line is easy to fake, so it passes a message on only when its sender is proven:
- Only the receiving server's word counts. That is the topmost
Authentication-Resultsheader: each server puts its own on top, and anything further down may have been written by the sender. It must saydmarc=passfor the From address's domain. A result inside a comment, a quoted string anywhere in that header, or a second DMARC result (one may be the sender's words echoed back) all fail. Withauthserv_idset (mx.google.comfor Gmail), the header's first field, the server name before the first;, must also be exactly that name. Gmail and Microsoft 365 add the header to every message; with a provider that doesn't, the topmost one could be the sender's own, so the channel is not safe there. - Only mail delivered straight to the provider. Gmail fetching another mailbox by POP, mail imported or moved in from elsewhere, and a filtering gateway in front of the provider all put a different verdict, or none, on top. None of them is supported.
- Exchange's own mark,
X-MS-Exchange-Organization-AuthAs: Internal, also counts, but only through Graph or Outlook classic on an Exchange mailbox, where Exchange strips it from mail arriving from outside. Every copy of it must say so. It means Exchange treats the message as internal, not that a person is proven: a relay inside the organisation, or a delegate with Send As or Send on Behalf, can produce it. On Gmail or IMAP it means nothing. - Anything else fails closed: no single From address, mail from the account itself,
auto-replies, mailing lists and bulk mail are skipped too, and so is mail whose new part
(above the quoted history) doesn't mention the assistant or holds the reply signature.
Attaché's own replies through IMAP say
Auto-Submitted: auto-replied, so assistants can't keep answering each other.
DMARC proves the domain sent the message, not which person at it: anyone who can send as
that domain (its admin, or someone who has taken over the mailbox) passes. So an email
address is only ever a person's id under [people], with that person's role: one in
[owner.ids], or an email chat as the approvals chat, is refused, and by email nobody gets
owner rights. A reply goes only to the From address of a message that person sent, never to
its Reply-To and never Cc. Attaché never writes to an address that hasn't written to it; if
the message has since been moved, the reply goes to that same address as a new email. Each
reply is logged with ids only. A mail skill older than the channel, whose replies would
follow Reply-To, is refused: see Updating the mail skill.
Files and the shell¶
The file tools work only inside the home folder. Attaché refuses, before touching the disk:
- absolute paths, drive letters (also
C:foo), UNC paths, and any path with a:; - anything that resolves outside the home folder, including through symlinks and junctions;
- the files it keeps to itself, in any case and at any depth:
.git,.claude,CLAUDE.md,.secret,.mail,.approvals,.browserandattache.db*.
The shell is not sandboxed
The run tool runs a command in the home folder (cmd.exe on Windows, sh elsewhere)
with your user's rights. A command can reach anything you can. That is why only your own
turns get it. Each command has a time limit, 60 seconds unless the assistant asks for
more, and at most 10 minutes; on timeout, the whole process tree is stopped.
Skill scripts run the same way, as you, with uv run, in the chat's files folder, for at
most 10 minutes. The script tool takes only plain skill and script names, so it can't be
pointed at a file outside skills/<name>/scripts/. Attaché sets ATTACHE_SENDER, ATTACHE_ROLE,
ATTACHE_CHAT and ATTACHE_HOME on every run itself, so a script can check who is asking
and nobody in a chat can choose those values (Who is asking).
A run with background: true may take up to 12 hours instead, at most four at once, and
stopping Attaché stops its whole process tree. When one fails, the person who asked hears
only that it failed (or the line the script chose for them), never its output, and you get a
notice in your approvals chat with what the script chose to tell you
(Long jobs).
Model API keys¶
Endpoints can use an environment variable (key_env) or a reference to an OS credential
(key_ref), never both. With the keyring, only the reference is written to attache.toml;
the secret is stored under attache-models in Windows Credential Manager, macOS Keychain,
Linux Secret Service or KWallet. Plaintext and null keyring backends are refused.
Use attache skill credentials login ENDPOINT in your own terminal. It prompts with hidden
input, and refuses piped input or a chat tool context. Never paste a key into chat or pass it
as a command argument. The command verifies storage before replacing the configuration;
on failure it rolls back an unreferenced new record when possible and reports any cleanup
needed without printing the key. Configuration changes preserve comments and use an atomic
file replacement. An edit detected since login began aborts the update.
The stored record binds the key to an API family and normalized base URL. Runtime lookup rejects a changed destination and ignores ambient SDK base-URL overrides for this source. These clients refuse HTTP redirects, including redirects to another path on the same host, so a redirected request cannot forward the saved credential outside that destination. The key passes directly to the SDK in memory; Attaché does not copy it to its environment, database, home git history, skill arguments or diagnostic output. Keyring lookups run off the event loop, and each endpoint's SDK client retains the key until Attaché stops.
After changing or removing a saved key, restart Attaché. Removing it locally does not revoke it at the provider. Backups of the home contain references only; copied homes on the same OS account can share those references, while another machine needs its own login.
The keyring protects persistence, not the whole running process. Owner tools and other
programs running with your OS account can access your store or process memory, subject to
the store's access controls. Environment credentials retain their existing behavior and
may be inherited by owner shell and skill processes. A headless service without an
accessible user store can continue using key_env. See
configuration for setup and rotation.
The web¶
The web skill searches the web and reads pages for you and for members. Its script goes
only to the public internet: every address, and every address a page redirects to, must
resolve to public IP addresses only, so this machine, your local network, Tailscale peers and
cloud metadata addresses are refused, whoever asks and whatever a page links to. Without a
proxy, the address each connection actually uses is checked too. Page text comes back
labelled as untrusted content, with text hidden from readers left out. See Web.
The browser¶
The browser skill drives Edge or Chrome with a profile signed in to your work, so it is
yours alone: it has no attache-roles, and its script refuses any request without owner
rights, so never in a group chat. It opens only addresses on the sites you list in its
browser.toml, and blocks a page or frame from anywhere else before the request goes; a
redirect elsewhere is followed by the browser, but nothing from that page is read. Its
profile, in .browser/, is one of the files the file tools refuse. The list keeps the signed-in
browser on your sites; it doesn't stop a page that talks the assistant into sending what it
read elsewhere, which is what the rule that pages are data is for. See
Browser.
The page sandbox¶
Pages are untrusted HTML: the assistant writes them, and they may quote emails or web pages.
Each one is served with a Content Security Policy sandbox, which gives it an opaque origin
and blocks every request to the internet, pop-ups, dialogs, form posts, and framing by other
sites. A page reaches Attaché only through the bridge, with a token for that page alone, and
only the collections named in its attache-collections meta tag. It opens only inside the
Attaché window, except from a snapshot link (for the render skill) or a share link. The
details are in Pages.
The chat on the web page is not a page and has no sandbox, so it never treats message text
as HTML. It builds a reply's Markdown from text nodes and a few plain elements. Links are
made only to http, https and mailto addresses, and images are shown as links, never
loaded.
Pages that ask¶
A page in the Attaché window can ask the assistant with attache.ask. That turn belongs to
the page, not to you:
- Its prompt reaches the model quoted as data from the page, like another person's message.
- It runs on the
group_chatsendpoint, neverclaude-cli, with no history. - Its only tool is
db, on the page's declared collections, through the member rules: the docs shared with the page's chat, or withlocal:mefor one of your own pages. Owner rights never reach a page turn, even on your own page. - It counts against the guests' daily budget and the per-chat limits, and nothing is stored in the chat's history.
Only a page's own token can ask (the web page's token alone can't), and never a share link.
Share links¶
When [web] public_url is set, a page made in a chat gets a share link,
<public_url>/s/<token>, that anyone can open. See Pages.
- Never your access. Only pages tied to a chat can be shared. A link reaches the
documents shared with that chat, in the collections the page declared when the link was
made, under the member rules (add and edit, no delete). It can't use
attache.ask, and no page, shared or not, reaches_sharesor_requests(members' requests to you). - Capped writes. Anyone with the link can write, so each link may make at most 60
changes an hour (refused tries count too), each at most 64 KB, and adds no document once
the chat has 500 in that collection; it can still edit the ones there. Over a cap, the
answer is 429, 413 or 403. The size is checked from the
Content-Lengthheader before any of the body is read, so a write without one, or sent in chunks (whose size is known only at the end), gets 413 straight away. Pages in the Attaché window have no such caps. - Short-lived. A link lasts 7 days, and dies at once if the page changes or is deleted.
- Kept as a hash. The token is 24 random bytes, and Attaché stores only its SHA-256 (in
the read-only
_sharescollection), so the database holds no working links. - Not passed on. The page opens top-level with
Referrer-Policy: no-referrerandframe-ancestors 'none', so the link doesn't reach other sites in aRefererheader and no site can frame the page. The bridge sends the token in a header (the live event stream, in its query string), and the web server keeps no access log.
The public port¶
Tailscale Funnel connects to Attaché from 127.0.0.1, like everything on this machine, so a
local request and one from the internet look alike. Attaché doesn't tell them apart by
address or Host header. Instead, share links get a web server of their own:
- It listens on
127.0.0.1:<public_port>(by default the web page's port plus one), only whenpublic_urlis set. Funnel forwards to that port; the web page's own port is never exposed. - It serves only share links (
/s/<token>), the bridge script, the fonts, and the document and live-event routes for share links, which take share tokens and nothing else. The chat page, other pages, chat, answers,attache.askand the MCP endpoint don't exist there. - It answers requests whose
Hostis the host ofpublic_urlor a loopback name (127.0.0.1,localhost,[::1]), since Funnel may pass either on. Any other name is refused. A loopbackHostgets nothing more: the port has only the share routes. - On the local web server, share tokens are refused, and
/s/doesn't exist.
Tokens¶
- The web page's token is random (24 bytes from
secrets) and new every time Attaché starts. It reaches the browser only through the link Attaché prints, after#t=; the fragment never goes to the server in a request line, and the page removes it from the address bar and keeps it in the browser's storage for other tabs. API calls carry it in a header (the live event stream, which can't send headers, in its query string), never in a cookie. - Each page's token is an HMAC of the web page's token and the page's name, so it changes every run too, and one page's token is no use to another.
- Each snapshot link (for the
renderskill) carries a random token for one page, kept only in memory and valid for 10 minutes. - Each share link carries a random token that lasts 7 days, stored only as a hash (see Share links).
- Each Claude Code turn gets its own MCP token, valid only while that turn runs and only for that turn's tools.
Attaché compares the web page's and each page's token with hmac.compare_digest, in
constant time. The random tokens (snapshot, share and MCP) are looked up instead; share links
by their hash.
Loopback only¶
The web servers serve this machine only (Funnel reaches the public port from this machine too):
[web] hostmust be127.0.0.1,localhostor::1. Attaché refuses to start on any other address, including0.0.0.0. The public port is always on127.0.0.1.- Requests whose
Hostheader isn't a loopback name are refused, so a web site can't reach the server by pointing its own domain name at127.0.0.1(DNS rebinding). The public port also takes the host ofpublic_url, and serves share routes only. - Only the bridge routes answer cross-origin requests, for the sandboxed pages. The MCP
endpoint refuses any request that comes with an
Originheader, so a web page can't post to it.
Anything that runs as your user on this machine can still read ~/.attache directly. The
tokens keep web pages, and programs that don't have the printed link, from driving the
assistant through its web server.
The claude-cli backend¶
The claude-cli backend runs Claude Code (claude -p) on your own login. It has its own
limits:
- Your own chats only. It answers only turns with owner rights, and refuses every group
turn and every other person's turn. Anthropic's terms allow a Pro or Max login for your
own use; anything that serves other people should use an API key, which is why
group_chatsdefaults to theclaude-apiendpoint. - Claude Code's own tools. Each turn may use Claude Code's Read, Write, Edit, Glob,
Grep, Bash, WebFetch and WebSearch without asking. These are Claude Code's tools, not
Attaché's: Attaché's home-folder limits don't apply to them, and your own Claude Code
settings in
~/.claudedo. - Only Attaché's MCP server. Claude Code reaches Attaché's other tools (documents,
reminders, pages, skill scripts, sending) through an MCP endpoint on the local web server.
The turn starts with
--strict-mcp-config, so no other MCP server you have configured is loaded. - Outside your home folder. Claude Code runs in
~/.attache-claude, next to the home folder, which it reaches with--add-dir. ACLAUDE.mdor.claude/settings.json(with hooks) written into~/.attacheis never loaded as project settings. - Your key, if one is set. If
ANTHROPIC_API_KEYis set where Attaché starts, Claude Code uses it instead of your login.
Limits and approvals¶
Turns from anyone but you, page turns included, are rate limited per sender, per chat and per day; see Channels. When a member asks for something only you can do, the assistant asks you first, and acts only on your yes. See Design and roadmap for what comes next.