
Security News
Ruby's Bundler 4.0.18 Extends Cooldown to bundle lock and bundle cache
The supply chain control that delays freshly published gems now covers lockfile generation and gem vendoring in Ruby projects.
Identity profiles for agents and shells - direnv scopes environments to directories; hats scopes them to identities.
Identity profiles for agents and shells.
direnv scopes environments to directories. hats scopes them to identities.
A hat is a named bundle of credentials: which Vercel account, which Google Workspace, which cloud project, which agent config a process sees. Run any command "wearing" a hat and it inherits that identity's environment, so every CLI it touches reads the right credential directory automatically.
hats wear project -- vercel deploy # deploys with your project's Vercel account
hats wear dayjob -- vercel deploy # same command, employer's account
hats wear project -- claude # an agent session, born scoped to the project
(hats wear is the flagship verb; hats run is a familiar alias - use whichever.)
Agents roam directories, but they must never roam identities. If you run coding agents (Claude Code, Codex, your own orchestrator) across multiple clients, employers, or businesses, the question isn't "which folder am I in," it's "whose credentials is this process holding?" hats makes the answer structural: every process is born wearing exactly one hat.
You don't need multiple agent accounts for this to matter. hats scopes
every credential-bearing CLI it touches - Vercel, Google Workspace, Render,
gcloud, Neon, Doppler - not just your agent's config. The moment you have a
personal Vercel and a client's, or your own Google Workspace and a
contract's, a bare vercel deploy is ambiguous. One agent login, many
identities underneath: freelancers, moonlighters, and anyone juggling personal
plus contract work all have this.
Prompts are opt-in correctness: every command must remember the instruction, and one context compaction, subagent, or injection later it's gone. Environment injection is opt-out correctness: the default path is the right account, wrong-identity access requires a deliberate act, and subprocesses inherit it for free.
Agents inherit identity from their execution context, never from their prompt.
If you run Claude Code, you can already scope MCP servers per project
(.mcp.json) or per user. So why hats?
Because MCP scoping only covers the agent's MCP tools. The moment the agent
runs a shell - vercel deploy, gcloud ..., gws ..., render deploy - those
hit your default account; MCP scoping does nothing for them. hats scopes the
whole process environment, so every CLI is born on the right identity.
.mcp.json scopes MCP servers. hats scopes Claude config plus
every credential CLI (vercel, gcloud, gws, render, neon, doppler, gh, glab,
...), browser routing, and secrets. An agent with a shell reaches for those
CLIs constantly - MCP scoping leaves them all on the wrong account..mcp.json scopes by directory (like
direnv). But your client identity isn't a folder - you touch its work in a
dozen folders, and personal in others. hats scopes by who you are, which
spans directories.vercel --scope) is remember-every-time. hats is ambient: the process is born
wearing the hat and every subprocess inherits it. Wrong-identity access takes
a deliberate act, not a forgotten flag.profiles.json; everything inherits.profiles.json (see Boundaries). MCP scoping has
no equivalent; it can't build a guard.MCP scoping secures one tool's plugins; hats secures the whole identity - every CLI, browser, and secret - for any tool, by default.
brew install chrismcdermut/tap/hats
# or
go install github.com/chrismcdermut/hats@latest
# or (npm: downloads the release binary for your platform)
npm install -g manyhats
Run hats init to drop a starter ~/.config/hats/profiles.json (and generate
the wrapper shims) in place, then edit it. Prefer to write it by hand? It lives
at ~/.config/hats/profiles.json - or set HATS_CONFIG to point hats at a
different directory (handy for a sandbox, CI, or keeping config in a dotfiles
repo).
Notice the pattern: same variables, different directory per identity. That
-<identity> suffix is the whole idea.
{
"profiles": {
"personal": {
"description": "Personal projects",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-personal",
"VERCEL_CONFIG_DIR": "~/.config/vercel-personal",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-personal"
},
"aliases": ["ccp", "gwsp", "verp", "rendp"],
"reachable": ["project"]
},
"dayjob": {
"description": "Employer",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-dayjob",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-dayjob",
"VERCEL_CONFIG_DIR": "~/.config/vercel-dayjob",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-dayjob",
"CLOUDSDK_CONFIG": "~/.config/gcloud-dayjob",
"NEON_CONFIG_DIR": "~/.config/neon-dayjob",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-dayjob"
},
"path_prepend": ["~/.local/bin"],
"env_files": ["~/.env-dayjob"],
"doctor": {
"claude": "~/.claude-dayjob/.claude.json",
"vercel": "~/.config/vercel-dayjob/auth.json"
},
"logins": {
"gws": "gws auth login",
"vercel": "vercel login",
"render": "render login"
},
"aliases": ["ccd", "gwsd", "verd", "rendd"]
},
"project": {
"description": "Side project / OSS",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-project",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-project",
"VERCEL_CONFIG_DIR": "~/.config/vercel-project"
},
"env_files": ["~/.env-project"],
"aliases": ["ccc", "gwsc", "verc"]
}
}
}
Only description and environment are required; everything else is optional.
dayjob above is a fully loaded profile - the others are trimmed to show the
minimum. Field by field: path_prepend front-loads dirs onto PATH (for the
wrapper shims below); env_files are gitignored secret files loaded only under
this hat; doctor refines the auto-derived credential checks (see
Doctor); logins declares each CLI's login command for
hats login; aliases lists this identity's launcher aliases; and reachable
lists other profiles this hat may deliberately reach.
The last two feed hats boundary: it doesn't store
a block list, it derives one. For a personal session it reads the other
profiles' config-dir names and aliases as foreign_paths / foreign_aliases
to block - except project, which personal marked reachable, so that one
stays allowed. Nothing about the boundary is hand-maintained; it all falls out
of the profiles above.
Three identities - personal, an employer (dayjob), a project - each scoping
the same eight CLIs to its own directories, with per-identity secrets and browser
routing. This is the whole config, only the names/ids changed:
{
"profiles": {
"personal": {
"description": "Personal",
"environment": {
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-personal",
"GOOGLE_WORKSPACE_PROJECT_ID": "gcp-personal",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-personal",
"VERCEL_CONFIG_DIR": "~/.config/vercel-personal",
"CLOUDSDK_CONFIG": "~/.config/gcloud-personal",
"NEON_CONFIG_DIR": "~/.config/neon-personal",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-personal",
"BROWSER": "~/.local/bin/browse-as-hat",
"CHROME_PROFILE_DIR": "Profile 1"
},
"path_prepend": ["~/.local/bin"],
"doctor": { "claude": "~/.claude.json" },
"logins": {
"gws": "gws auth login", "vercel": "vercel login", "render": "render login",
"gcloud": "gcloud auth login", "neon": "neonctl auth", "doppler": "doppler login"
},
"env_files": ["~/.env-personal"],
"aliases": ["ccp", "gwsp", "verp", "rendp", "neonp", "dopplerp"],
"reachable": ["project"]
},
"dayjob": {
"description": "Employer",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-dayjob",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-dayjob",
"GOOGLE_WORKSPACE_PROJECT_ID": "gcp-dayjob",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-dayjob",
"VERCEL_CONFIG_DIR": "~/.config/vercel-dayjob",
"CLOUDSDK_CONFIG": "~/.config/gcloud-dayjob",
"NEON_CONFIG_DIR": "~/.config/neon-dayjob",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-dayjob",
"BROWSER": "~/.local/bin/browse-as-hat",
"CHROME_PROFILE_DIR": "Profile 2"
},
"path_prepend": ["~/.local/bin"],
"logins": {
"gws": "gws auth login", "vercel": "vercel login", "render": "render login",
"gcloud": "gcloud auth login", "neon": "neonctl auth", "doppler": "doppler login"
},
"env_files": ["~/.env-dayjob"],
"aliases": ["ccd", "gwsd", "verd", "rendd", "neond", "dopplerd"]
},
"project": {
"description": "Side project / OSS",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-project",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-project",
"GOOGLE_WORKSPACE_PROJECT_ID": "gcp-project",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-project",
"VERCEL_CONFIG_DIR": "~/.config/vercel-project",
"CLOUDSDK_CONFIG": "~/.config/gcloud-project",
"NEON_CONFIG_DIR": "~/.config/neon-project",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-project",
"BROWSER": "~/.local/bin/browse-as-hat",
"CHROME_PROFILE_DIR": "Profile 3"
},
"path_prepend": ["~/.local/bin"],
"logins": {
"gws": "gws auth login", "vercel": "vercel login", "render": "render login",
"gcloud": "gcloud auth login", "neon": "neonctl auth", "doppler": "doppler login"
},
"env_files": ["~/.env-project"],
"aliases": ["ccc", "gwsc", "verc", "rendc", "neonc", "dopplerc"]
}
}
}
A few non-obvious bits:
BROWSER + CHROME_PROFILE_DIR - a tiny browse-as-hat script opens OAuth
flows in that identity's own Chrome profile, so a login started under one hat
can't land in another hat's browser session.GOOGLE_WORKSPACE_PROJECT_ID - the GCP project backing that identity's
Google Workspace CLI.env_files hold each identity's API tokens (gitignored, chmod 600),
loaded only under that hat - so a stray token is never global.personal marks reachable: ["project"],
so a personal session may deliberately reach the project hat; dayjob marks
nothing, so the employer identity is hard-walled from the others.personal needs a one-line doctor (it uses the default ~/.claude, so there's
no CLAUDE_CONFIG_DIR to derive from); dayjob/project need none.Every identity scopes the same tools (Claude, Google Workspace, Vercel,
Render) - only the directory changes (gws-personal vs gws-dayjob vs
gws-project). So hats wear dayjob -- gws ... reads the employer's Google
Workspace; hats wear project -- gws ... reads the project's. Same command, same
tool, different identity, by construction.
Then:
hats init # scaffold config + generate wrapper shims
hats ls # list hats (* = the one you're wearing)
hats wear project -- vercel deploy # one command under an identity
hats shell project # a subshell wearing the hat
hats login project # log this hat's CLIs in (writes to its dirs)
hats which # what hat is this process wearing?
hats doctor # is every identity aligned and logged in?
hats env project # eval-able exports, for scripts
hats env project --json # machine-readable, for orchestrators
wear actually doesNo magic: hats wear <profile> -- <cmd> sets the profile's env vars, prepends
any path_prepend dirs to PATH, sets HATS_PROFILE, and then execs the
command (a true exec: hats replaces itself with your command, so signals, tty,
and exit codes flow naturally). Every subprocess inherits the hat.
CLOUDSDK_CONFIG), doppler (DOPPLER_CONFIG_DIR), render
(RENDER_CLI_CONFIG_PATH), Claude Code (CLAUDE_CONFIG_DIR), GitHub
(GH_CONFIG_DIR), GitLab (GLAB_CONFIG_DIR), and more. Point each at a
<tool>-<identity> dir and that CLI is scoped for free - so a work hat and a
personal hat can each hold their own GitHub login, for example.AWS_SHARED_CREDENTIALS_FILE,
AWS_CONFIG_FILE) - and so does a tool scoped by a plain token: drop a
per-identity CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in the hat's
env_files and wrangler is scoped with no dir at all. If a CLI reads any env
var for its credentials, a hat can scope it.--config flag. Put shims in a path_prepend dir and
hats will front-load them onto PATH.Because each identity's credentials live in their own directory, isolation is structural: a process wearing the project hat cannot accidentally deploy with the dayjob account, because dayjob's tokens are simply not on its path.
A hat's environment only points at credential directories. You still have to put
credentials in them, and the golden rule is:
Always log in through the hat.
hats wear <profile> -- <cli> loginwrites the token to that profile's directory. A bare<cli> loginwrites to the CLI's default location and silently ends up under the wrong (or no) identity.
So to set up a new hat, log each CLI in wearing it:
hats wear project -- gws auth login # -> ~/.config/gws-project
hats wear project -- vercel login # -> ~/.config/vercel-project (needs shim, see below)
hats wear project -- render login # -> ~/.config/render-project
hats wear project -- gcloud auth login # -> ~/.config/gcloud-project
hats wear project -- neonctl auth # -> ~/.config/neon-project (needs shim)
hats wear project -- doppler login # -> ~/.config/doppler-project
hats doctor project # confirm each one landed
Each is a normal browser OAuth flow; the only thing hats changes is where the resulting token is saved. Because the env var is set for that command, the CLI reads and writes the right directory. Do this once per identity per machine (tokens don't sync between machines, so you re-login on each, but the hat definition travels).
CLIs with a native config-dir env var (gcloud, doppler, render, gws, Claude
Code) work out of the box. But vercel and neon ignore env vars and always
use a fixed default location, so hats wear project -- vercel login would still
clobber your default vercel login. The fix is a tiny wrapper on PATH that
translates an env var into their --config flag:
#!/bin/sh
# ~/.local/bin/vercel (shim). Finds the real vercel on PATH (skipping itself),
# so there's nothing to rename. This is exactly what `hats init` generates.
selfdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
real=; IFS=:
for d in $PATH; do [ "$d" = "$selfdir" ] && continue; [ -x "$d/vercel" ] && { real=$d/vercel; break; }; done
unset IFS
if [ -n "$VERCEL_CONFIG_DIR" ]; then exec "$real" --global-config "$VERCEL_CONFIG_DIR" "$@"; fi
exec "$real" "$@"
Put the shim dir in the profile's path_prepend, and VERCEL_CONFIG_DIR in
its environment. Then vercel logins land per-hat like everything else. You
don't have to write these by hand - hats init generates the vercel/neon
shims for you (see Doctor).
Hats carry all the identity; aliases are just muscle memory. Point short
launchers at hats wear:
A convention that scales: cc + an identity initial, plus y for yolo
(skip-permissions) sessions:
# Claude Code: cc<initial>, add y for autonomous mode
alias cc='hats wear personal -- claude'
alias ccy='hats wear personal -- claude --dangerously-skip-permissions'
alias ccd='hats wear dayjob -- claude'
alias ccdy='hats wear dayjob -- claude --dangerously-skip-permissions'
alias ccc='hats wear project -- claude'
alias cccy='hats wear project -- claude --dangerously-skip-permissions'
# Codex: same pattern
alias cx='hats wear personal -- codex'
alias cxdy='hats wear dayjob -- codex --full-auto'
# or ad hoc, no alias needed
hats wear project -- claude -p "summarize this repo"
The same trick works for any CLI, so you can reach a specific identity's
Vercel/Render/Google Workspace without wearing the whole hat. Name them
<cli><identity>:
# Google Workspace per identity (gws<initial>)
alias gwsp='hats wear personal -- gws'
alias gwsd='hats wear dayjob -- gws'
alias gwsc='hats wear project -- gws'
# Vercel / Render per identity
alias verp='hats wear personal -- vercel'
alias verc='hats wear project -- vercel'
alias rendd='hats wear dayjob -- render'
alias rendc='hats wear project -- render'
Now gwsc drive files list runs Google Workspace as the project, and
rendd deploy deploys Render as your employer, from any directory. Because
these delegate to hats run, the identity still lives only in profiles.json:
every alias is a thin pointer with zero credentials or paths baked in.
Adding identity #4 is a profiles.json edit. No alias surgery, no duplicated env blocks, and the aliases contain zero identity information.
hats doctor audits every hat: does each credential path exist, and is it
non-empty (logged in)? It's the "is everything aligned" check you'd otherwise
do by hand after every laptop migration, reauth, or 2am login mishap. It exits
non-zero if any check fails, so you can gate CI or a shell startup on it.
dayjob - Employer
✓ claude ~/.claude-dayjob/.claude.json
✓ gws ~/.config/gws-dayjob
○ vercel ~/.config/vercel-dayjob/auth.json [empty]
1 check(s) not ok (○ empty = not yet logged in, ✗ missing = path absent)
You don't list doctor checks - hats reads them from environment. Three
rules, in order:
GOOGLE_WORKSPACE_CLI_CONFIG_DIR,
RENDER_CLI_CONFIG_PATH, NEON_CONFIG_DIR, ... each turns into "does this dir
exist and is it non-empty?" You never restate these paths.VERCEL_CONFIG_DIR -> auth.json, CLAUDE_CONFIG_DIR
-> .claude.json. This is built in, so vercel/claude derive as file
checks automatically. Nothing to configure.That's why a fully-scoped profile needs no doctor block at all - every check
comes from its env vars.
The doctor map is only the leftovers rules 1-2 can't produce: a CLI with
no config-dir env var (a hat that uses the default ~/.claude, so there's
nothing to derive from), or a non-standard proof path. Most profiles need zero
lines here; a personal hat on the default Claude dir needs exactly one:
"doctor": { "claude": "~/.claude.json" }
Entries here override the derived check for that label, or add a new one.
So doctor is an exceptions list, not a copy of your paths. If you see more than
a line or two, something is probably restating what environment already implies.
hats inithats init # scaffold ~/.config/hats/profiles.json (if absent)
# + generate the vercel/neon wrapper shims into ~/.local/bin
hats init --dir ~/bin # write shims somewhere else
hats init --force # overwrite existing shims
hats init writes the vercel/neonctl shims described above so you don't hand-
write them. It never overwrites an existing file unless --force. Make sure the
target dir is on PATH ahead of the real CLIs (put it in a profile's
path_prepend).
hats env <profile> --json emits the resolved environment as JSON, so an
orchestrator can spawn each worker wearing the right hat:
const { env } = JSON.parse(execSync("hats env project --json"));
spawn(agentCmd, { env: { ...process.env, ...env } });
Put a hat's tokens in a gitignored env_files secret file so they load only
when you wear that hat, instead of globally:
"kanda": {
"environment": { "CLAUDE_CONFIG_DIR": "~/.claude-kanda" },
"env_files": ["~/.env-kanda-secrets"]
}
# ~/.env-kanda-secrets (chmod 600, never committed)
export JIRA_WORK_BASIC_AUTH="..."
Now hats wear kanda -- ... has the token; hats wear personal does not. Missing
files are skipped, so profiles.json stays portable (secrets are per-machine).
hats boundary)Env injection makes the default identity correct, but a determined command can
still reach another hat explicitly (hats run other -- ..., or another
identity's alias). For sessions where that must be blocked, hats boundary
emits the identity signals belonging to other hats, derived from
profiles.json, so a guard never hand-maintains a block list:
hats boundary dayjob --json
# { "profile": "dayjob", "reachable": [],
# "foreign_profiles": ["project", "personal"],
# "foreign_paths": ["gws-project", "vercel-personal", ...],
# "foreign_aliases": ["gwsc", "vercelp", ...] }
aliases field (the
short launchers; hyphenated <cli>-<identity> aliases are already caught as
path fragments).reachableBy default every other hat is foreign. Add reachable to allow specific ones -
useful for a session that legitimately spans two identities but must stay out of
a third:
"personal": {
"environment": { "CLAUDE_CONFIG_DIR": "~/.claude", "...": "..." },
"reachable": ["project"]
}
Now hats boundary personal --json drops project from foreign_profiles (and
its paths/aliases from the block lists), while any other hat stays foreign.
Point a guard at hats boundary from your harness. For Claude Code, add a
PreToolUse hook to the hat's settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "$HOME/.local/bin/hats-guard" }] }
]
}
}
The guard reads the command, asks hats boundary what's foreign for the active
hat, and blocks on a hit. A complete, minimal version:
#!/usr/bin/env python3
# hats-guard: block cross-identity commands in the active hat's session.
# exit 2 = block (stderr shown to the model), exit 0 = allow.
import json, os, re, subprocess, sys
cmd = (json.load(sys.stdin).get("tool_input") or {}).get("command", "")
prof = os.environ.get("HATS_PROFILE")
if not (cmd and prof):
sys.exit(0) # not wearing a hat, or nothing to check
b = json.loads(subprocess.run(["hats", "boundary", prof, "--json"],
capture_output=True, text=True).stdout)
hit = next((p for p in b["foreign_paths"] if p in cmd), None)
if not hit and b["foreign_aliases"]:
m = re.search(r"(?<![\w-])(" + "|".join(map(re.escape, b["foreign_aliases"])) + r")(?![\w-])", cmd)
hit = m.group(1) if m else None
if hit:
sys.stderr.write(f"blocked: '{hit}' belongs to another identity (this is a '{prof}' session)\n")
sys.exit(2)
Because the block set is computed from profiles.json, adding or renaming a hat
updates every guard for free. This is mistake/casual-misuse prevention, not hard
isolation (a heuristic is defeatable by obfuscation) - it's the middle rung
below.
hats wear): correct-by-default env injection.hats boundary + a guard hook): block explicit
cross-identity reach, from a config-derived block list.Most failures are rung-1 failures. Climb only as your threat model demands.
MIT
FAQs
Identity profiles for agents and shells - direnv scopes environments to directories; hats scopes them to identities.
We found that manyhats demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
The supply chain control that delays freshly published gems now covers lockfile generation and gem vendoring in Ruby projects.

Security News
During a UK cyber test, a Mythos 5 agent used sockpuppets, social engineering, and prompt injection to try to get a maintainer to merge malware.

Company News
Socket is now in the AWS Security Hub Extended plan. Adopt it through AWS, apply committed spend, and block malicious open source packages.