Repository navigation
Proposal: declare team secrets in the repo, keep their values on each machine, for MCP servers and CLIs #875
Description
Today. A team that shares a credential shares its value too. env/env.yaml holds each variable's value in plaintext, and teamai delivers it two ways: teamai pull resolves ${VAR} in mcp/mcp.yaml from it, and env.sh exports it to the member's login shell.
env/env.yaml GITHUB_TOKEN: ghp_... committed, in every clone and in git history
mcp/mcp.yaml Bearer ${GITHUB_TOKEN}
env.sh export GITHUB_TOKEN=... what gh, glab or an internal CLI sees
every member the same token, whoever it belongs to
A member can use their own token today only by exporting it in their shell. That breaks in two places:
- MCP. The session-start pull runs in the agent's environment, which often lacks that export (a GUI-launched tool, or a zsh export under
bash -lc), so it removes the MCP entry the interactive pull wrote. - CLIs.
gh,glabor a CLI the company ships as a package read their token from the environment of the process the agent starts.env.shis sourced once, when the shell starts, and doesn't follow the directory, so a member in two teams can't haveghuse a different account in each.
Proposal. The team declares which secrets it needs. Each member supplies the value on their own machine, or already has it in their environment.
env/secrets.yaml GITHUB_TOKEN, GITLAB_TOKEN declared, no value
Alice teamai env set GITHUB_TOKEN her token, stored on her machine
Bob exports GITHUB_TOKEN already used as is
Carol nothing set server skipped, and she is told what to run
- No secret value is written to the team repo.
- A member in two teams, such as a personal one and their company's, keeps a different value for each.
- A value the member already has in their environment is used without setting anything, and they can still override it for one team.
- MCP servers and CLIs the agent runs get the same value.
- A missing secret says which command fixes it, instead of a server that silently isn't there.
- Plain
env.yamlvariables keep working. A member can override one for their team withteamai env set, and every key resolves in one order, for MCP servers and CLIs alike.
Goal. A team can share the credentials its members need, for MCP servers and for CLIs such as gh, glab or an internal tool, without putting a credential in git, and each member uses them with their own account.
Terms. A secret is an env key the team declares without a value. A scope is one teamai config on a machine: the user scope in ~/.teamai, or a project with its own .teamai/config.yaml. A team is one team repo; several scopes can use the same team.
flowchart LR
D["env/secrets.yaml<br/>GITHUB_TOKEN declared"] --> R{{resolve per team}}
S["team value<br/>teamai env set"] --> R
G["machine value<br/>teamai env set --global"] --> R
E["system environment<br/>export GITHUB_TOKEN=..."] --> R
R --> M["mcp/mcp.yaml<br/>Bearer ${GITHUB_TOKEN}"]
M --> C["Claude Code, Codex, OpenCode, ...<br/>MCP config"]
R --> X["teamai env exec -- gh ...<br/>gh, glab, internal CLIs"]
R --> N["not set anywhere<br/>server skipped, pull and doctor say what to run"]
The team declares its secrets next to its variables, in a file of their own.
# env/secrets.yaml
secrets:
- key: GITHUB_TOKEN
description: GitHub token with repo scope, for the github MCP server and gh
url: https://github.com/settings/tokens # where a member gets one
- key: GITLAB_TOKEN
description: GitLab token with api scope, for glab
- key: ACME_TOKEN
description: Token for the acme CLImcp/mcp.yaml doesn't change. A ${VAR} that names a declared secret resolves from the member's value.
# mcp/mcp.yaml, unchanged
servers:
- name: github
transport: http
url: https://api.githubcopilot.com/mcp/
headers:
Authorization: Bearer ${GITHUB_TOKEN}A CLI gets the value when it runs under teamai env exec, which resolves the scope from the current directory:
teamai env exec -- gh pr create
teamai env exec -- glab mr list GITLAB_HOST from env.yaml and GITLAB_TOKEN from the member, for this directory's team
A namespace declares its own secrets in env/<ns>/secrets.yaml, activated by resources.env like env/<ns>/env.yaml. A namespace entry replaces the root entry with the same key.
A member sets a value once per team, or once for the whole machine. The value is read from a hidden prompt, from stdin or from another variable, never from an argument, so it doesn't land in shell history.
teamai env add GITHUB_TOKEN --secret -d "..." --url <url> declare it in the team repo, no value (admin)
teamai env set GITHUB_TOKEN value for this directory's team
teamai env set GITHUB_TOKEN --from-env WORK_GITHUB_TOKEN read it from another variable, no copy stored
teamai env set GITHUB_TOKEN --global value for every scope on this machine
teamai env set GITHUB_TOKEN --stdin for a member's own scripts
teamai env unset GITHUB_TOKEN [--global]
teamai env list each secret: team, global, environment, or missing
teamai env exec -- <command> run a command with this scope's env and secrets
Resolution, per key and team, the same for ${VAR} in MCP servers and for env exec:
value set for this team teamai env set KEY [--from-env VAR] secrets and variables
> value set for the machine teamai env set KEY --global secrets only
> the value in env.yaml the team's value variables only
> the member's own environment not a value a teamai `env.sh` exported secrets only
> missing the server is skipped, or keeps the entry an earlier pull wrote
A team value wins over the environment because it is an explicit choice for that team. Otherwise a personal GITHUB_TOKEN exported in .zshrc would override the company token a member set for their work project. For the same reason, the environment no longer overrides a plain env.yaml variable in MCP servers, as it does today: a GITLAB_HOST exported for one team would reach another team's server. A member who overrides a variable with export today moves to teamai env set KEY, and pull says so when it ignores an exported value that differs from the team's. env.sh then exports the member's value, so the shell follows the same order.
When a secret is missing, interactive pull, doctor, env list and mcp list name the secret, the server that needs it if there is one, and the fix:
github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (https://github.com/settings/tokens).
GITLAB_TOKEN is not set. Run `teamai env set GITLAB_TOKEN`.
The silent session-start pull doesn't print it, since it runs on every session. doctor reports it as a warning, not a failure. env exec prints the same line to stderr and runs the command anyway, since gh and glab can still use their own login (gh auth login).
What I'd put in v1.
env/secrets.yamlandenv/<ns>/secrets.yaml, withkey,descriptionand an optionalurl.env add --secret,env set [--global] [--stdin] [--from-env VAR],env unset [--global], and the secret state inenv list. Without--global,env setalso overrides a plain variable for this team.env exec -- <command>, with this scope's variables and secrets.- Resolution in the order above, for
${VAR}in MCP servers and forenv exec, replacing today's "the environment wins" for plain variables in MCP servers, with thepullnotice for an ignored export. - A declared secret that one pull can't find keeps the MCP entry an earlier pull wrote.
- The actionable message in interactive
pull,doctor,env list,mcp listandenv exec. - The session-start hook tells the agent which secrets the scope declares and to run the CLIs that need them through
env exec. docs/designs/team-secrets.mdandteam-secrets.zh-CN.md, shipped with the code the waymodel-profiles.mdwas: storage, resolution order, delivery to MCP andenv exec, and what stays reachable.- The existing docs and agent skills updated (see Docs and skills in the design notes).
What could wait.
- The OS credential store (Keychain, Credential Manager, Secret Service) instead of a 0600 file.
- Exporting secrets to the shell through
env.sh. - Writing third-party config files, such as
~/.npmrcfor a private registry. A project.npmrcthat reads${NODE_AUTH_TOKEN}already works underenv exec. - A hook that wraps the agent's commands in
env execautomatically. - Team-shared secrets encrypted in the repo (sops/age).
- HTTP mode, which has no env channel today.
- Secrets for remote executors and cloud sandboxes (Discussion: Workflow Runtime Integration #818), where there is no
~/.teamaiand no member environment.
Acceptance check. Alice belongs to a personal team in her user scope and to her company's team in ~/work/api. Both declare GITHUB_TOKEN. She runs teamai env set GITHUB_TOKEN --global with her personal token, then teamai env set GITHUB_TOKEN --from-env WORK_GITHUB_TOKEN in ~/work/api. The github server in her personal team uses the personal token and the one in ~/work/api uses the work token, and teamai env exec -- gh api user returns her personal account in her home directory and her work account in ~/work/api. Bob already exports SENTRY_AUTH_TOKEN and GITLAB_TOKEN in .zshrc and sets nothing. His interactive pull adds the sentry server, a session-start pull that doesn't see the export leaves it in place, and teamai env exec -- glab mr list uses his exported token. Carol sets nothing. Her pull skips github and tells her the command to run, and when she asks her agent about it, the agent asks her to run that command in her own terminal. Dave's team sets GITLAB_HOST in env.yaml, and he exports a different one. His pull keeps the team's value and tells him to run teamai env set GITLAB_HOST; after he does, the MCP server, teamai env exec -- glab and a new shell all use his. In a directory with no scope, teamai env exec -- env shows only the inherited environment and a notice on stderr. No token appears in the team repo or in git log -p.
Design notes: proposed details and open questions
Proposed:
- Separate file.
teamai env addandenv removerewriteenv.yamlwith only itsvariables:list, so a member on an older CLI editing env would drop asecrets:key in the same file. Older CLIs read onlyenv/env.yamlandenv/<ns>/env.yaml, so they ignoreenv/secrets.yamlentirely. A variable without avalueisn't an option either: it fails the schema, and with it the whole env resolution for every member on an older CLI. - Storage. Values live in files of their own under
~/.teamai/secrets/(~/.teamai/envis already the user scope's env backup), apart from model profile keys:machine.jsonfor the machine, andteams/<hash>.jsonper team repo, keyed by the same repository-identity hashteamai models configureuses, but without the team's display name, so renamingteam:inteamai.yamldoesn't orphan anyone's values. A team used from several projects or worktrees then needs oneenv set. Each entry is either a literal value or the name of a variable to read (--from-env). They always live under~/.teamai, never in the scope's data directory, since in single-repo mode that sits inside the business repo. Files are written atomically with mode 0600, like the model key files. - Model profile keys. They stay where they are.
api_key: ${API_KEY}inmodels/models.yamlis a slot per profile, not a variable: every profile uses the same placeholder, and its key is bound to the profile and its gateway's origin (feat(projects): extend project isolation to knowledge/docs/agents/env/hooks/mcp #707). It is written into each agent's machine-wide config, so it can't differ per directory.teamai models configurekeeps storing it under~/.teamai/models/.env setdoesn't configure it, andenv/secrets.yamlcan't declare it.models configure --from-env VARstill reads only the process environment, not a value set withenv set. The two share code, not files: the model key files are parsed strictly, so a secret entry there would break everymodelscommand on an older CLI. env exec. It resolves the scope from the current directory withdetectProjectConfig, by git anchors, so every worktree of a project resolves to that project, and falls back to the user scope. Then it runs the command with the inherited environment, overlaid with this scope's variables and secrets resolved in the order above, so aGITLAB_HOSTexported for another team doesn't leak in. It writes no value to disk. A variable or secret named like one a model profile writes (ANTHROPIC_*) overrides that profile for the command, soenv execis for CLIs, not for starting an agent.- Outside a scope.
env execin a directory with no teamai scope runs the command with the inherited environment and says on stderr that no scope was found. Machine values aren't applied there, since no team declares which keys the command needs. - Workflows (Discussion: Workflow Runtime Integration #818). A workflow step that lists
requires: { env: [GITHUB_TOKEN] }refers to the key declared here and resolves it in the same order. The step declared that it needs the key, so a missing one fails the run before it starts, instead of warning asenv execdoes. In CI there is nothing under~/.teamai, so the value comes from the job's environment, the last step of the order. No value is written to run history or sent to an executor. - Telling the agent. An agent that runs
ghwithoutenv execsilently uses whatever account its environment has. When the scope declares secrets, the session-start hook adds one line to the agent's context throughadditionalContext, the way the MR hint does, listing each key with itsdescriptionand saying to run those CLIs throughteamai env exec --. Agents without a SessionStart hook get the same rule from the core skill. - Keeping the entry. The session-start pull inherits the agent's environment, so a secret that only lives in the member's shell is there for one pull and gone for the next. Today a missing
${VAR}removes the managed entry. For a declared secret, a pull that can't find it keeps the entry as it is, the way a file that doesn't parse changes nothing today, and the way a model profile keeps an agent's settings when a pull can't see its--from-envvariable. The entry is removed only when its server leavesmcp.yaml. A rotated or removed secret then keeps the old value until a pull finds the new one, anddoctorsays so. Variables that aren't declared as secrets keep today's behaviour. - Not in
env.sh. Secrets are never written toenv.sh. It is sourced by every shell, is written with the default umask, and the profile loads one scope at a time (env: a project pull drops the user scope's env block from the shell profile #876).env execcovers the per-scope case, and a member who wants the token in every shell already has it in their environment. - Same key twice. A key declared as a secret and also set as a variable in
env.yamlresolves as a secret, and the repo value is ignored.pullanddoctorwarn about it. An older CLI keeps using the variable, so nobody loses the server while the team removes the value. env setchecks. In a scope,env setaccepts only keys that scope declares as secrets or receives as variables, and--globalonly secrets.env set --globaloutside any scope accepts any valid key name and notes that no team declares it yet, so a member can set a token they reuse across teams ahead of time.- No migration. A team that committed a token has to rotate it, since git history keeps it. The docs say so, and that's all v1 does about it.
- Agents. The agent skills say that an agent never asks for a secret value in chat, never passes one through
--stdin, never reads the values file, and never prints or quotes a secret. Whendoctor,pull,mcp listorenv execreport a missing secret, the agent asks the member to runteamai env set KEYin their own terminal. Declaring a secret withenv add --secretis safe for an agent, since it has no value. - Docs and skills.
docs/usage-guide.*(including its MCP Secrets paragraph, which says the environment wins),docs/product-overview.*, a link fromdocs/designs/model-profiles.*to the new design doc for Model profile keys,docs/designs/multi-project-management.mdanddocs/designs/management-backend.*in both languages;skill-data/corefor members, including when to useenv exec;skill-data/setup/references/manage-admin.mdfor admins;skill-data/core/references/commands.mdregenerated. No README change. - Still reachable. The resolved value is still written in plaintext to each tool's MCP config, as today (new files are created 0600). A command under
env execgets it in its environment, so an agent that runsteamai env exec -- envcan read it; the skills forbid it, but nothing enforces it. An agent that can runghwith a token can already do whatever the token allows. This keeps secrets out of git, not away from the member's machine or the agent it runs. - Not bound to a server. A secret reaches whatever server
mcp.yamlnames, as${VAR}does today. Unlike model profile keys (feat(projects): extend project isolation to knowledge/docs/agents/env/hooks/mcp #707), it isn't tied to a host, so whoever can changemcp.yamlor add a namespace decides where members' tokens go. Whoever can push to the team repo already ships hooks that run on every member's machine, so v1 accepts this and the docs say so. - Rejected. Writing secrets into each agent's config (Claude
settings.jsonenv,.claude/settings.local.json): only Claude injects them, and the value would sit in plaintext inside the business repo's directory. Aused_byfield:descriptionalready says which tool uses the key, and the field would drive no behaviour. A hook that rewrites the agent's commands to addenv exec: it depends on each agent and means parsing shell commands. - Code seams. Team key files and the
{ value } | { env }shape:getTeamValuesPathandresolveProfileinsrc/models/profile.ts. MCP variable table:buildVarTableinsrc/mcp-reconcile.ts. Session context: theadditionalContextmerge insrc/hook-dispatch.ts, assrc/mr-hint.tsuses it. Scope for a directory:detectProjectConfiginsrc/config.ts. - Prior art. Executor keeps credentials apart from app source and lets a member keep a work and a personal account for the same service. This takes the part that fits a git-backed tool. Inside teamai, the models channel already declares
api_key: ${API_KEY}in the repo and keeps the value on each machine, per team. Secrets reuse that shape, not its files; see Model profile keys.
Decided for v1, open to feedback until the spec is merged:
doctorwarns, it doesn't fail, when a delivered MCP server needs a missing secret.- The link field is
url. - The command is
env exec.
Feedback from anyone whose team shares credentials for MCP servers or CLIs would help:
- Does a value per team plus one per machine cover how you split personal and work accounts?
- Do your members already keep these tokens in their shell environment, or would they rather set them with teamai?
- Which CLIs does your team need a per-team token for?
- Is anything here more than a first version needs?
Activity
[-]Proposal: declare team secrets in the repo, keep their values on each machine[/-][+]Proposal: declare team secrets in the repo, keep their values on each machine, for MCP servers and CLIs[/+]feat(env): declare team secrets in env/secrets.yaml and show their st…
test(mcp): keep excluding a config whose entry a pull kept for a miss…
fix(mcp): replace a symlinked Codex config instead of writing into th…
fix(env): drop what a teamai env.sh exported from env exec when env.y…
fix(env): on Windows, match a declared secret to an env.yaml variable…
fix(env): on Windows, set and unset a key under the name the scope de…
fix(env): on Windows, match a secret's declaration and stored value i…
fix(env): key a team's values by its repo URL when the configured rem…
fix(env): on Windows, recognise a secret's env.yaml value under anoth…
fix(mcp): on Windows, keep an inherited value under another case of a…
fix(env): on Windows, replace and remove every stored entry under ano…
fix(mcp): on Windows, fill a placeholder from the variable of the sam…
fix(mcp): on Windows, match a placeholder to its secret in any case i…
fix(env): keep a file:// repo's .git suffix in its values file identi…
fix(env): on Windows, update and remove an env.yaml variable typed in…
feat(env): team-declared secrets with member-local values (#875) (#880)
feat(env): team-declared secrets with member-local values (Tencent#875)…