* feat: add run_as_user for per-project OS-user isolation
Implements #496. Adds an optional `run_as_user` field at the project
level that, when set, spawns the project's agent command under a
different Unix user via `sudo -n -iu <user> --`. This provides OS-level
file-system isolation from the supervisor user that runs cc-connect
itself: a misbehaving agent can no longer reach files outside the
target user's permission scope, even via python -c / shell redirection
/ other non-hooked code paths.
## What lands in this PR
### Config (config/)
- new fields `run_as_user *string` and `run_as_env []string` on each
`[[projects]]` entry
- validation: rejects root, rejects invalid usernames, rejects use on
Windows, enforces POSIX username charset to close a sudo argv
injection vector
### Core primitives (core/runas*.go)
POSIX-only (Windows stubs provided for API compatibility).
- `BuildSpawnCommand(ctx, opts, name, args...)` — wraps exec.Cmd with
`sudo -n -iu <user> --preserve-env=<allowlist> -- name args...` when
opts.RunAsUser is set; passthrough otherwise
- `FilterEnvForSpawn(env, opts)` — strips supervisor env to a minimal
allowlist (PATH, LANG, LC_*, TERM) plus any project-level extensions
- `VerifyRunAsUserCheap` — the two cheap sudo probes, suitable for
per-spawn re-verification (catches sudoers edits between startup and
spawn)
- `PreflightRunAsUser` — the three go/no-go startup gates from #496:
(1) passwordless sudo to target works,
(2) target cannot passwordless sudo (if escalation succeeds, captures
`sudo -n -l` output in the error message), and
(3) target can read AND write work_dir root. Plus a non-fatal
descendant scan warning on unreadable/unwritable descendants with
prune-paths, 10s timeout, and a 50-entry cap.
- `RunIsolationProbe` + embedded runas_probe.sh (//go:embed) — the
leak-audit probe codex asked for: spawns a fixed shell script as the
target user and parses its output into a structured IsolationReport.
Reports effective id/groups/umask/pwd, target-home config presence,
cross-user denial attempts for every other configured run_as_user,
and supervisor-denial attempts. Never reads file contents, only
existence and access.
All of these are testable without a real second user via an injectable
`SudoRunner` interface; production uses ExecSudoRunner.
### Failure policy
Per-spec: all checks are hard go/no-go, not warnings.
- Any preflight fatal in any project: cc-connect refuses to start
entirely; no half-spawned processes.
- Any audit-level `CROSS_LEAKED` (target reads another project user's
secrets) or `SUPERVISOR_LEAKED` (target reads the supervisor's
secrets) or `WORKDIR_WRITABLE=no`: cc-connect refuses to start.
- Descendant-scan findings are warnings (inaccessibility inside a
project tree is sometimes intentional).
### Startup wiring (cmd/cc-connect/)
- `runRunAsUserStartupChecks` runs preflight + isolation probe for
every project with run_as_user set, in parallel, BEFORE any agent
or engine is constructed. Logs every outcome via slog. On any fatal,
prints the full list and `os.Exit(1)`.
- injects project-level run_as_user / run_as_env into the agent's
options map before calling `core.CreateAgent`, so agents can opt in
without new top-level plumbing.
### Doctor subcommand (cmd/cc-connect/doctor_runas.go)
New `cc-connect doctor user-isolation [--project NAME] [--out PATH]`
subcommand. Runs preflight + isolation probe for one or all
`run_as_user` projects, writes a JSON report per project, and prints a
human-readable summary. Default output is
`~/.cc-connect/audits/<timestamp>-<project>.json`.
Also supports `--print-script` to dump the embedded probe source for
inspection (and for running shellcheck against it).
### Claude Code wiring (agent/claudecode/)
- `Agent` struct gains `runAsUser` and `runAsEnv` fields
- `New()` parses them from the opts map and skips the
`exec.LookPath("claude")` check when run_as_user is set (the target
user's PATH is what matters, not the supervisor's)
- `newClaudeSession` takes `core.SpawnOptions`, runs the per-spawn
cheap re-check immediately before building the command, uses
`core.BuildSpawnCommand` instead of `exec.CommandContext`, and
filters env via `core.FilterEnvForSpawn`
### Docs
- new "Running agents as a different Unix user" section in
`docs/usage.md` with setup steps, sudoers example, ACL example,
environment-propagation migration checklist (PGSSL*, ~/.claude/
settings.json, ~/.pgpass, ~/keys/, ~/.ssh/, MCP binaries, shell
profile, language toolchains), opt-out instructions, and
failure-mode debug guide
- README section with a short configuration example and a pointer to
the full docs
- `config.example.toml` commented example with setup recipe
### Tests
- config: parse run_as_user/run_as_env, reject root, reject invalid
chars, Windows rejection, username validator valid/invalid cases
- core: BuildSpawnCommand legacy vs. sudo form, FilterEnvForSpawn
filtering, VerifyRunAsUserCheap success/no-sudo/escalation-possible/
empty-user, PreflightRunAsUser all-pass/no-sudo/escalation-fatal/
workdir-fatal/descendant-warnings/descendant-cap, probe parser
clean/cross-leak-fatal/supervisor-leak-fatal/workdir-not-writable-
fatal, shell quoting, other-users filtering, embedded probe script
sanity
- agent/claudecode: New() parses run_as_user/run_as_env, run_as_user
skips LookPath
- cmd/cc-connect: default audit dir suffix, printHumanReport doesn't
panic on populated report
Uses stubbed SudoRunners throughout — no tests require a real second
Unix user or real sudo binary.
## Scope
Only Claude Code is wired up in this PR. Each non-claudecode agent has
its own session/spawn lifecycle (codex re-spawns per turn, cursor has
its own state model, etc.) so the sweep needs per-agent reasoning and
is deferred to a follow-up. The core primitives land here so each
future agent migration is a small, self-contained PR.
Follow-up issue tracks: codex, cursor, gemini, iflow, opencode, qoder,
acp, pi.
Refs: #496
Credit: issue framing via user + codex adversarial review
(go/no-go gates, leak-audit probe, env propagation docs,
sudo -iu rationale pinning)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(run_as_user): simplify after code review
Addresses findings from a parallel code-reuse / code-quality / efficiency
review of #498. Behavior unchanged; tests still pass.
Highlights:
- cache VerifyRunAsUserCheap results for 30s keyed by runAsUser, so
rapid-fire messages from the same Slack user don't pay the ~100ms
per-spawn sudo tax. Cache evicts on failure so the next spawn
re-verifies fresh. Adds ResetVerifyCache for tests + operators.
- doctor user-isolation now fans out across projects in parallel
(mirroring runas_startup.go) instead of iterating serially. Per-project
output is buffered and printed in order so the report stays grouped.
- collapse Agent.runAsUser + Agent.runAsEnv into a single
core.SpawnOptions field — eliminates the reassemble-at-call-site
pattern.
- fold core/runas_username.go into core/runas.go (one 17-line file →
one helper near its only caller).
- IsolationReport.RawOutput is now only populated when the audit had a
fatal — clean reports are smaller on disk.
- introduce typed string constants for the probe wire format
(tagBegin / tagWorkDirReadable / tagCrossLeaked / ...) so the parser
and runas_probe.sh share a discoverable contract.
- itoa() in tests → strconv.Itoa
- contains() helper in tests → slices.Contains
- mass comment trim across runas.go / runas_check.go / runas_audit.go:
delete WHAT-restates-name comments (~70 lines) while keeping the
load-bearing WHY blocks (sudo -n -iu rationale, env propagation
reasoning, find expression format note, scan timeout justification).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runas): drop PATH from default env allowlist
Preserving PATH across the sudo boundary defeats the whole point of
-i: it overrides the target user's login-profile PATH with the
supervisor's, and leaks supervisor work directories (e.g. the
cc-connect source tree on the supervisor's PATH) into the isolated
session.
sudo -i now rebuilds PATH from /etc/profile + the target user's
~/.profile. Binaries the isolated agent needs (like the claude CLI)
should be installed system-wide (e.g. /usr/local/bin symlinks) or put
on the target user's own shell profile.
Caught by Q6/Q8 daemon-spawn verification in data-worklog Phase 0a:
partseeker-coder sessions were running with leigh's PATH intact.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(engine): propagate run_as_user to per-workspace agents
In multi-workspace mode, getOrCreateWorkspaceAgent constructs a fresh
opts map for each per-workspace agent instead of inheriting the
project-level opts that main.go decorated at startup. As a result,
run_as_user (and run_as_env) silently dropped out of the per-workspace
spawn path — the project-level config said partseeker-coder but every
spawn actually ran as the supervisor.
Fix: expose GetRunAsUser/GetRunAsEnv accessors on *claudecode.Agent
and copy them across in getOrCreateWorkspaceAgent via interface
assertion, mirroring the existing GetModel/GetMode pattern. Two
regression tests cover the propagation and the no-propagation case
so multi-workspace deployments that don't use run_as_user are
unaffected.
Caught by Q6/Q8 daemon-spawn verification in data-worklog Phase 0a.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add OAuth credential sharing guidance for run_as_user setups
OAuth access tokens expire after a few hours and only get refreshed by
active Claude CLI sessions. When the target user has no active session
between cc-connect spawns, its token expires and auth fails with 401.
Document the symlink + ACL pattern: target user's credentials.json
symlinks to the supervisor's copy so both share one auto-refreshed token.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: graceful session shutdown — close stdin before signals
The old Close() called context.Cancel() which sends SIGKILL immediately
via Go's exec.CommandContext, giving Claude Code zero time to run Stop
hooks. This broke claude-mem session summaries (120s timeout) and any
other cleanup that depends on a clean exit.
New three-phase shutdown:
1. Close stdin (EOF) — Claude Code exits cleanly, running Stop hooks
2. SIGTERM after 30s grace period if still alive
3. SIGKILL after 5s more as last resort
Also increases engine-level close timeout from 10s to 45s to accommodate
the longer graceful window.
Tested: all agent/claudecode and core tests pass. Verified Claude Code
exits cleanly on stdin EOF (exit code 0).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: 120s graceful close + hold message during session transition
Increases the default graceful stop timeout from 30s to 120s to match
claude-mem's Stop hook timeout. The wait ends as soon as the process
exits — typical shutdowns take seconds, not the full 120s.
When a user messages during an idle-reset that triggers a graceful
close, they now see "Wrapping up your previous session (usually a few
seconds, up to 2 minutes). Your new session will start automatically."
instead of a silent delay. The hold message is sent before the blocking
close, and the new session starts immediately when the old one finishes.
Engine-level close timeout raised to 130s (120s grace + 5s SIGTERM +
5s buffer).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Leigh Stillard <leigh@stillard.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both features are marked as Beta (v1.2.2-beta.5+). Covers /web setup
chat command, manual config.toml options, Management API endpoints,
Bridge WebSocket/REST access, authentication methods, and build tags.
Made-with: Cursor
MiniMax-M2.7 is the latest flagship model with 1M context window.
- Update default model to MiniMax-M2.7 in provider config examples
- Add models list with M2.7, M2.5, and M2.5-highspeed aliases
- Update context window description from 204K to 1M
- Update both English and Chinese documentation
Add MiniMax (OpenAI-compatible, 204K context) as a provider example in
config.example.toml and usage docs (EN/ZH), alongside existing providers
like SiliconFlow and Bedrock. This helps users discover and configure
MiniMax models (MiniMax-M2.5, MiniMax-M2.5-highspeed) with cc-connect.
Co-Authored-By: octo-patch <noreply@github.com>