Skip to content

Give everything chopin connect starts only Chopin-issued GitHub and MCP credentials, and stop a launch Chopin refuses - #876

Open
lavaman131 wants to merge 4 commits into
mainfrom
connector-no-local-github
Open

lavaman131 wants to merge 4 commits into
mainfrom
connector-no-local-github

Conversation

@lavaman131

@lavaman131 lavaman131 commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Start nothing on a paired machine without Chopin's GitHub token, and use nothing of the machine's own GitHub setup

Requires:

  1. The server's GitHub App launch tokens (purpose, identity, starter, check, protocol 7): on main as 3df74b1.
  2. The server's mcp-chopin-bearer change (POST /api/connector/mcp-token, and /mcp accepting MCP tokens): on main as b8bf8eb. This branch is rebased onto origin/main 8634eef.
  3. A deployment's GITHUB_APP_ID and App key, without which every protocol-7 request is refused no-grant and nothing runs on a paired machine.
  4. A backend that reads CHOPIN_MCP_TOKEN_FILE as its /mcp bearer before it requires connector protocol 7. A launch's GitHub token is now a GitHub App installation token, and /mcp refuses those.

Don't ship the connector ahead of 3 and 4.

What was wrong

A paired machine's work fell back to the machine's own GitHub setup whenever Chopin gave no token, and in other places even when it did:

  • Refusals were ignored. A refused or failed token request returned nothing, and the run or Planner started anyway. chopin status described this as "this machine uses its own GitHub login".
  • Builds had only a stale token. A build's agent got GH_TOKEN and GITHUB_TOKEN set to the first token. They were never renewed.
  • Planners had no gh credential at all, so gh used the machine's own login.
  • The parent environment leaked in. Every launch inherited it: chopin connect --foreground passed a shell's GITHUB_TOKEN, SSH agent and GIT_CONFIG_* on.
  • The connector's own git used the person's SSH. It ran the person's core.sshCommand and keys, and fell back to an anonymous fetch.
  • Commits used the machine's identity and signing. Nothing set them.

On a machine with two GitHub accounts, this is how work reached GitHub as an account that wasn't the one Chopin checked.

What changes

Every launch asks Chopin first, and nothing starts on a refusal. A Planner, build, rebuild, investigation or spike asks POST /api/connector/github-token with purpose (planner or run), run and document.

  • A refusal stops the launch before its agent or command starts, with a message naming the repository. The codes are repository-forbidden, app-cannot-push, starter-cannot-push, run-unknown and no-grant. A run reports the message as its failure, and a Planner stops and tells Chopin why.
  • Chopin out of reach is retried for a Planner, as a failed start is, and fails a run.
  • The "own GitHub login" fallback, githubCannotPush and its status text are gone.

What a launch gets (githubEnvironment):

GH_CONFIG_DIR The launch's own gh configuration (hosts.yml in gh 2.40+'s layout, config.yml with version: "1"). It is rewritten on every renewal, and gh never reads ~/.config/gh.
CHOPIN_GITHUB_TOKEN_FILE The token file, for runs as well as Planners. Planners keep CHOPIN_GITHUB_TOKEN and its expiry.
git One credential helper for github.com that reads the file. The repository's SSH URLs are rewritten to https. GIT_SSH_COMMAND=false.
GIT_AUTHOR_*, GIT_COMMITTER_* The App's bot, from Chopin's answer. user.useConfigOnly=true, commit.gpgsign=false and tag.gpgsign=false are set through GIT_CONFIG_*.
CHOPIN_STARTER_LOGIN, _NAME, _EMAIL Who the work is for, for "Requested by" and Co-authored-by.
CHOPIN_MCP_TOKEN_FILE A Chopin token for /mcp. A Planner's is minted from its credential and renewed with it. A build's or investigation's is minted from the run credential and renewed 10 minutes before it expires. A refusal deletes the file and the launch goes on. A spike's agent gets none.

What a launch doesn't get. Before the kit is added, GH_*, GITHUB_TOKEN, GITHUB_ENTERPRISE_TOKEN, GIT_ASKPASS, SSH_ASKPASS, SSH_AUTH_SOCK, GIT_SSH*, GIT_CONFIG* and inherited GIT_AUTHOR_* and GIT_COMMITTER_* are removed from the connector's environment and from --env values. That covers Planners, runs and the connected agent check. A model login such as COPILOT_GITHUB_TOKEN stays.

Renewal and expiry. The token is renewed 25 minutes before it expires. When a renewal fails and the token expires, or Chopin refuses the renewal, the files are removed and the launch stops: a run fails saying so, and a Planner starts again with a new token.

The connector's own git never uses SSH. GIT_SSH_COMMAND=false wins over everything, and SSH_AUTH_SOCK, GIT_SSH and the askpass variables are dropped. A spike's host fetch uses Chopin's token, then the token goes before the spike's agent starts. The spike's agent gets no gh login, helper or SSH, and still can't push.

No credential on a command line. This was found on a live machine. MXC starts a sandboxed launch with each environment variable on the sandbox tool's command line (bwrap … --setenv CHOPIN_PLANNER_TOKEN <value> --setenv CHOPIN_GITHUB_TOKEN <value> …), so any process on the machine could read them in /proc/<pid>/cmdline or ps. That covered a Planner's credential and GitHub token and every --env value, for every launch kind.

Now, on Linux and macOS, sealedEnvironment writes the launch's whole environment to a 0600 file in its private temporary directory. MXC is given only PATH and that file's path, plus any variable whose name no shell can export. A one-line /bin/sh launcher exports the file, deletes it, unsets its path and execs the launch's own command. So the launch, and what it starts, has every variable as before, CHOPIN_PLANNER_TOKEN and CHOPIN_GITHUB_TOKEN included for backends that read them. They are readable only through /proc/<pid>/environ, which only the same user can read.

The proxy MXC sets still wins: the person's *_proxy variables are not passed on. They are dropped either way, because MXC overrides them, and a proxy URL can carry a password.

On Windows the policy is unchanged; ProcessContainer passes the environment as an environment, not verified here. Unsandboxed launches already get their environment through spawn, not their command line.

--sandbox-hide <path>. This new option (repeatable, saved like --sandbox-write) hides a file or folder from every sandboxed agent:

  • The read and write grants are listed around it, as for the state directory, so it is hidden even inside a --sandbox-write folder.
  • It is also denied, as a secret path is.

A backend can name its own stored sign-in here.

chopin status. --json gains github: { "owner/repo": "ok" | <refusal> | "unknown" } for registered checkouts. It is asked with { repository, purpose: "check" }, which mints no token. A refused repository's Checkouts line says why.

Protocol 7. CONNECTOR_PROTOCOL is 7, so a backend can require this behaviour.

Docs. In docs/chopin-cli.md, "Which GitHub account pushes" is rewritten, and --sandbox-hide is added to the options and commands. docs/agent-connection.md gains the CHOPIN_MCP_TOKEN_FILE row and a note on the bot token. docs/chopin-cli.md links to docs/github-app-identity.md for setup. That file lands with the docs branch (docs-github-app-identity), so merge them together.

e2e harness. The servers get GITHUB_APP_ID and a key generated for each run (e2e/github-app.ts). e2e/github.ts plays GitHub for the App: the installation, access tokens, collaborator permission, users and token revoke. Without that, every connector run in e2e would be refused no-grant.

Tests

New tests in github-token.test.ts:

  • The request names its purpose, run and document.
  • Real gh (gh auth token under GH_CONFIG_DIR) and git credential fill both see the renewed token.
  • Every refusal rejects with a message naming the repository, and writes nothing.
  • An expired token is removed and aborts expired.
  • withoutGitHub drops the list above and keeps everything else.
  • Under the kit, with a person's config set up as a decoy (identity, commit.gpgsign and a failing gpg.program, a credential helper, insteadOf to SSH), a commit is made as the bot and unsigned, the push URL is https, and the helper never runs.

In run.test.ts, on a decoy machine:

  • The decoy machine has a HOME with gh logged in as two other accounts, plus a credential helper, SSH rewrites, an identity with signing, an SSH key, GH_TOKEN, GITHUB_TOKEN, SSH_AUTH_SOCK and GIT_ASKPASS. A tripwire gh and ssh sit first on PATH.
  • A build's agent sees only Chopin's token: in gh (gh pr create), in git's credentials and in the token file.
  • Its commit is made as the bot and unsigned, and it gets the starter variables and the MCP token.
  • Nothing of the machine's is reached.

Also:

  • Refused runs: a refused run never starts its agent and reports the refusal, for three codes (run.test.ts).
  • Planners: the same for Planner commands, including the request's purpose and document, gh's configuration, the bot identity, and that a refusal stops the Planner while an unavailable Chopin is retried (planner.test.ts).
  • Status: status.test.ts covers the check answers.
  • Host git: git-host.test.ts checks that the connector's git runs no ssh: not the person's, not the environment's, not PATH's, and not the repository's.
  • Sandbox: sandbox.test.ts checks that a hidden path is in no grant on Linux and macOS, even inside a writable folder, and is denied.
  • Connect: main.test.ts checks that --sandbox-hide is saved.
  • Command lines: new sandbox-linux.test.ts tests run in real bubblewrap, one for a bare launch and one for each launch kind: a Planner, a build, an investigation and a spike. While each launch runs, they read every process's /proc/<pid>/cmdline and assert that no value appears: the Planner credential, the GitHub and MCP tokens, the run token, and an --env secret. They assert that the launch still has its variables, the Planner's CHOPIN_PLANNER_TOKEN and CHOPIN_GITHUB_TOKEN among them, and that the token files and gh's hosts.yml are 0600 in a 0700 folder. A value with quotes, a newline and $ arrives intact, and the environment file is gone once the command starts. All five fail with the fix turned off. sandbox.test.ts covers sealedEnvironment itself on any system.

Updated for the new behaviour: checkouts.test.ts, investigation-run.test.ts, and the implementation-launcher and build-restart e2e specs.

Checks run

  • bun run types: pass. bun run ci: pass.
  • bun test apps/connector/src on the final head: 384 pass, 1 skip, 0 fail. sandbox-linux.test.ts in real bubblewrap: 21 pass.
  • Playwright (--workers=2):
    • e2e/connector.e2e.ts: 3 passed.
    • With e2e/build-restart.e2e.ts and e2e/implementation-launcher.e2e.ts: 28 passed and 1 skipped.
    • e2e/machine-choice.e2e.ts: passed.

Judgement calls

  • Spikes get no MCP token. They keep their stricter posture: no token of any kind on disk while the agent runs. Their Chopin tools come from the bridge, as before.
  • A missing MCP token isn't fatal. A refused or unreachable MCP token deletes the file but doesn't stop the launch. That matches the server's contract: Chopin's tools refuse, and GitHub work goes on.
  • The decoy tests run unsandboxed. That is the stricter case for this change: only the design, not the sandbox's deny list, keeps the decoys out. The sandboxed Planner tests in sandbox-linux.test.ts (bubblewrap) run with the new launch environment and pass.
  • The pre-pairing start check is left as it is. chopin service install's and connect --install-service's ACP initialize check still gets the person's environment. It runs before pairing and sends the agent nothing from Chopin, as in design §3.
  • githubCannotPush is removed from chopin status --json. That breaks the rule that its keys are only added to. The value described a fallback that no longer exists.

…use nothing of the machine's own

Every Planner command, build, rebuild, investigation and spike now asks
Chopin for its GitHub token with what it is for (purpose, run, document), and
starts only once it has one. A refusal (repository-forbidden, app-cannot-push,
starter-cannot-push, run-unknown, no-grant) stops it before its agent or
command starts, with a message naming the repository. A run reports that
message as its failure, and a Planner stops and tells Chopin why. The machine
never falls back to its own GitHub login, so the fallback, githubCannotPush
and its status text are gone.

A launch reaches GitHub only through what the connector gives it:
- its own gh configuration (GH_CONFIG_DIR), rewritten with each renewed token;
- the token file (CHOPIN_GITHUB_TOKEN_FILE), for runs as well as Planners;
- one git credential helper reading that file, with the repository's SSH
  addresses rewritten to https and GIT_SSH_COMMAND=false;
- the App bot's identity in GIT_AUTHOR_* and GIT_COMMITTER_*, with
  user.useConfigOnly set and signing off;
- who the work is for in CHOPIN_STARTER_*.
GH_*, GITHUB_TOKEN, askpass, the SSH agent, GIT_SSH* and GIT_CONFIG* are
removed from what it inherits and from --env values. Chopin's own tools take
a separate Chopin token (CHOPIN_MCP_TOKEN_FILE), minted from the launch's
credential and renewed with it. A token that expires without a renewal stops
what used it.

The connector's own git never uses SSH. --sandbox-hide hides a file or
folder from every sandboxed agent, even inside a --sandbox-write folder.
`chopin status` asks Chopin, without a token being made, whether each
registered checkout's repository can have one. The connector speaks protocol 7.

The e2e servers get a GitHub App key, and the fake GitHub plays the App, so
a connector run there gets a token the way it would from GitHub.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun run types
Assistant-verification: bun run ci
Assistant-verification: bun test apps/connector/src (375 tests; the 4 failures of the first full run fixed and their files re-run: main, binary, sandbox-linux)
Assistant-verification: e2e/connector.e2e.ts, e2e/build-restart.e2e.ts, e2e/implementation-launcher.e2e.ts (28 passed, 1 skipped)
Assistant-verification: e2e/machine-choice.e2e.ts
User-preference: Chopin and its backends get GitHub credentials from Chopin, never from the machine's gh CLI or its active account
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…it no longer inherits

docs/chopin-cli.md's "Which GitHub account pushes" now says the App's bot
does, for the person who started the work, lists what a launch gets for
GitHub and what is removed, shows a refusal in `chopin status`, and adds
--sandbox-hide. docs/agent-connection.md adds CHOPIN_MCP_TOKEN_FILE and says
the GitHub token is for Git and gh only. Setup is linked, not repeated.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun run ci
User-preference: Chopin and its backends get GitHub credentials from Chopin, never from the machine's gh CLI or its active account
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…s on any command line

MXC starts a sandboxed launch with each variable on the sandbox tool's command
line (`bwrap --setenv NAME VALUE`), which any process on the machine can read
in /proc/<pid>/cmdline or `ps`. That included a Planner's credential, its
GitHub token and every --env value.

On Linux and macOS the launch's environment now goes to the sandbox in a 0600
file in its private temporary directory. MXC is given only PATH and that
file's path. A one-line shell exports the file, deletes it and becomes the
launch's command, so the launch has every variable as before,
CHOPIN_PLANNER_TOKEN and CHOPIN_GITHUB_TOKEN included, and only the person's
own processes can read them. The proxy MXC sets still wins, and the person's
proxy variables, which can carry a password, are not passed on. Windows is
unchanged.

New sandbox-linux tests, in real bubblewrap, start a bare launch, a Planner,
a build, an investigation and a spike. They find none of their credentials on
any process's command line, check that each still has its variables, and that
the token files are 0600. All five fail without the change.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun test apps/connector/src (384 pass, 1 skip, 0 fail)
Assistant-verification: bun test apps/connector/src/sandbox-linux.test.ts in real bubblewrap (21 pass; the 5 new ones fail with the change turned off)
Assistant-verification: bun run types
Assistant-verification: bun run ci
User-preference: Chopin and its backends get GitHub credentials from Chopin, never from the machine's gh CLI or its active account
Co-authored-by: Alex Lavaee <lavaman131@github.com>
… a command line

docs/chopin-cli.md's sandbox table now says how the environment reaches a sandboxed agent on Linux and macOS.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun run ci
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 force-pushed the connector-no-local-github branch from 6dc90cc to 97e3fe3 Compare November 12, 2026 23:38

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant