Skip to content

Commit c156ad5

Browse files
committed
feat: add mask_stdout to redact secrets from sandboxed process output
Intercepts stdout/stderr from sandboxed processes at the Go layer and redacts secrets before they reach the terminal — a defense-in-depth layer on top of existing kernel-level sandbox protections. - MaskingWriter: line-buffered io.Writer with regex-based redaction - 5 built-in presets: openai, anthropic, aws_key, github, bearer - Custom patterns with show_prefix (partial reveal) and case_insensitive options - All presets + a generic key=value pattern enabled by default in aigate init - Banner shows active presets/patterns at sandbox startup - Zero behavioral change when mask_stdout is absent from config
1 parent c29b7a2 commit c156ad5

15 files changed

Lines changed: 714 additions & 18 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ AI coding tools rely on application-level permission systems that can be bypasse
4747
- **Process isolation** - Mount namespaces overmount sensitive directories (Linux)
4848
- **Network isolation** - Network namespaces restrict egress to allowed domains (Linux)
4949
- **Command blocking** - Deny execution of dangerous commands (curl, wget, ssh)
50+
- **Output masking** - Redact secrets (API keys, tokens) from stdout/stderr before they reach the terminal
5051
- **Resource limits** - cgroups v2 enforce memory, CPU, PID limits (Linux)
5152
- **Tool-agnostic** - Works with any AI tool: Claude Code, Cursor, Copilot, Aider
5253
- **Sensible defaults** - Ships with deny rules for .env, secrets/, .ssh/, *.pem, etc.

actions/help_ai.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,68 @@ CONFIGURATION
8585
max_memory: 4G
8686
max_cpu_percent: 80
8787
max_pids: 1000
88+
mask_stdout:
89+
presets:
90+
- openai
91+
- anthropic
92+
- aws_key
93+
- github
94+
- bearer
8895
8996
Example .aigate.yaml (project-level, adds to global):
9097
deny_read:
9198
- .stripe-key
9299
- production.env
93100
allow_net:
94101
- api.stripe.com
102+
mask_stdout:
103+
presets:
104+
- openai
105+
- bearer
106+
patterns:
107+
- regex: "myapp-secret-[a-z0-9]+"
108+
show_prefix: 0
109+
case_insensitive: false
110+
- regex: "(?:db_pass|database_password)\\s*[=:]\\s*\\S+"
111+
show_prefix: 0
112+
case_insensitive: true
113+
114+
OUTPUT MASKING (mask_stdout)
115+
Redacts secrets from stdout/stderr before they reach the terminal. Applied in
116+
addition to kernel-level sandbox protections (defense-in-depth).
117+
118+
Built-in presets:
119+
openai sk-... / sk-proj-... → sk-***
120+
anthropic sk-ant-... → sk-ant-***
121+
aws_key AKIA... (access key ID) → AKIA***
122+
github ghp_, gho_, ghu_, ghs_, ghr_ → ghp_***
123+
bearer Bearer <token> → Bearer ***
124+
125+
All 5 presets are enabled by default (aigate init).
126+
127+
Pattern options:
128+
regex RE2-compatible regular expression (required)
129+
show_prefix bytes to preserve before *** (default: 0, fully masked)
130+
case_insensitive match regardless of letter case (default: false)
131+
132+
Custom pattern examples:
133+
mask_stdout:
134+
patterns:
135+
- regex: "mysecret-[a-z0-9]+"
136+
show_prefix: 0 # → ***
137+
- regex: "token-[a-zA-Z0-9]{16}"
138+
show_prefix: 6 # → token-***
139+
- regex: "(?:password|secret)\\s*[=:]\\s*\\S+"
140+
show_prefix: 0
141+
case_insensitive: true # catches PASSWORD=, Password=, etc.
95142
96143
WHAT THE AI AGENT SEES INSIDE THE SANDBOX
97144
Startup banner on stderr:
98145
[aigate] sandbox active
99146
[aigate] deny_read: .env, secrets/, *.pem
100147
[aigate] deny_exec: curl, wget, ssh
101148
[aigate] allow_net: api.anthropic.com (all other outbound connections will be blocked)
149+
[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer
102150
103151
Denied files contain a marker instead of their content:
104152
[aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions.

actions/run.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,14 @@ func printSandboxBanner(cfg *domain.Config) {
7979
if len(cfg.AllowNet) > 0 {
8080
fmt.Fprintf(os.Stderr, "[aigate] allow_net: %s (all other outbound connections will be blocked)\n", strings.Join(cfg.AllowNet, ", "))
8181
}
82+
if len(cfg.MaskStdout.Presets) > 0 || len(cfg.MaskStdout.Patterns) > 0 {
83+
var parts []string
84+
if len(cfg.MaskStdout.Presets) > 0 {
85+
parts = append(parts, strings.Join(cfg.MaskStdout.Presets, ", "))
86+
}
87+
if len(cfg.MaskStdout.Patterns) > 0 {
88+
parts = append(parts, fmt.Sprintf("+%d custom pattern(s)", len(cfg.MaskStdout.Patterns)))
89+
}
90+
fmt.Fprintf(os.Stderr, "[aigate] mask_stdout: %s\n", strings.Join(parts, "; "))
91+
}
8292
}

docs/user/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,64 @@ resource_limits:
192192
max_pids: 1000
193193
```
194194
195+
### Output Masking (mask_stdout)
196+
197+
`mask_stdout` intercepts stdout and stderr from the sandboxed process and redacts secrets before they reach your terminal. This is an **application-layer** protection on top of the kernel-level sandbox — it prevents secrets from appearing in logs, CI output, or terminal recordings.
198+
199+
**Built-in presets:**
200+
201+
| Preset | Matches | Example output |
202+
|--------|---------|----------------|
203+
| `openai` | `sk-...` / `sk-proj-...` | `sk-***` |
204+
| `anthropic` | `sk-ant-...` | `sk-ant-***` |
205+
| `aws_key` | `AKIA...` (access key ID) | `AKIA***` |
206+
| `github` | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` | `ghp_***` |
207+
| `bearer` | `Bearer <token>` in headers/logs | `Bearer ***` |
208+
209+
Enable presets in your config:
210+
211+
```yaml
212+
# ~/.aigate/config.yaml or .aigate.yaml
213+
mask_stdout:
214+
presets:
215+
- openai
216+
- anthropic
217+
- aws_key
218+
- github
219+
- bearer
220+
```
221+
222+
**Custom patterns** with options:
223+
224+
```yaml
225+
mask_stdout:
226+
presets:
227+
- openai
228+
patterns:
229+
# Fully mask a custom secret format
230+
- regex: "myapp-secret-[a-z0-9]+"
231+
show_prefix: 0
232+
# Show first 8 chars, mask the rest (e.g. "token-AB***")
233+
- regex: "token-[a-zA-Z0-9]{16}"
234+
show_prefix: 8
235+
# Case-insensitive match (catches PASSWORD=, password=, Password=)
236+
- regex: "(?:password|secret|token)\\s*[=:]\\s*\\S+"
237+
show_prefix: 0
238+
case_insensitive: true
239+
```
240+
241+
**Pattern options:**
242+
243+
| Option | Type | Default | Description |
244+
|--------|------|---------|-------------|
245+
| `regex` | string | — | RE2-compatible regular expression |
246+
| `show_prefix` | int | `0` | Bytes to preserve before `***` (0 = fully masked) |
247+
| `case_insensitive` | bool | `false` | Match regardless of letter case |
248+
249+
`show_prefix: N` preserves the first N bytes so you can identify which secret was present without exposing the value.
250+
251+
> **Note:** Masking is line-buffered. Secrets spanning chunk boundaries on the same line are still caught. Binary output streams should not use `mask_stdout`.
252+
195253
### Project Config (.aigate.yaml)
196254

197255
Place in your project root to extend global rules:
@@ -204,6 +262,14 @@ allow_net:
204262
- "registry.terraform.io"
205263
resource_limits:
206264
max_memory: "8G"
265+
mask_stdout:
266+
presets:
267+
- openai
268+
- github
269+
patterns:
270+
- regex: "stripe_key\\s*[=:]\\s*\\S+"
271+
show_prefix: 0
272+
case_insensitive: true
207273
```
208274

209275
Project config merges with global (extends, does not replace).

domain/types.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ type ResourceLimits struct {
2222
MaxPIDs int `yaml:"max_pids"`
2323
}
2424

25+
// MaskPattern defines a single custom output masking rule.
26+
type MaskPattern struct {
27+
Regex string `yaml:"regex"`
28+
ShowPrefix int `yaml:"show_prefix"`
29+
CaseInsensitive bool `yaml:"case_insensitive"`
30+
}
31+
32+
// MaskStdout configures output redaction applied to sandboxed process stdout/stderr.
33+
// Presets are named built-in patterns for common secret formats.
34+
// Patterns are additional user-defined regexes.
35+
type MaskStdout struct {
36+
Presets []string `yaml:"presets"`
37+
Patterns []MaskPattern `yaml:"patterns"`
38+
}
39+
2540
// Config represents the aigate configuration file.
2641
type Config struct {
2742
Group string `yaml:"group"`
@@ -30,6 +45,7 @@ type Config struct {
3045
DenyExec []string `yaml:"deny_exec"`
3146
AllowNet []string `yaml:"allow_net"`
3247
ResourceLimits ResourceLimits `yaml:"resource_limits"`
48+
MaskStdout MaskStdout `yaml:"mask_stdout"`
3349
}
3450

3551
// SandboxProfile is the fully resolved configuration used to launch a sandboxed process.

services/config_service.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ func (s *ConfigService) Merge(global, project *domain.Config) *domain.Config {
111111
if project.ResourceLimits.MaxPIDs > 0 {
112112
merged.ResourceLimits.MaxPIDs = project.ResourceLimits.MaxPIDs
113113
}
114+
if len(project.MaskStdout.Presets) > 0 {
115+
merged.MaskStdout.Presets = appendUnique(merged.MaskStdout.Presets, project.MaskStdout.Presets)
116+
}
117+
if len(project.MaskStdout.Patterns) > 0 {
118+
merged.MaskStdout.Patterns = append(merged.MaskStdout.Patterns, project.MaskStdout.Patterns...)
119+
}
114120
return &merged
115121
}
116122

@@ -160,6 +166,24 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config {
160166
MaxCPUPercent: 80,
161167
MaxPIDs: 1000,
162168
},
169+
MaskStdout: domain.MaskStdout{
170+
Presets: []string{
171+
"openai",
172+
"anthropic",
173+
"aws_key",
174+
"github",
175+
"bearer",
176+
},
177+
Patterns: []domain.MaskPattern{
178+
{
179+
// Catch generic key=value / key: value assignments for common secret names
180+
// e.g. "API_KEY=abc123", "secret: mysecret", "password=hunter2"
181+
Regex: `(?:api_?key|secret|password|passwd|token|credential)\s*[=:]\s*\S+`,
182+
ShowPrefix: 0,
183+
CaseInsensitive: true,
184+
},
185+
},
186+
},
163187
}
164188
}
165189

services/masker.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package services
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"regexp"
8+
9+
"github.com/AxeForging/aigate/domain"
10+
)
11+
12+
// builtinPresets maps preset names to their regex pattern and how many leading
13+
// bytes to preserve before the *** replacement (0 = fully masked).
14+
var builtinPresets = map[string]struct {
15+
pattern string
16+
showPrefix int
17+
}{
18+
// OpenAI: sk-... or sk-proj-...
19+
"openai": {`sk-[a-zA-Z0-9\-_]{20,}`, 3},
20+
// Anthropic: sk-ant-api03-...
21+
"anthropic": {`sk-ant-[a-zA-Z0-9\-_]{20,}`, 7},
22+
// AWS access key ID
23+
"aws_key": {`AKIA[0-9A-Z]{16}`, 4},
24+
// GitHub PATs: ghp_, gho_, ghu_, ghs_, ghr_
25+
"github": {`gh[pousr]_[A-Za-z0-9_]{36,}`, 4},
26+
// Generic Bearer token in headers/logs
27+
"bearer": {`(?i)Bearer [A-Za-z0-9._\-]{20,}`, 7},
28+
}
29+
30+
// BuiltinPresetNames returns the sorted list of available preset names.
31+
// Used for validation and documentation.
32+
func BuiltinPresetNames() []string {
33+
names := make([]string, 0, len(builtinPresets))
34+
for k := range builtinPresets {
35+
names = append(names, k)
36+
}
37+
return names
38+
}
39+
40+
type maskRule struct {
41+
re *regexp.Regexp
42+
showPrefix int
43+
}
44+
45+
// MaskingWriter wraps an io.Writer and redacts secrets from each line before
46+
// forwarding to the underlying writer. It buffers across Write calls so that
47+
// secrets spanning chunk boundaries on the same line are still caught.
48+
type MaskingWriter struct {
49+
out io.Writer
50+
rules []maskRule
51+
buf []byte
52+
}
53+
54+
// NewMaskingWriter builds a MaskingWriter from the given MaskStdout config.
55+
// Returns (nil, nil) when no presets or patterns are configured — callers
56+
// should fall back to the raw writer in that case.
57+
func NewMaskingWriter(out io.Writer, cfg domain.MaskStdout) (*MaskingWriter, error) {
58+
var rules []maskRule
59+
60+
for _, name := range cfg.Presets {
61+
p, ok := builtinPresets[name]
62+
if !ok {
63+
return nil, fmt.Errorf("unknown mask_stdout preset %q (available: openai, anthropic, aws_key, github, bearer)", name)
64+
}
65+
re, err := regexp.Compile(p.pattern)
66+
if err != nil {
67+
return nil, fmt.Errorf("internal error compiling preset %q: %w", name, err)
68+
}
69+
rules = append(rules, maskRule{re: re, showPrefix: p.showPrefix})
70+
}
71+
72+
for _, mp := range cfg.Patterns {
73+
pattern := mp.Regex
74+
if mp.CaseInsensitive {
75+
pattern = "(?i)" + pattern
76+
}
77+
re, err := regexp.Compile(pattern)
78+
if err != nil {
79+
return nil, fmt.Errorf("invalid mask_stdout pattern %q: %w", mp.Regex, err)
80+
}
81+
rules = append(rules, maskRule{re: re, showPrefix: mp.ShowPrefix})
82+
}
83+
84+
if len(rules) == 0 {
85+
return nil, nil
86+
}
87+
return &MaskingWriter{out: out, rules: rules}, nil
88+
}
89+
90+
// Write buffers p, flushes complete lines through the redactor, and returns
91+
// len(p) so callers treat the write as fully consumed.
92+
func (m *MaskingWriter) Write(p []byte) (int, error) {
93+
m.buf = append(m.buf, p...)
94+
for {
95+
idx := bytes.IndexByte(m.buf, '\n')
96+
if idx < 0 {
97+
break
98+
}
99+
line := m.buf[:idx+1]
100+
if _, err := m.out.Write(m.redact(line)); err != nil {
101+
return 0, err
102+
}
103+
m.buf = m.buf[idx+1:]
104+
}
105+
return len(p), nil
106+
}
107+
108+
// Flush writes any remaining buffered bytes (last line without a trailing newline).
109+
// Call this after the child process exits.
110+
func (m *MaskingWriter) Flush() error {
111+
if len(m.buf) == 0 {
112+
return nil
113+
}
114+
_, err := m.out.Write(m.redact(m.buf))
115+
m.buf = m.buf[:0]
116+
return err
117+
}
118+
119+
// redact applies all masking rules to a single line.
120+
func (m *MaskingWriter) redact(line []byte) []byte {
121+
result := line
122+
for _, rule := range m.rules {
123+
result = rule.re.ReplaceAllFunc(result, func(match []byte) []byte {
124+
if rule.showPrefix > 0 && len(match) > rule.showPrefix {
125+
out := make([]byte, rule.showPrefix+3)
126+
copy(out, match[:rule.showPrefix])
127+
copy(out[rule.showPrefix:], "***")
128+
return out
129+
}
130+
return []byte("***")
131+
})
132+
}
133+
return result
134+
}

0 commit comments

Comments
 (0)