Skip to content

Commit 1773e73

Browse files
machado144claude
andauthored
feat: add mask_stdout to redact secrets from sandboxed process output (#6)
## Summary - Adds a `mask_stdout` config block that intercepts stdout/stderr from sandboxed processes and redacts secrets before they reach the terminal - Defense-in-depth layer on top of existing kernel-level protections (namespaces, ACLs, iptables) — purely additive, zero impact on existing sandbox behaviour - 5 built-in presets covering the most common secret formats, all enabled by default via `aigate init` ## What's included **New: `services/masker.go`** - `MaskingWriter` — line-buffered `io.Writer` that applies regex rules before forwarding to the real writer - Secrets split across write chunks on the same line are still caught (buffered until `\n`) - `Flush()` handles the last line if it has no trailing newline **Built-in presets** | Preset | Matches | Output | |--------|---------|--------| | `openai` | `sk-...` / `sk-proj-...` | `sk-***` | | `anthropic` | `sk-ant-...` | `sk-ant-***` | | `aws_key` | `AKIA...` | `AKIA***` | | `github` | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` | `ghp_***` | | `bearer` | `Bearer <token>` | `Bearer ***` | **Custom pattern options** ```yaml mask_stdout: patterns: - regex: "myapp-secret-[a-z0-9]+" show_prefix: 0 # fully masked - regex: "token-[a-zA-Z0-9]{16}" show_prefix: 6 # token-*** - regex: "(?:password|secret)\\s*[=:]\\s*\\S+" show_prefix: 0 case_insensitive: true # catches PASSWORD=, Password=, etc. ``` **Default config (`aigate init`)** now ships with all 5 presets + one example pattern for generic `key=value` assignments (`api_key=`, `secret:`, `password=`, etc.) ## Safety - When `mask_stdout` is absent from config, `buildOutputWriters` returns `os.Stdout`/`os.Stderr` — identical to previous behaviour - `RunPassthrough` now delegates to `RunPassthroughWith(os.Stdout, os.Stderr, ...)` — same result - All platform interface changes (`RunSandboxed`, `Executor`) are mechanical — all implementations and test mocks updated - No new external dependencies - `go vet` + race detector both pass ## Test plan - [ ] `go test ./... -count=1 -race` passes - [ ] `aigate init` generates config with `mask_stdout` presets + example pattern - [ ] `aigate run -- env` with an OpenAI key in env does not print the raw key - [ ] Existing sandbox restrictions (deny_read, deny_exec, allow_net) unaffected - [ ] `[aigate] mask_stdout: ...` line appears in startup banner when configured --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c29b7a2 commit 1773e73

27 files changed

Lines changed: 1201 additions & 67 deletions

.github/workflows/pr.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: PR
2+
3+
on:
4+
pull_request:
5+
types: [opened, edited, synchronize, reopened]
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: read
11+
pull-requests: write
12+
13+
jobs:
14+
title:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Validate PR title follows Conventional Commits
18+
env:
19+
TITLE: ${{ github.event.pull_request.title }}
20+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21+
run: |
22+
if echo "$TITLE" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?(!)?: .+"; then
23+
echo "PR title is valid: $TITLE"
24+
exit 0
25+
fi
26+
27+
BODY=$(cat <<'COMMENT'
28+
### ⚠️ Invalid PR Title
29+
30+
PR title must follow the **Conventional Commits** format since we use squash merge:
31+
32+
```
33+
<type>[optional scope][!]: <description>
34+
```
35+
36+
**Allowed types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`, `perf`, `revert`
37+
38+
**Examples:**
39+
- `feat: add new feature`
40+
- `fix(sandbox): resolve namespace issue`
41+
- `feat!: breaking change`
42+
- `chore(deps): update dependencies`
43+
COMMENT
44+
)
45+
46+
gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
47+
-X POST -f body="$BODY"
48+
49+
echo "::error::PR title must follow Conventional Commits format"
50+
exit 1
51+
52+
review:
53+
runs-on: ubuntu-latest
54+
steps:
55+
- uses: actions/checkout@v4
56+
- uses: AxeForging/reviewforge@main
57+
with:
58+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
AI_PROVIDER: gemini
60+
AI_MODEL: gemini-2.5-flash
61+
AI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
62+
SHOW_TOKEN_USAGE: true
63+
INCREMENTAL: false
64+
REVIEW_RULES: concise
65+
66+
validate:
67+
runs-on: ubuntu-latest
68+
steps:
69+
- uses: actions/checkout@v4
70+
- uses: AxeForging/structlint@main
71+
with:
72+
config: .structlint.yaml
73+
comment-on-pr: "true"
74+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/release.yml

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ on:
44
workflow_dispatch:
55
inputs:
66
tag:
7-
description: 'Tag to release (e.g. v1.0.0)'
8-
required: true
7+
description: 'Release tag (leave empty for auto-bump from conventional commits)'
8+
required: false
9+
type: string
910

1011
permissions:
1112
contents: write
@@ -14,7 +15,8 @@ jobs:
1415
release:
1516
runs-on: ubuntu-latest
1617
steps:
17-
- uses: actions/checkout@v4
18+
- name: Checkout code
19+
uses: actions/checkout@v4
1820
with:
1921
fetch-depth: 0
2022

@@ -27,16 +29,39 @@ jobs:
2729
- name: Run tests
2830
run: go test ./... -v
2931

32+
- name: Determine version
33+
id: version
34+
uses: AxeForging/releaseforge@main
35+
with:
36+
command: bump
37+
38+
- name: Set tag
39+
id: tag
40+
run: |
41+
if [ -n "${{ inputs.tag }}" ]; then
42+
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
43+
else
44+
echo "tag=${{ steps.version.outputs.next-version }}" >> "$GITHUB_OUTPUT"
45+
fi
46+
47+
- name: Generate release notes
48+
id: notes
49+
uses: AxeForging/releaseforge@main
50+
with:
51+
command: generate
52+
api-key: ${{ secrets.GEMINI_API_KEY }}
53+
github-token: ${{ secrets.GITHUB_TOKEN }}
54+
3055
- name: Create and push tag
3156
run: |
32-
git config user.name "github-actions[bot]"
33-
git config user.email "github-actions[bot]@users.noreply.github.com"
34-
git tag -a ${{ github.event.inputs.tag }} -m "Release ${{ github.event.inputs.tag }}"
35-
git push origin ${{ github.event.inputs.tag }}
57+
echo "Releasing ${{ steps.tag.outputs.tag }}"
58+
git tag ${{ steps.tag.outputs.tag }}
59+
git push origin ${{ steps.tag.outputs.tag }}
3660
37-
- uses: goreleaser/goreleaser-action@v6
61+
- name: Run GoReleaser
62+
uses: goreleaser/goreleaser-action@v6
3863
with:
3964
version: latest
40-
args: "release --clean"
65+
args: release --clean --release-notes ${{ steps.notes.outputs.release-notes }}
4166
env:
42-
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
67+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/test.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
name: Test
22

33
on:
4-
push:
5-
branches: [ "**" ]
64
pull_request:
75

86
jobs:
@@ -22,3 +20,23 @@ jobs:
2220

2321
- name: Run tests
2422
run: go test ./... -v
23+
24+
- name: Build binary
25+
run: make build-local
26+
27+
lint:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
- name: Set up Go
33+
uses: actions/setup-go@v5
34+
with:
35+
go-version: '1.24'
36+
cache: true
37+
38+
- name: Run golangci-lint
39+
uses: golangci/golangci-lint-action@v6
40+
with:
41+
version: latest
42+
args: --timeout=5m

.structlint.yaml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
dir_structure:
2+
allowedPaths:
3+
- "."
4+
- "actions/**"
5+
- "domain/**"
6+
- "services/**"
7+
- "helpers/**"
8+
- "integration/**"
9+
- "docs/**"
10+
- "dist/**"
11+
- ".github/**"
12+
- ".claude/**"
13+
disallowedPaths:
14+
- "vendor/**"
15+
- "node_modules/**"
16+
- "tmp/**"
17+
- "temp/**"
18+
- ".git/**"
19+
- "*.log"
20+
requiredPaths:
21+
- "actions"
22+
- "domain"
23+
- "services"
24+
- "helpers"
25+
- "docs"
26+
27+
file_naming_pattern:
28+
allowed:
29+
- "*.go"
30+
- "*.mod"
31+
- "*.sum"
32+
- "*.yaml"
33+
- "*.yml"
34+
- "*.json"
35+
- "*.toml"
36+
- "*.md"
37+
- "*.txt"
38+
- "*.png"
39+
- "*.jpg"
40+
- "*.svg"
41+
- "*.puml"
42+
- "README*"
43+
- "LICENSE*"
44+
- "CHANGELOG*"
45+
- "Makefile"
46+
- "Dockerfile*"
47+
- "*.sh"
48+
- ".gitignore"
49+
- ".editorconfig"
50+
- ".golangci.yml"
51+
- ".goreleaser.yaml"
52+
- ".goreleaser.yml"
53+
- ".github/**"
54+
disallowed:
55+
- "*.env*"
56+
- ".env*"
57+
- "*.key"
58+
- "*.pem"
59+
- "*.p12"
60+
- "*.log"
61+
- "*.tmp"
62+
- "*.temp"
63+
- "*~"
64+
- "*.swp"
65+
- "*.swo"
66+
- "*.bak"
67+
- "*.backup"
68+
- ".DS_Store"
69+
- "Thumbs.db"
70+
required:
71+
- "go.mod"
72+
- "README.md"
73+
- ".gitignore"
74+
- "main.go"
75+
76+
ignore:
77+
- ".git"
78+
- "vendor"
79+
- "node_modules"
80+
- "bin"
81+
- "dist"
82+
- "build"
83+
- ".idea"
84+
- ".vscode"
85+
- ".DS_Store"
86+
- "*.log"
87+
- "*.tmp"
88+
- "aigate"

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
}

0 commit comments

Comments
 (0)