← Blog

My AI Assistant Was Reading Its Own Credentials From a Text File

I’ve been in this industry long enough to have been paged for things that no longer exist. I’ve written a postmortem whose root cause was differences between BSD sed and GNU sed. So it was with some personal embarrassment that I recently audited the state of my Claude credential management.

There was a markdown file. It had GitHub tokens in it. My AI assistant was reading it via the filesystem.

No one got hurt. The file had restricted permissions. But still.


What the Setup Actually Was

Claude Code (CCCLI) is Anthropic’s agentic coding tool, and I use it heavily across two GitHub orgs — one personal, one for client work. It needs a GitHub PAT to push branches, open PRs, and hit the API.

My claude-wrapper handled this reasonably well. A secrets.op file mapped env var names to op:// vault references, and a shell script ran op inject at launch to resolve them and hydrate the environment. The op:// references themselves aren’t sensitive — they’re just paths. The actual secrets stayed in 1Password and got fetched at runtime. The PATs were generated specifically for CCCLI with tightly scoped permissions: repo access, workflow, org read, nothing else. Genuinely fine from a security standpoint.

The problem was TouchID. Every CCCLI launch triggered a biometric prompt, which is correct behavior and also deeply annoying when you’re iterating quickly.

Claude Desktop was a different situation with a different problem. The desktop app runs in a macOS sandbox and can’t shell out to the 1Password CLI. No op inject, no way to resolve vault references at startup. So I ended up with claude-secrets.md: a file generated by a refresh script that resolved the op:// references and wrote the plaintext values to disk with restricted permissions. A project instruction pointed Claude Desktop at it via the filesystem MCP.

This was not insane given the constraints. The refresh script used atomic writes, chmod 400, the whole thing. But the end state was a file sitting on disk with live GitHub tokens, regenerated manually after rotation. And two orgs meant two PATs, a token router in the wrapper, per-repo secrets.op files — the whole thing had accumulated enough moving parts that any failure was hard to diagnose.


The Better Architecture

I wanted three things: no plaintext credentials on disk, non-interactive auth for CCCLI sessions, and Claude Desktop able to look up secrets without me maintaining a separate file.

The answer was 1Password Service Accounts. A service account is 1Password’s headless authentication primitive — it gets a token, that token grants scoped vault access, and it works without biometrics or an active session. Think of it as a machine identity rather than a user identity.

The architecture ended up as three layers.

The vault. Create a dedicated Automation vault at start.1password.com. Service accounts can’t access Personal, Private, or Shared vaults by design, which is exactly right. Move all automation-accessible secrets here: GitHub PAT, project-specific API keys, anything that needs to be resolved non-interactively. One vault, one scope, auditable access.

Create the service account via the service account wizard with read-only (read_items) access to Automation only. When the token appears — it shows once — save it to your Personal vault immediately, then store it in macOS Keychain:

Terminal window
security add-generic-password \
-a "$USER" \
-s "op-service-account-claude-automation" \
-w "ops1..." \
-A # allow access by any application without prompting

The -A flag matters. Without it, every new process that reads the item triggers a permission dialog. (The tradeoff: any user-space process can then read this Keychain item silently, without prompting. Since the threat model here is malware-with-user-privileges reading a service-account token whose blast radius is already “read one vault,” I’m comfortable with it. You should think about whether you are.)

One aside: the 1Password GitHub shell plugin is the official mechanism for injecting GH_TOKEN per invocation, and it works by creating an alias: alias gh="op plugin run -- gh". That alias collides with a gh() bash function I have for enforcing pre-merge review hooks. In bash, an alias expands at parse time and turns gh() { into op plugin run -- gh() {, which is a syntax error. So the shell plugin stays out of it entirely. Not everyone will hit this, but it’s the kind of thing that will bite you at 11pm if you don’t know to look for it.

CCCLI credential injection. Rather than loading credentials into the interactive shell environment where any subprocess inherits them indefinitely, the wrapper fetches them at launch and they live only for the duration of that CCCLI session. A new lib/credentials.sh module in claude-wrapper handles this, sourced before secrets-loader.sh (which needs OP_SERVICE_ACCOUNT_TOKEN to run op inject without a biometric prompt):

Terminal window
# Fetch OP_SERVICE_ACCOUNT_TOKEN from Keychain
_load_service_account_token() {
local token
token="$(security find-generic-password \
-a "$(id -un)" \
-s "op-service-account-claude-automation" \
-w 2>/dev/null || true)"
[[ -n "${token}" ]] && export OP_SERVICE_ACCOUNT_TOKEN="${token}"
}
# Fetch GH_TOKEN from Automation vault via service account
_load_gh_token() {
[[ -z "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]] && return 0
local token
token="$(op read "op://Automation/GitHub - CCCLI/Token" 2>/dev/null || true)"
[[ -n "${token}" ]] && export GH_TOKEN="${token}"
}
_load_service_account_token
_load_gh_token

The interactive shell has neither token. Only CCCLI sessions do. The GitHub PAT here is a dedicated restricted-scope PAT, separate from the personal token gh stores in its own keyring. The keyring token has broad access; this one doesn’t. The separation is intentional.

For cases that genuinely need Personal vault access — provisioning scripts, anything that touches items the service account can’t see — there’s a helper defined in my dotfiles and exported for availability inside CCCLI sessions:

Terminal window
opp() {
(
unset OP_SERVICE_ACCOUNT_TOKEN
if ! op whoami &>/dev/null; then
op signin
fi
op "$@"
)
}
export -f opp

The subshell means the parent environment is untouched. opp1 item list shows all vaults; op item list shows only Automation.

Claude Desktop MCP. The 1Password MCP server (published to npm as @takescake/1password-mcp — same project, different handles) — a community project, Apache licensed, not affiliated with 1Password — connects Claude Desktop to the vault via the same service account. With it running, Claude Desktop can call password_read with an op:// reference and get the value directly, replacing the markdown file entirely.

The naive configuration puts the service account token directly in claude_desktop_config.json as an env var, which trades one problem (a markdown file with tokens) for a different one (a JSON file with a token in a predictable location, readable by any process with filesystem access — including Claude itself via the filesystem MCP).

The config format doesn’t support shell interpolation, so you can’t reference Keychain directly. The fix is to point command at a small launcher script instead of npx:

#!/usr/bin/env bash
set -euo pipefail
_token="$(security find-generic-password \
-a "$USER" -s "op-service-account-claude-automation" -w 2>/dev/null)" || {
echo "ERROR: token not found in Keychain" >&2
exit 1
}
export OP_SERVICE_ACCOUNT_TOKEN="${_token}"
unset _token
exec npx -y @takescake/1password-mcp "$@"

The claude_desktop_config.json entry becomes:

"1password": {
"command": "/Users/yourname/.local/bin/1password-mcp-launcher.sh",
"args": []
}

No env block, no token in the file. The config is safe to look at and safe to share.

If you get a recurring “bash would like to access data from other apps” prompt: clicking Allow should persist the decision, but it won’t if a previous “Don’t Allow” click recorded a hard deny in macOS’s TCC database, or if bash was upgraded via Homebrew since the deny was recorded, leaving a stale entry keyed to the old binary path. The user TCC database at ~/Library/Application Support/com.apple.TCC/TCC.db is user-writable — no SIP bypass needed — but your Terminal (or whatever runs sqlite3) needs Full Disk Access granted in System Settings → Privacy & Security. Without it, sqlite3 silently opens the file read-only and the UPDATE no-ops with no error. If your fix seems to do nothing, check that first. Check what’s actually recorded:

Terminal window
sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \
"SELECT service, client, auth_value FROM access \
WHERE client LIKE '%bash%' OR client LIKE '%sh%';"

auth_value=5 means denied by user. Fix it directly:

Terminal window
sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \
"UPDATE access SET auth_value=2, auth_reason=2 \
WHERE client='/opt/homebrew/Cellar/bash/X.X.X/bin/bash' \
AND service='kTCCServiceSystemPolicyAppData';"

Replace X.X.X with your installed version (bash --version). Restart Claude Desktop after the update — no further prompts.


What Changed in the Wrapper

The claude-wrapper had accumulated three modules specifically for GitHub token management: github-token.sh for loading the token, gh-token-router.sh for per-org routing, and a global secrets.op file for the vault references. All three are gone. The per-org PATs were consolidated into a single account-wide CCCLI PAT in the Automation vault. One item, one vault, one token.

The per-project secrets.op files changed from referencing op://Personal/... to op://Automation/.... That’s the whole migration — the service account can reach Automation, can’t reach Personal, done.

One actual bug surfaced during cleanup: op inject with --out-file was printing the resolved temp file path to stdout rather than stderr. Only stderr was suppressed. So every CCCLI session was silently leaking a path like /var/folders/.../0-resolved-secrets.op to the terminal. Changed 2>/dev/null to &>/dev/null. Years of that sitting there.

The wrapper is shorter by about 80 lines and two modules. Removing code that was solving a problem you no longer have is genuinely underrated.


The Result

The interactive shell has no credentials in it at all. No OP_SERVICE_ACCOUNT_TOKEN, no GH_TOKEN, nothing. CCCLI sessions get both tokens injected at launch via the wrapper, scoped to the process lifetime. Claude Desktop resolves secrets via MCP with no token on disk. Per-project secrets like RevenueCat keys are resolved from the Automation vault via op inject at wrapper launch, also process-scoped.

The service account is read-only, scoped to one vault. If the token leaks, the blast radius is “someone can read the Automation vault.” Bounded. Rotate the token, create a new service account, update one Keychain entry and the MCP launcher. The underlying secrets are unaffected.

One thing worth naming directly: the Automation vault also holds financial API credentials for a separate project. The right long-term answer is a second, more narrowly scoped service account for Claude Desktop — it mostly needs the GitHub PAT, not the financial API keys. Different consumers, different access. That’s on the list for another evening.


Quick Reference

For anyone following this as a guide, the moving parts in order:

  1. Create an Automation vault in 1Password
  2. Move automation-accessible secrets into it; use Login items with a Token field for GitHub PATs
  3. Create a service account with read-only (read_items) access to Automation only; save the token to your Personal vault immediately
  4. Store the service account token in Keychain: security add-generic-password -a "$USER" -s "op-service-account-claude-automation" -w "ops1..." -A
  5. In claude-wrapper, add lib/credentials.sh to fetch OP_SERVICE_ACCOUNT_TOKEN from Keychain and GH_TOKEN from the vault at launch; source it before secrets-loader.sh
  6. Add opp() to your bash config with export -f opp; remove any shell-level token injection
  7. Update per-project secrets.op files from op://Personal/... to op://Automation/...
  8. Create the MCP launcher script at a stable path; point claude_desktop_config.json at it with no env block

The 1Password CLI documentation lives at developer.1password.com. The MCP server referenced is CakeRepository/1Password-MCP on GitHub, published as @takescake/1password-mcp on npm — Apache licensed, community maintained, not affiliated with 1Password. My own claude-wrapper and dotfiles are public if you want to see how the pieces fit together in context.


If you’re an employer and you’re thinking I want someone who thinks about credential hygiene this hardI’m on LinkedIn and reachable by email. I’m a Principal SRE by trade, and this is the kind of work I do for fun.

If you’re a business owner thinking I have secrets sprayed across config files and env vars and I don’t love it — that’s exactly what Night Owl Studio is for. Infrastructure hardening done by someone who’s already made the mistakes so you don’t have to.

Footnotes

  1. Other People’s Passwords?