Skip to content

Commit 6f183a9

Browse files
JAORMXclaude
andcommitted
Enable git operations inside guest VMs
Include .git/objects/ in snapshots (COW makes this zero-cost), inject a sanitized .git/config into snapshots, forward git identity and GITHUB_TOKEN/GH_TOKEN into the VM, add a credential helper scoped to github.com, and implement SSH agent forwarding from host to guest. New domain types: git.Identity, git.IdentityProvider, CommonEnvPatterns, workspace.SnapshotPostProcessor, and GitConfig with tighten-only merge. New infra: ConfigSanitizer (allowlist-based .git/config parser that strips credentials, dangerous sections, and sensitive keys), HostIdentityProvider (reads ~/.gitconfig), InjectGitConfig rootfs hook (credential helper + .gitconfig), and client-side SSH agent forwarding. CLI flags: --no-git-token, --no-git-ssh-agent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c4392d9 commit 6f183a9

20 files changed

Lines changed: 1677 additions & 58 deletions

File tree

cmd/apiary-init/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func main() {
3131
stopReaper := reaper.Start(logger)
3232
defer stopReaper()
3333

34-
shutdown, err := boot.Run(logger)
34+
shutdown, err := boot.Run(logger, boot.WithSSHAgentForwarding(true))
3535
if err != nil {
3636
logger.Error("boot failed", "error", err)
3737
halt()

cmd/apiary/main.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ import (
2323
"github.com/stacklok/apiary/internal/domain/egress"
2424
"github.com/stacklok/apiary/internal/domain/progress"
2525
"github.com/stacklok/apiary/internal/domain/snapshot"
26+
"github.com/stacklok/apiary/internal/domain/workspace"
2627
infraagent "github.com/stacklok/apiary/internal/infra/agent"
2728
infraconfig "github.com/stacklok/apiary/internal/infra/config"
2829
"github.com/stacklok/apiary/internal/infra/diff"
2930
"github.com/stacklok/apiary/internal/infra/exclude"
31+
infragit "github.com/stacklok/apiary/internal/infra/git"
3032
infralogging "github.com/stacklok/apiary/internal/infra/logging"
3133
inframcp "github.com/stacklok/apiary/internal/infra/mcp"
3234
infraprogress "github.com/stacklok/apiary/internal/infra/progress"
@@ -70,6 +72,8 @@ func rootCmd() *cobra.Command {
7072
mcpGroup string
7173
mcpPort uint16
7274
mcpConfig string
75+
noGitToken bool
76+
noGitSSHAgent bool
7377
)
7478

7579
cmd := &cobra.Command{
@@ -114,6 +118,8 @@ Example:
114118
mcpGroup: mcpGroup,
115119
mcpPort: mcpPort,
116120
mcpConfig: mcpConfig,
121+
noGitToken: noGitToken,
122+
noGitSSHAgent: noGitSSHAgent,
117123
})
118124
},
119125
SilenceUsage: true,
@@ -136,6 +142,8 @@ Example:
136142
cmd.Flags().StringVar(&mcpGroup, "mcp-group", "default", "ToolHive group to discover MCP servers from")
137143
cmd.Flags().Uint16Var(&mcpPort, "mcp-port", 4483, "Port for MCP proxy on VM gateway")
138144
cmd.Flags().StringVar(&mcpConfig, "mcp-config", "", "Path to custom vmcp config YAML")
145+
cmd.Flags().BoolVar(&noGitToken, "no-git-token", false, "Disable forwarding GITHUB_TOKEN/GH_TOKEN into the VM")
146+
cmd.Flags().BoolVar(&noGitSSHAgent, "no-git-ssh-agent", false, "Disable SSH agent forwarding into the VM")
139147

140148
// Add list subcommand.
141149
cmd.AddCommand(listCmd())
@@ -175,6 +183,8 @@ type runFlags struct {
175183
mcpGroup string
176184
mcpPort uint16
177185
mcpConfig string
186+
noGitToken bool
187+
noGitSSHAgent bool
178188
}
179189

180190
func run(parentCtx context.Context, agentName string, flags runFlags) error {
@@ -334,6 +344,13 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
334344
cfg.MCP.ConfigPath = mcpConfigPath
335345
}
336346

347+
// Resolve git config from config + CLI flags.
348+
gitTokenEnabled := cfg.Git.GitTokenEnabled() && !flags.noGitToken
349+
sshAgentEnabled := cfg.Git.SSHAgentEnabled() && !flags.noGitSSHAgent
350+
351+
// Wire git identity provider (unconditional — used for both review and no-review modes).
352+
deps.GitIdentityProvider = infragit.NewHostIdentityProvider("")
353+
337354
// Wire snapshot isolation dependencies only when review is enabled.
338355
if reviewEnabled {
339356
deps.WorkspaceCloner = infraws.NewFSWorkspaceCloner(
@@ -343,6 +360,11 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
343360
deps.Reviewer = reviewer
344361
deps.Flusher = review.NewFSFlusher()
345362
deps.Differ = diff.NewFSDiffer()
363+
364+
// Wire snapshot post-processors (git config sanitizer).
365+
deps.SnapshotPostProcessors = []workspace.SnapshotPostProcessor{
366+
infragit.NewConfigSanitizer(logger),
367+
}
346368
}
347369

348370
// Validate and parse egress flags.
@@ -367,13 +389,15 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
367389
runner := app.NewSandboxRunner(deps)
368390

369391
opts := app.RunOpts{
370-
CPUs: flags.cpus,
371-
Memory: flags.memory,
372-
Workspace: ws,
373-
SSHPort: flags.sshPort,
374-
ImageOverride: flags.image,
375-
EgressProfile: flags.egressProfile,
376-
AllowHosts: parsedAllowHosts,
392+
CPUs: flags.cpus,
393+
Memory: flags.memory,
394+
Workspace: ws,
395+
SSHPort: flags.sshPort,
396+
ImageOverride: flags.image,
397+
EgressProfile: flags.egressProfile,
398+
AllowHosts: parsedAllowHosts,
399+
GitTokenEnabled: gitTokenEnabled,
400+
SSHAgentForward: sshAgentEnabled,
377401
Snapshot: app.SnapshotOpts{
378402
Enabled: reviewEnabled,
379403
SnapshotMatcher: snapshotMatcher,
@@ -395,9 +419,7 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
395419
}
396420
}()
397421

398-
restore, _ := terminal.MakeRaw()
399422
termErr := runner.Attach(ctx, sb, terminal)
400-
restore()
401423

402424
if stopErr := runner.Stop(sb); stopErr != nil {
403425
logger.Error("failed to stop VM", "error", stopErr)

internal/app/sandbox.go

Lines changed: 142 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/stacklok/apiary/internal/domain/agent"
1515
"github.com/stacklok/apiary/internal/domain/config"
1616
"github.com/stacklok/apiary/internal/domain/egress"
17+
domaingit "github.com/stacklok/apiary/internal/domain/git"
1718
"github.com/stacklok/apiary/internal/domain/hostservice"
1819
"github.com/stacklok/apiary/internal/domain/progress"
1920
"github.com/stacklok/apiary/internal/domain/session"
@@ -62,6 +63,12 @@ type RunOpts struct {
6263
// Snapshot holds snapshot isolation options.
6364
Snapshot SnapshotOpts
6465

66+
// GitTokenEnabled controls whether git token env vars are forwarded.
67+
GitTokenEnabled bool
68+
69+
// SSHAgentForward enables SSH agent forwarding to the VM.
70+
SSHAgentForward bool
71+
6572
// Terminal provides I/O streams for the session. Required for Run().
6673
Terminal session.Terminal
6774
}
@@ -84,18 +91,25 @@ type SandboxDeps struct {
8491

8592
// MCPProvider creates host services for MCP proxy (nil = disabled).
8693
MCPProvider hostservice.Provider
94+
95+
// SnapshotPostProcessors run after snapshot creation but before VM start.
96+
SnapshotPostProcessors []workspace.SnapshotPostProcessor
97+
98+
// GitIdentityProvider resolves the host git user identity.
99+
GitIdentityProvider domaingit.IdentityProvider
87100
}
88101

89102
// Sandbox holds the state of a running sandbox session.
90103
// Created by Prepare, consumed by Attach/Stop/Changes/Flush/Cleanup.
91104
type Sandbox struct {
92-
Agent agent.Agent
93-
VM domvm.VM
94-
VMConfig domvm.VMConfig
95-
Snapshot *workspace.Snapshot
96-
WorkspacePath string
97-
DiffMatcher snapshot.Matcher
98-
EnvVars map[string]string
105+
Agent agent.Agent
106+
VM domvm.VM
107+
VMConfig domvm.VMConfig
108+
Snapshot *workspace.Snapshot
109+
WorkspacePath string
110+
DiffMatcher snapshot.Matcher
111+
EnvVars map[string]string
112+
SSHAgentForward bool
99113
}
100114

101115
// Cleanup releases resources (snapshot dir). Safe to call multiple times.
@@ -116,18 +130,20 @@ func (sb *Sandbox) Cleanup() error {
116130
// Changes(), Flush(), and Sandbox.Cleanup() individually. This allows the caller
117131
// to control terminal attachment, async review workflows, and concurrent sessions.
118132
type SandboxRunner struct {
119-
registry agent.Registry
120-
vmRunner domvm.VMRunner
121-
sessionRunner session.TerminalSession
122-
config *config.Config
123-
envProvider agent.EnvProvider
124-
logger *slog.Logger
125-
observer progress.Observer
126-
workspaceCloner workspace.WorkspaceCloner
127-
reviewer snapshot.Reviewer
128-
flusher snapshot.Flusher
129-
differ snapshot.Differ
130-
mcpProvider hostservice.Provider
133+
registry agent.Registry
134+
vmRunner domvm.VMRunner
135+
sessionRunner session.TerminalSession
136+
config *config.Config
137+
envProvider agent.EnvProvider
138+
logger *slog.Logger
139+
observer progress.Observer
140+
workspaceCloner workspace.WorkspaceCloner
141+
reviewer snapshot.Reviewer
142+
flusher snapshot.Flusher
143+
differ snapshot.Differ
144+
mcpProvider hostservice.Provider
145+
snapshotPostProcessors []workspace.SnapshotPostProcessor
146+
gitIdentityProvider domaingit.IdentityProvider
131147
}
132148

133149
// NewSandboxRunner creates a new SandboxRunner with the given dependencies.
@@ -137,18 +153,20 @@ func NewSandboxRunner(deps SandboxDeps) *SandboxRunner {
137153
obs = progress.Nop()
138154
}
139155
return &SandboxRunner{
140-
registry: deps.Registry,
141-
vmRunner: deps.VMRunner,
142-
sessionRunner: deps.SessionRunner,
143-
config: deps.Config,
144-
envProvider: deps.EnvProvider,
145-
logger: deps.Logger,
146-
observer: obs,
147-
workspaceCloner: deps.WorkspaceCloner,
148-
reviewer: deps.Reviewer,
149-
flusher: deps.Flusher,
150-
differ: deps.Differ,
151-
mcpProvider: deps.MCPProvider,
156+
registry: deps.Registry,
157+
vmRunner: deps.VMRunner,
158+
sessionRunner: deps.SessionRunner,
159+
config: deps.Config,
160+
envProvider: deps.EnvProvider,
161+
logger: deps.Logger,
162+
observer: obs,
163+
workspaceCloner: deps.WorkspaceCloner,
164+
reviewer: deps.Reviewer,
165+
flusher: deps.Flusher,
166+
differ: deps.Differ,
167+
mcpProvider: deps.MCPProvider,
168+
snapshotPostProcessors: deps.SnapshotPostProcessors,
169+
gitIdentityProvider: deps.GitIdentityProvider,
152170
}
153171
}
154172

@@ -230,8 +248,12 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
230248
)
231249
}
232250

233-
// 3. Collect env vars.
234-
envVars := agent.ForwardEnv(ag.EnvForward, s.envProvider)
251+
// 3. Collect env vars (merge common git patterns when token forwarding is enabled).
252+
allPatterns := ag.EnvForward
253+
if opts.GitTokenEnabled {
254+
allPatterns = mergeEnvPatterns(allPatterns, domaingit.CommonEnvPatterns())
255+
}
256+
envVars := agent.ForwardEnv(allPatterns, s.envProvider)
235257
if len(envVars) > 0 {
236258
keys := make([]string, 0, len(envVars))
237259
for k := range envVars {
@@ -240,6 +262,44 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
240262
s.logger.Debug("forwarding environment variables", "keys", keys)
241263
}
242264

265+
// Resolve git identity (fallback for env vars not already set).
266+
var gitIdentity domaingit.Identity
267+
if s.gitIdentityProvider != nil {
268+
id, idErr := s.gitIdentityProvider.GetIdentity()
269+
if idErr != nil {
270+
s.logger.Warn("failed to resolve git identity", "error", idErr)
271+
} else {
272+
gitIdentity = id
273+
}
274+
}
275+
276+
// Inject git identity into env vars as fallback when not already present.
277+
if gitIdentity.Name != "" {
278+
if envVars == nil {
279+
envVars = make(map[string]string)
280+
}
281+
if _, ok := envVars["GIT_AUTHOR_NAME"]; !ok {
282+
envVars["GIT_AUTHOR_NAME"] = gitIdentity.Name
283+
}
284+
if _, ok := envVars["GIT_COMMITTER_NAME"]; !ok {
285+
envVars["GIT_COMMITTER_NAME"] = gitIdentity.Name
286+
}
287+
}
288+
if gitIdentity.Email != "" {
289+
if envVars == nil {
290+
envVars = make(map[string]string)
291+
}
292+
if _, ok := envVars["GIT_AUTHOR_EMAIL"]; !ok {
293+
envVars["GIT_AUTHOR_EMAIL"] = gitIdentity.Email
294+
}
295+
if _, ok := envVars["GIT_COMMITTER_EMAIL"]; !ok {
296+
envVars["GIT_COMMITTER_EMAIL"] = gitIdentity.Email
297+
}
298+
}
299+
300+
// Determine if a GitHub token is available for credential helper injection.
301+
hasGitToken := opts.GitTokenEnabled && (envVars["GITHUB_TOKEN"] != "" || envVars["GH_TOKEN"] != "")
302+
243303
// 4. Set up MCP host services if enabled.
244304
var hostServices []domvm.HostService
245305
mcpCfg := s.resolveMCPConfig(cfg, agentName)
@@ -286,6 +346,20 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
286346
"original", snap.OriginalPath,
287347
"snapshot", snap.SnapshotPath,
288348
)
349+
350+
// Run post-processors on the snapshot (e.g., git config sanitizer).
351+
// Failures abort VM start — post-processors are security-relevant
352+
// (credential stripping) and must not be silently skipped.
353+
for _, pp := range s.snapshotPostProcessors {
354+
if ppErr := pp.Process(ctx, snap.OriginalPath, snap.SnapshotPath); ppErr != nil {
355+
s.observer.Fail("Snapshot post-processing failed")
356+
if cleanErr := snap.Cleanup(); cleanErr != nil {
357+
s.logger.Error("failed to clean up snapshot after post-processor failure", "error", cleanErr)
358+
}
359+
return nil, fmt.Errorf("snapshot post-processing: %w", ppErr)
360+
}
361+
}
362+
289363
workspacePath = snap.SnapshotPath
290364
}
291365

@@ -303,6 +377,9 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
303377
EgressPolicy: egressPolicy,
304378
HostServices: hostServices,
305379
MCPConfigFormat: ag.MCPConfigFormat,
380+
GitIdentity: gitIdentity,
381+
HasGitToken: hasGitToken,
382+
SSHAgentForward: opts.SSHAgentForward,
306383
}
307384

308385
sandboxVM, err := s.vmRunner.Start(ctx, vmCfg)
@@ -320,13 +397,14 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
320397
s.observer.Complete("Sandbox ready")
321398

322399
return &Sandbox{
323-
Agent: ag,
324-
VM: sandboxVM,
325-
VMConfig: vmCfg,
326-
Snapshot: snap,
327-
WorkspacePath: workspacePath,
328-
DiffMatcher: diffMatcher,
329-
EnvVars: envVars,
400+
Agent: ag,
401+
VM: sandboxVM,
402+
VMConfig: vmCfg,
403+
Snapshot: snap,
404+
WorkspacePath: workspacePath,
405+
DiffMatcher: diffMatcher,
406+
EnvVars: envVars,
407+
SSHAgentForward: opts.SSHAgentForward,
330408
}, nil
331409
}
332410

@@ -335,12 +413,13 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
335413
// The terminal parameter provides I/O streams and PTY control for this session.
336414
func (s *SandboxRunner) Attach(ctx context.Context, sb *Sandbox, terminal session.Terminal) error {
337415
sessionOpts := session.SessionOpts{
338-
Host: "127.0.0.1",
339-
Port: sb.VM.SSHPort(),
340-
User: "sandbox",
341-
KeyPath: sb.VM.SSHKeyPath(),
342-
Command: sb.Agent.Command,
343-
Terminal: terminal,
416+
Host: "127.0.0.1",
417+
Port: sb.VM.SSHPort(),
418+
User: "sandbox",
419+
KeyPath: sb.VM.SSHKeyPath(),
420+
Command: sb.Agent.Command,
421+
Terminal: terminal,
422+
SSHAgentForward: sb.SSHAgentForward,
344423
}
345424

346425
s.logger.Debug("connecting to sandbox VM",
@@ -449,6 +528,23 @@ func (s *SandboxRunner) Run(ctx context.Context, agentName string, opts RunOpts)
449528
return reviewErr
450529
}
451530

531+
// mergeEnvPatterns combines two pattern lists, deduplicating entries.
532+
func mergeEnvPatterns(base, extra []string) []string {
533+
seen := make(map[string]bool, len(base))
534+
for _, p := range base {
535+
seen[p] = true
536+
}
537+
merged := make([]string, len(base))
538+
copy(merged, base)
539+
for _, p := range extra {
540+
if !seen[p] {
541+
merged = append(merged, p)
542+
seen[p] = true
543+
}
544+
}
545+
return merged
546+
}
547+
452548
// resolveMCPConfig returns the effective MCP configuration by merging
453549
// global config with any agent-specific override.
454550
func (s *SandboxRunner) resolveMCPConfig(cfg *config.Config, agentName string) config.MCPConfig {

0 commit comments

Comments
 (0)