diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..de226c6 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,74 @@ +name: PR + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: + - main + +permissions: + contents: read + pull-requests: write + +jobs: + title: + runs-on: ubuntu-latest + steps: + - name: Validate PR title follows Conventional Commits + env: + TITLE: ${{ github.event.pull_request.title }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if echo "$TITLE" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?(!)?: .+"; then + echo "PR title is valid: $TITLE" + exit 0 + fi + + BODY=$(cat <<'COMMENT' + ### ⚠️ Invalid PR Title + + PR title must follow the **Conventional Commits** format since we use squash merge: + + ``` + [optional scope][!]: + ``` + + **Allowed types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`, `perf`, `revert` + + **Examples:** + - `feat: add new feature` + - `fix(sandbox): resolve namespace issue` + - `feat!: breaking change` + - `chore(deps): update dependencies` + COMMENT + ) + + gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + -X POST -f body="$BODY" + + echo "::error::PR title must follow Conventional Commits format" + exit 1 + + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: AxeForging/reviewforge@main + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_PROVIDER: gemini + AI_MODEL: gemini-2.5-flash + AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + SHOW_TOKEN_USAGE: true + INCREMENTAL: false + REVIEW_RULES: concise + + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: AxeForging/structlint@main + with: + config: .structlint.yaml + comment-on-pr: "true" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c51391c..541128a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to release (e.g. v1.0.0)' - required: true + description: 'Release tag (leave empty for auto-bump from conventional commits)' + required: false + type: string permissions: contents: write @@ -14,7 +15,8 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -27,16 +29,39 @@ jobs: - name: Run tests run: go test ./... -v + - name: Determine version + id: version + uses: AxeForging/releaseforge@main + with: + command: bump + + - name: Set tag + id: tag + run: | + if [ -n "${{ inputs.tag }}" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ steps.version.outputs.next-version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Generate release notes + id: notes + uses: AxeForging/releaseforge@main + with: + command: generate + api-key: ${{ secrets.GEMINI_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Create and push tag run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a ${{ github.event.inputs.tag }} -m "Release ${{ github.event.inputs.tag }}" - git push origin ${{ github.event.inputs.tag }} + echo "Releasing ${{ steps.tag.outputs.tag }}" + git tag ${{ steps.tag.outputs.tag }} + git push origin ${{ steps.tag.outputs.tag }} - - uses: goreleaser/goreleaser-action@v6 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 with: version: latest - args: "release --clean" + args: release --clean --release-notes ${{ steps.notes.outputs.release-notes }} env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2330db..b6750d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,6 @@ name: Test on: - push: - branches: [ "**" ] pull_request: jobs: @@ -22,3 +20,23 @@ jobs: - name: Run tests run: go test ./... -v + + - name: Build binary + run: make build-local + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m diff --git a/.structlint.yaml b/.structlint.yaml new file mode 100644 index 0000000..ffb9690 --- /dev/null +++ b/.structlint.yaml @@ -0,0 +1,88 @@ +dir_structure: + allowedPaths: + - "." + - "actions/**" + - "domain/**" + - "services/**" + - "helpers/**" + - "integration/**" + - "docs/**" + - "dist/**" + - ".github/**" + - ".claude/**" + disallowedPaths: + - "vendor/**" + - "node_modules/**" + - "tmp/**" + - "temp/**" + - ".git/**" + - "*.log" + requiredPaths: + - "actions" + - "domain" + - "services" + - "helpers" + - "docs" + +file_naming_pattern: + allowed: + - "*.go" + - "*.mod" + - "*.sum" + - "*.yaml" + - "*.yml" + - "*.json" + - "*.toml" + - "*.md" + - "*.txt" + - "*.png" + - "*.jpg" + - "*.svg" + - "*.puml" + - "README*" + - "LICENSE*" + - "CHANGELOG*" + - "Makefile" + - "Dockerfile*" + - "*.sh" + - ".gitignore" + - ".editorconfig" + - ".golangci.yml" + - ".goreleaser.yaml" + - ".goreleaser.yml" + - ".github/**" + disallowed: + - "*.env*" + - ".env*" + - "*.key" + - "*.pem" + - "*.p12" + - "*.log" + - "*.tmp" + - "*.temp" + - "*~" + - "*.swp" + - "*.swo" + - "*.bak" + - "*.backup" + - ".DS_Store" + - "Thumbs.db" + required: + - "go.mod" + - "README.md" + - ".gitignore" + - "main.go" + +ignore: + - ".git" + - "vendor" + - "node_modules" + - "bin" + - "dist" + - "build" + - ".idea" + - ".vscode" + - ".DS_Store" + - "*.log" + - "*.tmp" + - "aigate" diff --git a/README.md b/README.md index 4aaf3a0..8e354f4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ AI coding tools rely on application-level permission systems that can be bypasse - **Process isolation** - Mount namespaces overmount sensitive directories (Linux) - **Network isolation** - Network namespaces restrict egress to allowed domains (Linux) - **Command blocking** - Deny execution of dangerous commands (curl, wget, ssh) +- **Output masking** - Redact secrets (API keys, tokens) from stdout/stderr before they reach the terminal - **Resource limits** - cgroups v2 enforce memory, CPU, PID limits (Linux) - **Tool-agnostic** - Works with any AI tool: Claude Code, Cursor, Copilot, Aider - **Sensible defaults** - Ships with deny rules for .env, secrets/, .ssh/, *.pem, etc. diff --git a/actions/help_ai.go b/actions/help_ai.go index 1bb13c7..e9b5a4b 100644 --- a/actions/help_ai.go +++ b/actions/help_ai.go @@ -85,6 +85,13 @@ CONFIGURATION max_memory: 4G max_cpu_percent: 80 max_pids: 1000 + mask_stdout: + presets: + - openai + - anthropic + - aws_key + - github + - bearer Example .aigate.yaml (project-level, adds to global): deny_read: @@ -92,6 +99,46 @@ CONFIGURATION - production.env allow_net: - api.stripe.com + mask_stdout: + presets: + - openai + - bearer + patterns: + - regex: "myapp-secret-[a-z0-9]+" + show_prefix: 0 + case_insensitive: false + - regex: "(?:db_pass|database_password)\\s*[=:]\\s*\\S+" + show_prefix: 0 + case_insensitive: true + +OUTPUT MASKING (mask_stdout) + Redacts secrets from stdout/stderr before they reach the terminal. Applied in + addition to kernel-level sandbox protections (defense-in-depth). + + Built-in presets: + openai sk-... / sk-proj-... → sk-*** + anthropic sk-ant-... → sk-ant-*** + aws_key AKIA... (access key ID) → AKIA*** + github ghp_, gho_, ghu_, ghs_, ghr_ → ghp_*** + bearer Bearer → Bearer *** + + All 5 presets are enabled by default (aigate init). + + Pattern options: + regex RE2-compatible regular expression (required) + show_prefix bytes to preserve before *** (default: 0, fully masked) + case_insensitive match regardless of letter case (default: false) + + Custom pattern examples: + mask_stdout: + patterns: + - regex: "mysecret-[a-z0-9]+" + show_prefix: 0 # → *** + - 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. WHAT THE AI AGENT SEES INSIDE THE SANDBOX Startup banner on stderr: @@ -99,6 +146,7 @@ WHAT THE AI AGENT SEES INSIDE THE SANDBOX [aigate] deny_read: .env, secrets/, *.pem [aigate] deny_exec: curl, wget, ssh [aigate] allow_net: api.anthropic.com (all other outbound connections will be blocked) + [aigate] mask_stdout: openai, anthropic, aws_key, github, bearer Denied files contain a marker instead of their content: [aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions. diff --git a/actions/run.go b/actions/run.go index b2bd042..91db584 100644 --- a/actions/run.go +++ b/actions/run.go @@ -79,4 +79,14 @@ func printSandboxBanner(cfg *domain.Config) { if len(cfg.AllowNet) > 0 { fmt.Fprintf(os.Stderr, "[aigate] allow_net: %s (all other outbound connections will be blocked)\n", strings.Join(cfg.AllowNet, ", ")) } + if len(cfg.MaskStdout.Presets) > 0 || len(cfg.MaskStdout.Patterns) > 0 { + var parts []string + if len(cfg.MaskStdout.Presets) > 0 { + parts = append(parts, strings.Join(cfg.MaskStdout.Presets, ", ")) + } + if len(cfg.MaskStdout.Patterns) > 0 { + parts = append(parts, fmt.Sprintf("+%d custom pattern(s)", len(cfg.MaskStdout.Patterns))) + } + fmt.Fprintf(os.Stderr, "[aigate] mask_stdout: %s\n", strings.Join(parts, "; ")) + } } diff --git a/docs/user/README.md b/docs/user/README.md index 79c836a..75968b2 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -192,6 +192,64 @@ resource_limits: max_pids: 1000 ``` +### Output Masking (mask_stdout) + +`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. + +**Built-in presets:** + +| Preset | Matches | Example output | +|--------|---------|----------------| +| `openai` | `sk-...` / `sk-proj-...` | `sk-***` | +| `anthropic` | `sk-ant-...` | `sk-ant-***` | +| `aws_key` | `AKIA...` (access key ID) | `AKIA***` | +| `github` | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` | `ghp_***` | +| `bearer` | `Bearer ` in headers/logs | `Bearer ***` | + +Enable presets in your config: + +```yaml +# ~/.aigate/config.yaml or .aigate.yaml +mask_stdout: + presets: + - openai + - anthropic + - aws_key + - github + - bearer +``` + +**Custom patterns** with options: + +```yaml +mask_stdout: + presets: + - openai + patterns: + # Fully mask a custom secret format + - regex: "myapp-secret-[a-z0-9]+" + show_prefix: 0 + # Show first 8 chars, mask the rest (e.g. "token-AB***") + - regex: "token-[a-zA-Z0-9]{16}" + show_prefix: 8 + # Case-insensitive match (catches PASSWORD=, password=, Password=) + - regex: "(?:password|secret|token)\\s*[=:]\\s*\\S+" + show_prefix: 0 + case_insensitive: true +``` + +**Pattern options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `regex` | string | — | RE2-compatible regular expression | +| `show_prefix` | int | `0` | Bytes to preserve before `***` (0 = fully masked) | +| `case_insensitive` | bool | `false` | Match regardless of letter case | + +`show_prefix: N` preserves the first N bytes so you can identify which secret was present without exposing the value. + +> **Note:** Masking is line-buffered. Secrets spanning chunk boundaries on the same line are still caught. Binary output streams should not use `mask_stdout`. + ### Project Config (.aigate.yaml) Place in your project root to extend global rules: @@ -204,6 +262,14 @@ allow_net: - "registry.terraform.io" resource_limits: max_memory: "8G" +mask_stdout: + presets: + - openai + - github + patterns: + - regex: "stripe_key\\s*[=:]\\s*\\S+" + show_prefix: 0 + case_insensitive: true ``` Project config merges with global (extends, does not replace). diff --git a/domain/types.go b/domain/types.go index defc5d4..833b0a2 100644 --- a/domain/types.go +++ b/domain/types.go @@ -22,6 +22,21 @@ type ResourceLimits struct { MaxPIDs int `yaml:"max_pids"` } +// MaskPattern defines a single custom output masking rule. +type MaskPattern struct { + Regex string `yaml:"regex"` + ShowPrefix int `yaml:"show_prefix"` + CaseInsensitive bool `yaml:"case_insensitive"` +} + +// MaskStdout configures output redaction applied to sandboxed process stdout/stderr. +// Presets are named built-in patterns for common secret formats. +// Patterns are additional user-defined regexes. +type MaskStdout struct { + Presets []string `yaml:"presets"` + Patterns []MaskPattern `yaml:"patterns"` +} + // Config represents the aigate configuration file. type Config struct { Group string `yaml:"group"` @@ -30,6 +45,7 @@ type Config struct { DenyExec []string `yaml:"deny_exec"` AllowNet []string `yaml:"allow_net"` ResourceLimits ResourceLimits `yaml:"resource_limits"` + MaskStdout MaskStdout `yaml:"mask_stdout"` } // SandboxProfile is the fully resolved configuration used to launch a sandboxed process. diff --git a/integration/cli_test.go b/integration/cli_test.go index 0358ccf..69643ce 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -94,9 +94,9 @@ func TestCLI_DenyReadAndAllow(t *testing.T) { // Create a minimal config manually (skip init which needs root) configDir := filepath.Join(tmpHome, ".aigate") - os.MkdirAll(configDir, 0o755) + _ = os.MkdirAll(configDir, 0o755) configContent := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: []\nallow_net: []\n" - os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) + _ = os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) // Add deny rules cmd := exec.Command(bin, "deny", "read", ".env", "secrets/") @@ -157,9 +157,9 @@ func TestCLI_DenyExec(t *testing.T) { // Create minimal config configDir := filepath.Join(tmpHome, ".aigate") - os.MkdirAll(configDir, 0o755) + _ = os.MkdirAll(configDir, 0o755) configContent := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: []\nallow_net: []\n" - os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) + _ = os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) cmd := exec.Command(bin, "deny", "exec", "curl", "wget") cmd.Env = env @@ -179,9 +179,9 @@ func TestCLI_DenyExecSubcommand(t *testing.T) { // Create minimal config configDir := filepath.Join(tmpHome, ".aigate") - os.MkdirAll(configDir, 0o755) + _ = os.MkdirAll(configDir, 0o755) configContent := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: []\nallow_net: []\n" - os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) + _ = os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) // Add subcommand deny rule cmd := exec.Command(bin, "deny", "exec", "kubectl delete", "kubectl create") @@ -252,9 +252,9 @@ func TestCLI_RunNoArgs(t *testing.T) { // Create minimal config configDir := filepath.Join(tmpHome, ".aigate") - os.MkdirAll(configDir, 0o755) + _ = os.MkdirAll(configDir, 0o755) configContent := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: []\nallow_net: []\n" - os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) + _ = os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0o644) cmd := exec.Command(bin, "run") cmd.Env = env diff --git a/lefthook.yml b/lefthook.yml index ac5173d..edb1a4d 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,29 +1,36 @@ +# Lefthook configuration +# Install: go install github.com/evilmartians/lefthook@latest +# Setup: lefthook install + pre-commit: + parallel: true commands: gofmt: - run: | - gofmt -w . - git add . + glob: "*.go" + run: gofmt -l -w {staged_files} + stage_fixed: true + + govet: + glob: "*.go" + run: go vet ./... + + golangci-lint: + glob: "*.go" + run: golangci-lint run --timeout=5m -prepare-commit-msg: + structlint: + run: structlint validate --config .structlint.yaml --silent + +commit-msg: commands: commitlint: run: | - if [ -z "$1" ]; then - COMMIT_MSG_FILE=".git/COMMIT_EDITMSG" - else - COMMIT_MSG_FILE=$1 - fi - COMMIT_MSG=$(cat $COMMIT_MSG_FILE) - echo "$COMMIT_MSG" | grep -E '^((feat|fix|docs|style|refactor|test|chore)(\([a-zA-Z0-9_-]+\))?: .{1,})$' || { - echo "⛔️ Commit message does not follow the required pattern!" + msg=$(cat {1}) + if ! echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?(!)?: .+"; then + echo "Error: Commit message must follow conventional commits format" echo "Examples:" - echo " feat(auth): add login functionality" - echo " fix(api): resolve 500 error on user fetch" - echo " docs: update README with installation steps" - echo " style(ui): align buttons on the dashboard" - echo " refactor: improve performance of database queries" - echo " test(auth): add tests for login endpoint" - echo " chore(deps): update dependency versions" + echo " feat: add new feature" + echo " fix(sandbox): resolve namespace issue" + echo " docs: update README" exit 1 - } + fi diff --git a/services/config_service.go b/services/config_service.go index 5a7ccad..5445bc7 100644 --- a/services/config_service.go +++ b/services/config_service.go @@ -111,6 +111,12 @@ func (s *ConfigService) Merge(global, project *domain.Config) *domain.Config { if project.ResourceLimits.MaxPIDs > 0 { merged.ResourceLimits.MaxPIDs = project.ResourceLimits.MaxPIDs } + if len(project.MaskStdout.Presets) > 0 { + merged.MaskStdout.Presets = appendUnique(merged.MaskStdout.Presets, project.MaskStdout.Presets) + } + if len(project.MaskStdout.Patterns) > 0 { + merged.MaskStdout.Patterns = append(merged.MaskStdout.Patterns, project.MaskStdout.Patterns...) + } return &merged } @@ -160,6 +166,24 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config { MaxCPUPercent: 80, MaxPIDs: 1000, }, + MaskStdout: domain.MaskStdout{ + Presets: []string{ + "openai", + "anthropic", + "aws_key", + "github", + "bearer", + }, + Patterns: []domain.MaskPattern{ + { + // Catch generic key=value / key: value assignments for common secret names + // e.g. "API_KEY=abc123", "secret: mysecret", "password=hunter2" + Regex: `(?:api_?key|secret|password|passwd|token|credential)\s*[=:]\s*\S+`, + ShowPrefix: 0, + CaseInsensitive: true, + }, + }, + }, } } diff --git a/services/config_service_test.go b/services/config_service_test.go index dc460dd..bbc5b17 100644 --- a/services/config_service_test.go +++ b/services/config_service_test.go @@ -64,7 +64,7 @@ func TestLoadProject(t *testing.T) { AllowNet: []string{"registry.terraform.io"}, } data, _ := yaml.Marshal(&projectCfg) - os.WriteFile(filepath.Join(tmpDir, ".aigate.yaml"), data, 0o644) + _ = os.WriteFile(filepath.Join(tmpDir, ".aigate.yaml"), data, 0o644) svc := NewConfigService() loaded, err := svc.LoadProject(tmpDir) @@ -148,7 +148,7 @@ func TestConfigExists(t *testing.T) { } cfg := svc.InitDefaultConfig() - svc.SaveGlobal(cfg) + _ = svc.SaveGlobal(cfg) if !svc.ConfigExists() { t.Error("ConfigExists() should return true after save") diff --git a/services/masker.go b/services/masker.go new file mode 100644 index 0000000..33532ca --- /dev/null +++ b/services/masker.go @@ -0,0 +1,134 @@ +package services + +import ( + "bytes" + "fmt" + "io" + "regexp" + + "github.com/AxeForging/aigate/domain" +) + +// builtinPresets maps preset names to their regex pattern and how many leading +// bytes to preserve before the *** replacement (0 = fully masked). +var builtinPresets = map[string]struct { + pattern string + showPrefix int +}{ + // OpenAI: sk-... or sk-proj-... + "openai": {`sk-[a-zA-Z0-9\-_]{20,}`, 3}, + // Anthropic: sk-ant-api03-... + "anthropic": {`sk-ant-[a-zA-Z0-9\-_]{20,}`, 7}, + // AWS access key ID + "aws_key": {`AKIA[0-9A-Z]{16}`, 4}, + // GitHub PATs: ghp_, gho_, ghu_, ghs_, ghr_ + "github": {`gh[pousr]_[A-Za-z0-9_]{36,}`, 4}, + // Generic Bearer token in headers/logs + "bearer": {`(?i)Bearer [A-Za-z0-9._\-]{20,}`, 7}, +} + +// BuiltinPresetNames returns the sorted list of available preset names. +// Used for validation and documentation. +func BuiltinPresetNames() []string { + names := make([]string, 0, len(builtinPresets)) + for k := range builtinPresets { + names = append(names, k) + } + return names +} + +type maskRule struct { + re *regexp.Regexp + showPrefix int +} + +// MaskingWriter wraps an io.Writer and redacts secrets from each line before +// forwarding to the underlying writer. It buffers across Write calls so that +// secrets spanning chunk boundaries on the same line are still caught. +type MaskingWriter struct { + out io.Writer + rules []maskRule + buf []byte +} + +// NewMaskingWriter builds a MaskingWriter from the given MaskStdout config. +// Returns (nil, nil) when no presets or patterns are configured — callers +// should fall back to the raw writer in that case. +func NewMaskingWriter(out io.Writer, cfg domain.MaskStdout) (*MaskingWriter, error) { + var rules []maskRule + + for _, name := range cfg.Presets { + p, ok := builtinPresets[name] + if !ok { + return nil, fmt.Errorf("unknown mask_stdout preset %q (available: openai, anthropic, aws_key, github, bearer)", name) + } + re, err := regexp.Compile(p.pattern) + if err != nil { + return nil, fmt.Errorf("internal error compiling preset %q: %w", name, err) + } + rules = append(rules, maskRule{re: re, showPrefix: p.showPrefix}) + } + + for _, mp := range cfg.Patterns { + pattern := mp.Regex + if mp.CaseInsensitive { + pattern = "(?i)" + pattern + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid mask_stdout pattern %q: %w", mp.Regex, err) + } + rules = append(rules, maskRule{re: re, showPrefix: mp.ShowPrefix}) + } + + if len(rules) == 0 { + return nil, nil + } + return &MaskingWriter{out: out, rules: rules}, nil +} + +// Write buffers p, flushes complete lines through the redactor, and returns +// len(p) so callers treat the write as fully consumed. +func (m *MaskingWriter) Write(p []byte) (int, error) { + m.buf = append(m.buf, p...) + for { + idx := bytes.IndexByte(m.buf, '\n') + if idx < 0 { + break + } + line := m.buf[:idx+1] + if _, err := m.out.Write(m.redact(line)); err != nil { + return 0, err + } + m.buf = m.buf[idx+1:] + } + return len(p), nil +} + +// Flush writes any remaining buffered bytes (last line without a trailing newline). +// Call this after the child process exits. +func (m *MaskingWriter) Flush() error { + if len(m.buf) == 0 { + return nil + } + _, err := m.out.Write(m.redact(m.buf)) + m.buf = m.buf[:0] + return err +} + +// redact applies all masking rules to a single line. +func (m *MaskingWriter) redact(line []byte) []byte { + result := line + for _, rule := range m.rules { + result = rule.re.ReplaceAllFunc(result, func(match []byte) []byte { + if rule.showPrefix > 0 && len(match) > rule.showPrefix { + out := make([]byte, rule.showPrefix+3) + copy(out, match[:rule.showPrefix]) + copy(out[rule.showPrefix:], "***") + return out + } + return []byte("***") + }) + } + return result +} diff --git a/services/masker_test.go b/services/masker_test.go new file mode 100644 index 0000000..17a160a --- /dev/null +++ b/services/masker_test.go @@ -0,0 +1,356 @@ +package services + +import ( + "bytes" + "strings" + "testing" + + "github.com/AxeForging/aigate/domain" +) + +func TestNewMaskingWriter_NilWhenEmpty(t *testing.T) { + mw, err := NewMaskingWriter(&bytes.Buffer{}, domain.MaskStdout{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mw != nil { + t.Fatal("expected nil MaskingWriter when no rules configured") + } +} + +func TestNewMaskingWriter_UnknownPreset(t *testing.T) { + _, err := NewMaskingWriter(&bytes.Buffer{}, domain.MaskStdout{Presets: []string{"not-a-preset"}}) + if err == nil { + t.Fatal("expected error for unknown preset") + } + if !strings.Contains(err.Error(), "not-a-preset") { + t.Errorf("error should name the bad preset, got: %v", err) + } +} + +func TestNewMaskingWriter_InvalidCustomPattern(t *testing.T) { + cfg := domain.MaskStdout{ + Patterns: []domain.MaskPattern{{Regex: "[invalid"}}, + } + _, err := NewMaskingWriter(&bytes.Buffer{}, cfg) + if err == nil { + t.Fatal("expected error for invalid regex") + } +} + +func TestMaskingWriter_OpenAIPreset(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"openai"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "Using key sk-abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKL\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "sk-abcdefghijklmnopqrstuvwxyz") { + t.Errorf("output should not contain the raw key: %q", out) + } + if !strings.Contains(out, "sk-***") { + t.Errorf("output should contain masked key prefix, got: %q", out) + } +} + +func TestMaskingWriter_AnthropicPreset(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"anthropic"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "key=sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "api03") { + t.Errorf("output should not contain raw anthropic key: %q", out) + } + if !strings.Contains(out, "sk-ant-***") { + t.Errorf("output should contain 'sk-ant-***', got: %q", out) + } +} + +func TestMaskingWriter_AWSKeyPreset(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"aws_key"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "AKIAIOSFODNN7EXAMPLE") { + t.Errorf("output should not contain raw AWS key: %q", out) + } + if !strings.Contains(out, "AKIA***") { + t.Errorf("output should contain masked AWS key prefix, got: %q", out) + } +} + +func TestMaskingWriter_GitHubPreset(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"github"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "token: ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "ghp_AAAA") { + t.Errorf("output should not contain raw GitHub token: %q", out) + } + if !strings.Contains(out, "ghp_***") { + t.Errorf("output should contain masked GitHub prefix, got: %q", out) + } +} + +func TestMaskingWriter_BearerPreset(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"bearer"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "eyJhbGci") { + t.Errorf("output should not contain raw bearer token: %q", out) + } + if !strings.Contains(out, "Bearer ***") { + t.Errorf("output should contain 'Bearer ***', got: %q", out) + } +} + +func TestMaskingWriter_CustomPattern_FullMask(t *testing.T) { + var buf bytes.Buffer + cfg := domain.MaskStdout{ + Patterns: []domain.MaskPattern{ + {Regex: `mysecret-[a-z0-9]+`, ShowPrefix: 0}, + }, + } + mw, err := NewMaskingWriter(&buf, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "value=mysecret-abc123xyz\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "mysecret-abc123xyz") { + t.Errorf("output should not contain raw secret: %q", out) + } + if !strings.Contains(out, "***") { + t.Errorf("output should contain ***, got: %q", out) + } +} + +func TestMaskingWriter_CustomPattern_ShowPrefix(t *testing.T) { + var buf bytes.Buffer + cfg := domain.MaskStdout{ + Patterns: []domain.MaskPattern{ + {Regex: `token-[a-zA-Z0-9]{16}`, ShowPrefix: 6}, + }, + } + mw, err := NewMaskingWriter(&buf, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "token-ABCDEFGHIJKLMNOP logged\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "token-***") { + t.Errorf("expected 'token-***', got: %q", out) + } + if strings.Contains(out, "ABCDEF") { + t.Errorf("should not contain characters after prefix, got: %q", out) + } +} + +func TestMaskingWriter_NoFalsePositives(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"openai", "github", "aws_key"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "hello world, no secrets here\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if out != input { + t.Errorf("clean line should pass through unchanged, got: %q", out) + } +} + +func TestMaskingWriter_MultipleWritesAcrossChunks(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"openai"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Write in two chunks, split mid-key + chunk1 := "key=sk-abcdefghijklmnopqrst" + chunk2 := "uvwxyz1234567890ABCDEFGHIJKL\n" + if _, err := mw.Write([]byte(chunk1)); err != nil { + t.Fatalf("Write chunk1 error: %v", err) + } + if _, err := mw.Write([]byte(chunk2)); err != nil { + t.Fatalf("Write chunk2 error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "sk-abcdefghijklmnopqrstuv") { + t.Errorf("key split across chunks should still be masked: %q", out) + } +} + +func TestMaskingWriter_FlushIncompleteLastLine(t *testing.T) { + var buf bytes.Buffer + cfg := domain.MaskStdout{ + Patterns: []domain.MaskPattern{{Regex: `secret`, ShowPrefix: 0}}, + } + mw, err := NewMaskingWriter(&buf, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // No trailing newline + if _, err := mw.Write([]byte("contains secret")); err != nil { + t.Fatalf("Write error: %v", err) + } + + // Nothing flushed yet + if buf.Len() != 0 { + t.Errorf("incomplete line should be buffered, got: %q", buf.String()) + } + + if err := mw.Flush(); err != nil { + t.Fatalf("Flush error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "secret") { + t.Errorf("flushed content should be masked: %q", out) + } + if !strings.Contains(out, "***") { + t.Errorf("flushed content should contain ***, got: %q", out) + } +} + +func TestMaskingWriter_MultipleSecretsOnOneLine(t *testing.T) { + var buf bytes.Buffer + mw, err := NewMaskingWriter(&buf, domain.MaskStdout{Presets: []string{"openai", "github"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + input := "openai=sk-abcdefghijklmnopqrstuvwxyz123456789012 github=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "abcdefghijk") { + t.Errorf("openai key should be masked: %q", out) + } + if strings.Contains(out, "ghp_AAAA") { + t.Errorf("github token should be masked: %q", out) + } +} + +func TestMaskingWriter_CaseInsensitive(t *testing.T) { + var buf bytes.Buffer + cfg := domain.MaskStdout{ + Patterns: []domain.MaskPattern{ + {Regex: `mysecret-[a-z0-9]+`, ShowPrefix: 0, CaseInsensitive: true}, + }, + } + mw, err := NewMaskingWriter(&buf, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, input := range []string{ + "value=MYSECRET-abc123\n", + "value=mysecret-abc123\n", + "value=MySecret-ABC123\n", + } { + buf.Reset() + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "***") { + t.Errorf("case-insensitive match failed for input %q, got: %q", input, out) + } + } +} + +func TestMaskingWriter_CaseSensitiveByDefault(t *testing.T) { + var buf bytes.Buffer + cfg := domain.MaskStdout{ + Patterns: []domain.MaskPattern{ + {Regex: `mysecret-[a-z0-9]+`, ShowPrefix: 0}, + }, + } + mw, err := NewMaskingWriter(&buf, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // uppercase should NOT match when case_insensitive is false + input := "value=MYSECRET-abc123\n" + if _, err := mw.Write([]byte(input)); err != nil { + t.Fatalf("Write error: %v", err) + } + out := buf.String() + if out != input { + t.Errorf("case-sensitive pattern should not match uppercase, got: %q", out) + } +} + +func TestBuiltinPresetNames(t *testing.T) { + names := BuiltinPresetNames() + expected := []string{"openai", "anthropic", "aws_key", "github", "bearer"} + nameSet := make(map[string]bool, len(names)) + for _, n := range names { + nameSet[n] = true + } + for _, want := range expected { + if !nameSet[want] { + t.Errorf("expected preset %q in BuiltinPresetNames()", want) + } + } +} diff --git a/services/platform.go b/services/platform.go index 85f2ec3..5a20c57 100644 --- a/services/platform.go +++ b/services/platform.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "io" "os" "os/exec" "path/filepath" @@ -22,13 +23,14 @@ type Platform interface { SetFileACLDeny(group string, patterns []string, workDir string) error RemoveFileACL(group string, patterns []string, workDir string) error ListACLs(workDir string) ([]string, error) - RunSandboxed(profile domain.SandboxProfile, cmd string, args []string) error + RunSandboxed(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error } // Executor abstracts command execution for testability. type Executor interface { Run(name string, args ...string) ([]byte, error) RunPassthrough(name string, args ...string) error + RunPassthroughWith(stdout, stderr io.Writer, name string, args ...string) error } // RealExecutor executes real OS commands. @@ -39,10 +41,14 @@ func (e *RealExecutor) Run(name string, args ...string) ([]byte, error) { } func (e *RealExecutor) RunPassthrough(name string, args ...string) error { + return e.RunPassthroughWith(os.Stdout, os.Stderr, name, args...) +} + +func (e *RealExecutor) RunPassthroughWith(stdout, stderr io.Writer, name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = stdout + cmd.Stderr = stderr return cmd.Run() } diff --git a/services/platform_darwin.go b/services/platform_darwin.go index e8ed647..9c7f96b 100644 --- a/services/platform_darwin.go +++ b/services/platform_darwin.go @@ -4,6 +4,7 @@ package services import ( "fmt" + "io" "os" "os/exec" "path/filepath" @@ -179,7 +180,7 @@ func (p *DarwinPlatform) ListACLs(workDir string) ([]string, error) { return results, nil } -func (p *DarwinPlatform) RunSandboxed(profile domain.SandboxProfile, cmd string, args []string) error { +func (p *DarwinPlatform) RunSandboxed(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { // Generate sandbox-exec profile sbProfile := generateSeatbeltProfile(profile) @@ -199,7 +200,7 @@ func (p *DarwinPlatform) RunSandboxed(profile domain.SandboxProfile, cmd string, // Run command under sandbox-exec sandboxArgs := []string{"-f", tmpFile.Name(), cmd} sandboxArgs = append(sandboxArgs, args...) - return p.exec.RunPassthrough("sandbox-exec", sandboxArgs...) + return p.exec.RunPassthroughWith(stdout, stderr, "sandbox-exec", sandboxArgs...) } func generateSeatbeltProfile(profile domain.SandboxProfile) string { diff --git a/services/platform_linux.go b/services/platform_linux.go index 1df8d91..2012332 100644 --- a/services/platform_linux.go +++ b/services/platform_linux.go @@ -6,6 +6,7 @@ import ( "bufio" "encoding/base64" "fmt" + "io" "net" "os" "os/exec" @@ -154,7 +155,7 @@ func (p *LinuxPlatform) RemoveFileACL(group string, patterns []string, workDir s helpers.Log.Warn().Str("path", path).Str("output", string(out)).Msg("failed to remove ACL") } defaultEntry := fmt.Sprintf("d:g:%s", group) - p.exec.Run("setfacl", "-R", "-x", defaultEntry, path) + _, _ = p.exec.Run("setfacl", "-R", "-x", defaultEntry, path) } return nil } @@ -174,18 +175,18 @@ func (p *LinuxPlatform) ListACLs(workDir string) ([]string, error) { return results, nil } -func (p *LinuxPlatform) RunSandboxed(profile domain.SandboxProfile, cmd string, args []string) error { +func (p *LinuxPlatform) RunSandboxed(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { if len(profile.Config.AllowNet) > 0 { if hasSlirp4netns() { - return p.runWithNetFilter(profile, cmd, args) + return p.runWithNetFilter(profile, cmd, args, stdout, stderr) } helpers.Log.Warn().Msg("slirp4netns not found; network filtering unavailable, running without network restrictions") } - return p.runUnshare(profile, cmd, args) + return p.runUnshare(profile, cmd, args, stdout, stderr) } // runUnshare runs a command in a user/mount/pid namespace without network filtering. -func (p *LinuxPlatform) runUnshare(profile domain.SandboxProfile, cmd string, args []string) error { +func (p *LinuxPlatform) runUnshare(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { unshareArgs := []string{ "--mount", // Mount namespace "--pid", // PID namespace @@ -207,7 +208,7 @@ func (p *LinuxPlatform) runUnshare(profile domain.SandboxProfile, cmd string, ar sb.WriteString("\n") fullArgs := append(unshareArgs, "sh", "-c", sb.String()) - return p.exec.RunPassthrough("unshare", fullArgs...) + return p.exec.RunPassthroughWith(stdout, stderr, "unshare", fullArgs...) } // hasSlirp4netns checks whether slirp4netns is available on the system. @@ -298,7 +299,7 @@ func parseDNSFromFile(path string) []string { // slirp4netns must run INSIDE the user namespace to have CAP_SYS_ADMIN for // setns(CLONE_NEWNET). Launching it from the host fails with EPERM because an // unprivileged process lacks CAP_SYS_ADMIN in its own (init) user namespace. -func (p *LinuxPlatform) runWithNetFilter(profile domain.SandboxProfile, cmd string, args []string) error { +func (p *LinuxPlatform) runWithNetFilter(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { dnsServers := getSystemDNS() helpers.Log.Info(). Strs("allow_net", profile.Config.AllowNet). @@ -308,7 +309,7 @@ func (p *LinuxPlatform) runWithNetFilter(profile domain.SandboxProfile, cmd stri innerScript := buildNetFilterScript(profile.Config.AllowNet, dnsServers, profile, cmd, args) outerScript := buildOrchestrationScript(innerScript) - return p.exec.RunPassthrough("unshare", "--user", "--map-root-user", "--", "sh", "-c", outerScript) + return p.exec.RunPassthroughWith(stdout, stderr, "unshare", "--user", "--map-root-user", "--", "sh", "-c", outerScript) } // buildOrchestrationScript wraps the inner sandbox script with the two-process diff --git a/services/platform_linux_test.go b/services/platform_linux_test.go index b7ea060..431315a 100644 --- a/services/platform_linux_test.go +++ b/services/platform_linux_test.go @@ -5,6 +5,7 @@ package services import ( "encoding/base64" "fmt" + "io" "os" "strings" "testing" @@ -46,6 +47,10 @@ func (m *mockExecutor) Run(name string, args ...string) ([]byte, error) { } func (m *mockExecutor) RunPassthrough(name string, args ...string) error { + return m.RunPassthroughWith(os.Stdout, os.Stderr, name, args...) +} + +func (m *mockExecutor) RunPassthroughWith(_ io.Writer, _ io.Writer, name string, args ...string) error { m.calls = append(m.calls, mockCall{Name: name, Args: args}) key := name if len(args) > 0 { @@ -424,7 +429,7 @@ func TestRunSandboxedDispatch(t *testing.T) { Config: domain.Config{AllowNet: nil}, WorkDir: "/tmp", } - _ = p.RunSandboxed(profile, "echo", []string{"hello"}) + _ = p.RunSandboxed(profile, "echo", []string{"hello"}, os.Stdout, os.Stderr) if mock.callCount() == 0 { t.Fatal("expected executor to be called") } @@ -455,7 +460,7 @@ func TestRunSandboxedDispatch(t *testing.T) { Config: domain.Config{AllowNet: []string{"example.com"}}, WorkDir: "/tmp", } - _ = p.RunSandboxed(profile, "echo", []string{"hello"}) + _ = p.RunSandboxed(profile, "echo", []string{"hello"}, os.Stdout, os.Stderr) if mock.callCount() == 0 { t.Fatal("expected executor to be called via runUnshare fallback") } @@ -596,7 +601,7 @@ func TestResolvePatterns_TildeDir(t *testing.T) { t.Setenv("HOME", tmpDir) // Create ~/.ssh/ directory - os.MkdirAll(tmpDir+"/.ssh", 0o755) + _ = os.MkdirAll(tmpDir+"/.ssh", 0o755) paths, err := resolvePatterns([]string{"~/.ssh/"}, "/irrelevant") if err != nil { @@ -802,7 +807,7 @@ func TestBuildMountOverrides_QuotesPaths(t *testing.T) { func TestBuildMountOverrides_DirMount(t *testing.T) { tmpDir := t.TempDir() - os.MkdirAll(tmpDir+"/secrets", 0o755) + _ = os.MkdirAll(tmpDir+"/secrets", 0o755) profile := domain.SandboxProfile{ Config: domain.Config{ @@ -820,9 +825,8 @@ func TestBuildMountOverrides_DirMount(t *testing.T) { t.Error("dir mount should have || true for resilience") } // Should NOT create the file deny marker (no file mounts) - if strings.Contains(result, "/tmp/.aigate-denied\n") { - // This would be the file marker creation, which shouldn't exist - // when there are only directory mounts + if strings.Contains(result, "printf '[aigate]") && strings.Contains(result, "/tmp/.aigate-denied\n") { + t.Error("file deny marker should not be created when there are only directory mounts") } } @@ -833,7 +837,7 @@ func TestRunUnshare_MountMakeRprivate(t *testing.T) { Config: domain.Config{}, WorkDir: "/tmp", } - _ = p.RunSandboxed(profile, "echo", []string{"hello"}) + _ = p.RunSandboxed(profile, "echo", []string{"hello"}, os.Stdout, os.Stderr) if mock.callCount() == 0 { t.Fatal("expected executor to be called") diff --git a/services/platform_other.go b/services/platform_other.go index 4d245c1..74f2135 100644 --- a/services/platform_other.go +++ b/services/platform_other.go @@ -3,6 +3,8 @@ package services import ( + "io" + "github.com/AxeForging/aigate/domain" "github.com/AxeForging/aigate/helpers" ) @@ -27,7 +29,7 @@ func (p *unsupportedPlatform) SetFileACLDeny(string, []string, string) error { r func (p *unsupportedPlatform) RemoveFileACL(string, []string, string) error { return errUnsupported } func (p *unsupportedPlatform) ListACLs(string) ([]string, error) { return nil, errUnsupported } -func (p *unsupportedPlatform) RunSandboxed(_ domain.SandboxProfile, _ string, _ []string) error { +func (p *unsupportedPlatform) RunSandboxed(_ domain.SandboxProfile, _ string, _ []string, _, _ io.Writer) error { return errUnsupported } diff --git a/services/rule_service_test.go b/services/rule_service_test.go index d72ac57..11ce724 100644 --- a/services/rule_service_test.go +++ b/services/rule_service_test.go @@ -49,7 +49,7 @@ func TestAddDenyRule_NoDuplicates(t *testing.T) { svc := NewRuleService() cfg := &domain.Config{DenyRead: []string{".env"}} - svc.AddDenyRule(cfg, domain.RuleTypeRead, []string{".env", "secrets/"}) + _ = svc.AddDenyRule(cfg, domain.RuleTypeRead, []string{".env", "secrets/"}) if len(cfg.DenyRead) != 2 { t.Errorf("DenyRead len = %d, want 2 (no duplicates)", len(cfg.DenyRead)) } diff --git a/services/runner_service.go b/services/runner_service.go index d620375..da83124 100644 --- a/services/runner_service.go +++ b/services/runner_service.go @@ -2,6 +2,8 @@ package services import ( "fmt" + "io" + "os" "path/filepath" "strings" @@ -39,5 +41,28 @@ func (s *RunnerService) Run(profile domain.SandboxProfile, cmd string, args []st } } - return s.platform.RunSandboxed(profile, cmd, args) + stdout, stderr := buildOutputWriters(profile.Config.MaskStdout) + if mw, ok := stdout.(*MaskingWriter); ok { + defer mw.Flush() //nolint:errcheck + } + if mw, ok := stderr.(*MaskingWriter); ok { + defer mw.Flush() //nolint:errcheck + } + return s.platform.RunSandboxed(profile, cmd, args, stdout, stderr) +} + +// buildOutputWriters returns (stdout, stderr) writers with masking applied when +// mask_stdout is configured. Falls back to os.Stdout/os.Stderr on error or when +// no masking rules are defined. +func buildOutputWriters(cfg domain.MaskStdout) (io.Writer, io.Writer) { + outMasker, err := NewMaskingWriter(os.Stdout, cfg) + if err != nil { + helpers.Log.Warn().Err(err).Msg("mask_stdout configuration error; output masking disabled") + return os.Stdout, os.Stderr + } + if outMasker == nil { + return os.Stdout, os.Stderr + } + errMasker, _ := NewMaskingWriter(os.Stderr, cfg) + return outMasker, errMasker } diff --git a/services/runner_service_test.go b/services/runner_service_test.go index e0adca7..06c1449 100644 --- a/services/runner_service_test.go +++ b/services/runner_service_test.go @@ -2,6 +2,7 @@ package services import ( "errors" + "io" "testing" "github.com/AxeForging/aigate/domain" @@ -25,7 +26,7 @@ func (m *mockPlatform) UserExists(string) (bool, error) { return f func (m *mockPlatform) SetFileACLDeny(string, []string, string) error { return nil } func (m *mockPlatform) RemoveFileACL(string, []string, string) error { return nil } func (m *mockPlatform) ListACLs(string) ([]string, error) { return nil, nil } -func (m *mockPlatform) RunSandboxed(_ domain.SandboxProfile, cmd string, args []string) error { +func (m *mockPlatform) RunSandboxed(_ domain.SandboxProfile, cmd string, args []string, _, _ io.Writer) error { m.runSandboxedCalled = true m.runSandboxedCmd = cmd m.runSandboxedArgs = args