Skip to content

Commit ce4632a

Browse files
authored
Merge pull request #77 from peg/staging
fix(upgrade): remove single-file archive constraint (v0.4.5)
2 parents 44d2c16 + ba0cfff commit ce4632a

File tree

6 files changed

+245
-10
lines changed

6 files changed

+245
-10
lines changed

cmd/rampart/cli/setup.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Supported AI Agents:
4646
cmd.AddCommand(newSetupClaudeCodeCmd(opts))
4747
cmd.AddCommand(newSetupClineCmd(opts))
4848
cmd.AddCommand(newSetupOpenClawCmd(opts))
49+
cmd.AddCommand(newSetupCodexCmd(opts))
4950

5051
return cmd
5152
}

cmd/rampart/cli/setup_codex.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright 2026 The Rampart Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
8+
package cli
9+
10+
import (
11+
"fmt"
12+
"io"
13+
"os"
14+
"os/exec"
15+
"path/filepath"
16+
"runtime"
17+
"strings"
18+
19+
"github.com/spf13/cobra"
20+
)
21+
22+
func newSetupCodexCmd(_ *rootOptions) *cobra.Command {
23+
var remove bool
24+
var force bool
25+
26+
cmd := &cobra.Command{
27+
Use: "codex",
28+
Short: "Install Rampart wrapper for Codex CLI",
29+
Long: `Creates a wrapper script that intercepts all Codex tool calls via
30+
rampart preload (LD_PRELOAD syscall interception). The wrapper is installed
31+
at ~/.local/bin/codex and the real codex binary is called through it.
32+
33+
Run 'rampart setup codex --remove' to uninstall.`,
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
if runtime.GOOS == "windows" {
36+
return fmt.Errorf("setup codex: LD_PRELOAD not supported on Windows — use 'rampart wrap -- codex' instead")
37+
}
38+
39+
out := cmd.OutOrStdout()
40+
41+
// Find the real codex binary.
42+
realCodex, err := exec.LookPath("codex")
43+
if err != nil {
44+
return fmt.Errorf("setup codex: codex not found in PATH — install it first")
45+
}
46+
47+
// Resolve symlinks so the wrapper doesn't point to itself.
48+
realCodex, err = filepath.EvalSymlinks(realCodex)
49+
if err != nil {
50+
return fmt.Errorf("setup codex: resolve codex path: %w", err)
51+
}
52+
53+
home, err := os.UserHomeDir()
54+
if err != nil {
55+
return fmt.Errorf("setup codex: resolve home: %w", err)
56+
}
57+
58+
wrapperDir := filepath.Join(home, ".local", "bin")
59+
wrapperPath := filepath.Join(wrapperDir, "codex")
60+
61+
if remove {
62+
return removeCodexWrapper(out, wrapperPath)
63+
}
64+
65+
// Safety: don't overwrite if it's already pointing somewhere else.
66+
if _, err := os.Stat(wrapperPath); err == nil && !force {
67+
data, readErr := os.ReadFile(wrapperPath)
68+
if readErr == nil && !containsRampartPreload(string(data)) {
69+
return fmt.Errorf("setup codex: %s already exists and is not a Rampart wrapper\n use --force to overwrite or --remove to uninstall", wrapperPath)
70+
}
71+
}
72+
73+
if err := os.MkdirAll(wrapperDir, 0o755); err != nil {
74+
return fmt.Errorf("setup codex: create %s: %w", wrapperDir, err)
75+
}
76+
77+
// Find rampart binary path for the wrapper.
78+
rampartPath, err := exec.LookPath("rampart")
79+
if err != nil {
80+
rampartPath = "rampart" // fallback to PATH lookup at runtime
81+
}
82+
83+
wrapper := fmt.Sprintf(`#!/bin/sh
84+
# Rampart wrapper for Codex — managed by 'rampart setup codex'
85+
# Intercepts all tool calls via LD_PRELOAD syscall enforcement.
86+
# Real codex: %s
87+
# Remove: rampart setup codex --remove
88+
exec %s preload -- %s "$@"
89+
`, realCodex, rampartPath, realCodex)
90+
91+
// Atomic write.
92+
tmp, err := os.CreateTemp(wrapperDir, ".rampart-codex-wrapper-*.sh")
93+
if err != nil {
94+
return fmt.Errorf("setup codex: create temp file: %w", err)
95+
}
96+
tmpPath := tmp.Name()
97+
if _, err := tmp.WriteString(wrapper); err != nil {
98+
tmp.Close()
99+
os.Remove(tmpPath)
100+
return fmt.Errorf("setup codex: write wrapper: %w", err)
101+
}
102+
if err := tmp.Chmod(0o755); err != nil {
103+
tmp.Close()
104+
os.Remove(tmpPath)
105+
return fmt.Errorf("setup codex: chmod wrapper: %w", err)
106+
}
107+
tmp.Close()
108+
if err := os.Rename(tmpPath, wrapperPath); err != nil {
109+
os.Remove(tmpPath)
110+
return fmt.Errorf("setup codex: install wrapper: %w", err)
111+
}
112+
113+
fmt.Fprintf(out, "✓ Wrapper installed at %s\n", wrapperPath)
114+
fmt.Fprintf(out, " Wraps: %s\n", realCodex)
115+
fmt.Fprintf(out, " Via: %s preload\n\n", rampartPath)
116+
117+
// Check if wrapperDir is on PATH and warn if not.
118+
if !isOnPath(wrapperDir) {
119+
fmt.Fprintf(out, "⚠ %s is not on your PATH.\n", wrapperDir)
120+
fmt.Fprintln(out, " Add this to your shell config (~/.bashrc, ~/.zshrc):")
121+
fmt.Fprintf(out, " export PATH=\"%s:$PATH\"\n\n", wrapperDir)
122+
}
123+
124+
fmt.Fprintln(out, "✓ Run 'codex' normally — all tool calls are now enforced by Rampart.")
125+
fmt.Fprintln(out, " Uninstall: rampart setup codex --remove")
126+
return nil
127+
},
128+
}
129+
130+
cmd.Flags().BoolVar(&remove, "remove", false, "Remove the Codex wrapper")
131+
cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing wrapper")
132+
return cmd
133+
}
134+
135+
func removeCodexWrapper(out io.Writer, wrapperPath string) error {
136+
data, err := os.ReadFile(wrapperPath)
137+
if err != nil {
138+
if os.IsNotExist(err) {
139+
fmt.Fprintf(out, "Nothing to remove — %s does not exist.\n", wrapperPath)
140+
return nil
141+
}
142+
return fmt.Errorf("setup codex: read wrapper: %w", err)
143+
}
144+
if !containsRampartPreload(string(data)) {
145+
return fmt.Errorf("setup codex: %s does not appear to be a Rampart wrapper — refusing to remove", wrapperPath)
146+
}
147+
// Extract real binary path from the wrapper comment before deleting.
148+
realBin := extractRealBinFromWrapper(string(data))
149+
if err := os.Remove(wrapperPath); err != nil {
150+
return fmt.Errorf("setup codex: remove wrapper: %w", err)
151+
}
152+
fmt.Fprintf(out, "✓ Wrapper removed from %s\n", wrapperPath)
153+
if realBin != "" {
154+
fmt.Fprintf(out, " codex now points to: %s\n", realBin)
155+
}
156+
return nil
157+
}
158+
159+
// extractRealBinFromWrapper parses "# Real codex: /path" from the wrapper script.
160+
func extractRealBinFromWrapper(content string) string {
161+
const prefix = "# Real codex: "
162+
for _, line := range strings.Split(content, "\n") {
163+
if strings.HasPrefix(line, prefix) {
164+
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
165+
}
166+
}
167+
return ""
168+
}
169+
170+
func containsRampartPreload(content string) bool {
171+
return strings.Contains(content, "rampart preload") ||
172+
strings.Contains(content, "Rampart wrapper")
173+
}
174+
175+
func isOnPath(dir string) bool {
176+
pathEnv := os.Getenv("PATH")
177+
for _, p := range filepath.SplitList(pathEnv) {
178+
abs, err := filepath.Abs(p)
179+
if err != nil {
180+
continue
181+
}
182+
if abs == dir {
183+
return true
184+
}
185+
}
186+
return false
187+
}

cmd/rampart/cli/setup_interactive.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ func detectAgents() []agentInfo {
6262
ManualCmd: "rampart mcp -- cursor",
6363
},
6464
{
65-
Name: "Codex",
66-
HasSetup: false,
67-
ManualCmd: "rampart preload -- codex",
65+
Name: "Codex",
66+
HasSetup: true,
67+
SetupCmd: "codex",
6868
},
6969
}
7070

cmd/rampart/cli/upgrade.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -548,11 +548,7 @@ func extractRampartBinary(archive []byte) ([]byte, error) {
548548
}
549549

550550
if len(payload) == 0 {
551-
return nil, fmt.Errorf("upgrade: rampart binary not found in archive")
552-
}
553-
if count > 1 {
554-
// Keep strict behavior expected by release layout.
555-
return nil, fmt.Errorf("upgrade: archive contains %d files; expected a single binary", count)
551+
return nil, fmt.Errorf("upgrade: rampart binary not found in archive (archive had %d regular files)", count)
556552
}
557553
return payload, nil
558554
}

cmd/rampart/cli/upgrade_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,57 @@ func TestExtractRampartBinary(t *testing.T) {
3535
}
3636
}
3737

38+
// TestExtractRampartBinary_GoreleaserLayout tests the real goreleaser archive
39+
// format which bundles LICENSE and README.md alongside the binary.
40+
// Regression test for: "archive contains 4 files; expected a single binary"
41+
func TestExtractRampartBinary_GoreleaserLayout(t *testing.T) {
42+
// Mimic actual goreleaser archive layout (flat, no subdirectory prefix):
43+
// rampart ← the binary
44+
// LICENSE
45+
// README.md
46+
// CHANGELOG.md
47+
archive := makeMultiFileArchive(t, map[string][]byte{
48+
"rampart": []byte("real-binary"),
49+
"LICENSE": []byte("Apache 2.0"),
50+
"README.md": []byte("# Rampart"),
51+
"CHANGELOG.md": []byte("## v0.4.4"),
52+
})
53+
got, err := extractRampartBinary(archive)
54+
if err != nil {
55+
t.Fatalf("extractRampartBinary failed on goreleaser layout: %v", err)
56+
}
57+
if string(got) != "real-binary" {
58+
t.Fatalf("unexpected payload: %q", string(got))
59+
}
60+
}
61+
62+
func makeMultiFileArchive(t *testing.T, files map[string][]byte) []byte {
63+
t.Helper()
64+
var buf bytes.Buffer
65+
gz := gzip.NewWriter(&buf)
66+
tw := tar.NewWriter(gz)
67+
for name, payload := range files {
68+
if err := tw.WriteHeader(&tar.Header{
69+
Name: name,
70+
Mode: 0o755,
71+
Size: int64(len(payload)),
72+
Typeflag: tar.TypeReg,
73+
}); err != nil {
74+
t.Fatalf("write header %s: %v", name, err)
75+
}
76+
if _, err := tw.Write(payload); err != nil {
77+
t.Fatalf("write payload %s: %v", name, err)
78+
}
79+
}
80+
if err := tw.Close(); err != nil {
81+
t.Fatalf("close tar: %v", err)
82+
}
83+
if err := gz.Close(); err != nil {
84+
t.Fatalf("close gzip: %v", err)
85+
}
86+
return buf.Bytes()
87+
}
88+
3889
func TestNewUpgradeCmdAlreadyLatest(t *testing.T) {
3990
deps := &upgradeDeps{
4091
currentVersion: func(context.Context, commandRunner, func() (string, error)) (string, error) {

policies/standard.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ policies:
118118
match:
119119
tool: ["exec"]
120120
rules:
121-
- action: log
121+
- action: watch
122122
when:
123123
command_matches:
124124
- "env"
@@ -347,7 +347,7 @@ policies:
347347
match:
348348
tool: ["mcp-dangerous"]
349349
rules:
350-
- action: log
350+
- action: watch
351351
message: "Dangerous MCP operation logged (stop/restart/execute/modify)"
352352

353353
- name: block-credential-leaks

0 commit comments

Comments
 (0)